JavaScript — Consume the DevExpress Backend Web API with Svelte (Part 4. Edit and Validate Data)

News
04 May 2023

In the first part of this post series, I described how to set up a Svelte Kit project to load data from the DevExpress Web API service. The second part described how I queried metadata from the service, in order to display captions customized in the Application Model. Part 3 covered sorting and filtering of data, as examples of data retrieval customized dynamically by the user at runtime.

You can find source code for each stage of the demo in the GitHub repository. Here is a GitHub sample for the current post: Stage 4 - Edit and validate data.

Note that the Web API functionality (XAF's Validation Module) described in this post requires a Universal Subscription license.

Table of Contents

Persist Objects Via OData

The standard data manipulation interface made available by the Web API Service uses the OData standard. Microsoft has comprehensive documentation on the various endpoints and URL structures that are used by this protocol. You can also find dozens of code examples in our Web API Service documentation as well: Make HTTP Requests to the Web API. In this step of the sample project I will show the use of a POST call to create a new entity, and a PATCH call to modify an entity.

You can try these calls from the command line, since they are basic HTTP requests. Alternatively, as I described previously in the second part of this blog series, UI clients are available to make testing of HTTP APIs easier. In all cases, the information published by the Swagger interface at http://localhost:5273/swagger in the sample project is useful to list the available endpoints and find out the correct syntax.

Here is an example session at the command line, which may help you get started with your own explorations. First, you see all entities of type SaleProduct queried from the backend. Then, a new SaleProduct is created using a POST call, modified with a PATCH call and then queried individually to confirm the modification.

Note that jq is a convenient command line tool that handles JSON — invoked without options, it simply formats its input nicely so it’s easier to read. It also has powerful features to query and reshape JSON, so I recommend you check it out!

> curl http://localhost:5273/api/odata/SaleProduct | jq
{
  "@odata.context": "http://localhost:5273/api/odata/$metadata#SaleProduct",
  "value": [
    {
      "ID": "08d7df5d-ca05-48f3-66fc-08db35e6b738",
      "Name": "Rubber Chicken",
      "Price": 13.99
    },
    {
      "ID": "078b4ebe-d6b3-43ce-66fd-08db35e6b738",
      "Name": "Pulley",
      "Price": 3.99
    },
    {
      "ID": "e135b865-eac2-46dc-66fe-08db35e6b738",
      "Name": "Starship Enterprise",
      "Price": 149999999.99
    },
    {
      "ID": "68753142-114f-49d3-66ff-08db35e6b738",
      "Name": "The Lost Ark",
      "Price": 1000000000000
    }
  ]
}

> curl -X POST http://localhost:5273/api/odata/SaleProduct \
	-H "Content-Type: application/json" \
	-d '{ "Name": "Test Item", "Price": 17.99 }' | jq
{
  "@odata.context": "http://localhost:5273/api/odata/$metadata#SaleProduct/$entity",
  "ID": "c67c4dcf-4327-4ed4-ddb7-08db46429611",
  "Name": "Test Item",
  "Price": 17.99
}

> curl -X PATCH http://localhost:5273/api/odata/SaleProduct/c67c4dcf-4327-4ed4-ddb7-08db46429611 \
	-H "Content-Type: application/json" \
	-d '{ "Name": "Test Item - changed" }' | jq

> curl http://localhost:5273/api/odata/SaleProduct/c67c4dcf-4327-4ed4-ddb7-08db46429611 | jq
{
  "@odata.context": "http://localhost:5273/api/odata/$metadata#SaleProduct/$entity",
  "ID": "c67c4dcf-4327-4ed4-ddb7-08db46429611",
  "Name": "Test Item - changed",
  "Price": 17.99
}

Use a Form to Edit Data

Svelte has very good support for edit forms, so the syntax does not need to be very complex for the edit form implementation. Note that no client-side validation is applied here — it would be an extra job for the developer to add this. For the sample I’m going to rely on the server-side validation that is supported directly by the Web API service.

Here is the complete code for the form. If you are following along, create a new file src/routes/saleProducts/edit/[[productId]]/+page.svelte and insert this code.

<script>
	import { enhance } from '$app/forms';

	export let data;
	$: ({ product, schema } = data);

	export let form;
</script>

{#if product.ID}
	<h2>Edit {schema['$$classCaption']} "{product.Name}"</h2>
{:else}
	<h2>Create new product</h2>
{/if}

<form method="post" use:enhance>
	<input type="hidden" name="ID" value={product.ID || ''} />
	<table class="border-y-2 w-full">
		<tr>
			<td><label for="name">{schema.Name}</label></td>
			<td><input type="text" id="name" name="Name" bind:value={product.Name} /></td>
		</tr>
		<tr>
			<td><label for="price">{schema.Price}</label></td>
			<td>
				<input	class="text-right" type="number"
					id="price" name="Price" step={0.01}
					bind:value={product.Price} />
			</td>
		</tr>
	</table>
	{#if form?.error}
		<div class="bg-red-200 rounded w-full p-2 m-2 whitespace-pre-line">{form.error}</div>
	{/if}
	<div class="flex mt-4">
		<button class="ml-auto bg-green-200 px-4 py-1 rounded hover:bg-red-200" type="submit">
			Save
		</button>
	</div>
</form>

<style lang="postcss">
	input {
		@apply border px-2 w-full;
	}
	label {
		@apply mx-2;
	}
	td {
		@apply py-2;
	}
</style>

The form is basic, but the implementation takes advantage of Svelte-specific features. The script block exports a form property in addition to the usual data property. This is used automatically in the submit cycle, to send data back to the form, if necessary. In this sample, it is used to carry error values, which are evaluated in the bottom part of the form. It is possible to use the same mechanism to return other details to the form, as required.

Together with the general topic of progressive enhancement, the details of the submit cycle are explained on this page of the Svelte Kit documentation. The enhance action, which you can see applied to the <form> tag in code, enables some the advanced enhancement features.

The conditional blocks in code distinguish between the case where the product object includes an ID, and the case where it doesn’t — the former occurs when an existing item is edited, the latter when a new item is created. In the path you created for this Svelte component, the syntax [[productId]] denotes that the path parameter productId is optional. OData does not allow a POST call to include an ID value for a newly created object, so we start out without an ID. When the ID is known after creation, it can be added to the URL.

The final interesting detail you may notice is that the <form> tag declares method="post", but no action attribute is specified. You may imagine that Svelte Kit has a default for the POST callback to the server, like other frameworks do — specifically ASP.NET uses similar approaches. This is exactly what happens, and since the form includes just one submit button, no further configuration is necessary. The Svelte Kit documentation includes details about named actions, which you need in case you want to trigger multiple actions from the same form.

Server Code to Load a SaleProduct

In the same path as the last +page.svelte, add a file called +page.server.js now. Then create the load function, like this:

export function load({ fetch, params }) {
	let product = { Name: '', Price: 0.0 };
	if (params.productId) {
		product = fetch(`http://webapi:5273/api/odata/SaleProduct/${params.productId}`).then((res) =>
			res.json()
		);
	}
	const schema = fetch('/api/schema/XAFApp.Module.BusinessObjects.SaleProduct').then((res) =>
		res.json()
	);
	return { product, schema };
}

This function includes most of the logic you may have missed in the component. It is called by the framework when data has to be loaded — obviously this occurs when the page is activated, but it can also occur ahead of time when the user hovers the mouse over a link, or during server-side rendering.

As you know, the productId is optional. A default product is created and potentially returned, but if a productId is specified in the URL, the corresponding product is fetched from the Web API Service instead.

The return statement combines the data object with the schema. Since you prepared this functionality as part of the second post in this blog series, it is easy to reuse now. I didn’t point this out, but the schema details are used in the edit form to render the field labels, so the form is consistent with the column headers in the overview page.

Note that Svelte Kit applies special handling to the top level elements of the returned structure. If they are promises, they are awaited automatically before being returned. That’s important in this example, because it means that product is always a simple data structure, even though it could be either a plain object or a promise based on the code in the load function alone. For more details about promises in Svelte Kit return values, see this documentation page.

Finally, to point out the possibly obvious: the return value is fed into the property data of the page component. You have seen this before so it shouldn’t come as a surprise, but in this case the data flow is more complicated than before due to the bit that’s missing so far: the form action.

Add a Form Action to Save an Entity

Once more in src/routes/saleProducts/edit/[[productId]]/+page.server.js, add the form action default using the following code:

export const actions = {
	default: async ({ request, fetch }) => {
		const formData = Object.fromEntries(await request.formData());
		const postData = { Name: formData.Name, Price: parseFloat(formData.Price) || 0 };

		if (!formData.ID) {
			// POST a new product
		} else {
			// PATCH an existing product
		}
	}
};

The function default is the action that will be called by Svelte Kit if a <form> is submitted that does not include an action attribute. If you use named actions in more complex cases, you will simply have more than one item instead of default.

Each action function receives an event parameter, which includes the parts you would expect: the request object, parameters and route info, access to cookies and headers, and the usual fetch reference you can see in code. Read the form data from the request as shown, and there may be an ID included or not — this distinguishes whether a new entity is being created or an existing one modified. The two cases have a few details in common, but there are important differences so I’ll show them separately. Here is the code to create a new entity:

if (!formData.ID) {
	const response = await fetch(`http://webapi:5273/api/odata/SaleProduct`, {
		method: 'POST',
		headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
		body: JSON.stringify(postData)
	});
	if (response.ok) {
		const json = await response.json();
		throw redirect(303, `/saleProducts/edit/${json.ID}`);
	} else {
		return fail(400, { error: await response.text() });
	}
}

The postData is sent with the fetch request. Then, if everything has gone well, the response object provides access to the newly generated ID — compare to the command line example above. Using this ID, a redirect is executed (in Svelte Kit this happens by way of an exception) so that the same edit form is displayed again, but this time for the existing object with the new ID.

The error handling takes advantage of the form property in the page component, which I mentioned before. The value object returned with the fail helper ends up in the form property. It is up to you which information you want to send to the form in this case, and some common patterns return the complete formData for further evaluation. This is not necessary in this case, but it’s interesting to see how flexible this simple mechanism is.

The second block of code deals with the modification of an entity. Here it is:

	...
} else {
	const response = await fetch(`http://webapi:5273/api/odata/SaleProduct/${formData.ID}/`, {
		method: 'PATCH',
		headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
		body: JSON.stringify(postData)
	});
	if (response.ok) {
		return null;
	} else {
		return fail(400, { error: await response.text() });
	}
}

One difference is the use of the PATCH HTTP verb, but the handling of the success case is also different: it returns null because error information is not required in this case. Since the null result is returned as a success value, the default handling on the client is to reload the page. The user can continue editing in a new instance of the form. Note that the details of this behavior depend on the enhance function mentioned above, and you can customize them as needed.

A few additions to the component in src/lib/DataTable.svelte are required so that users can navigate to the new edit form, both to create new SaleProduct entities and to modify existing ones. I decided to add a new column to the table that can work as a “command column”. The “new” and “edit” buttons can then be added to that column, leaving room for other extensions.

For flexibility, add the property editBaseUrl to the component first:

...
export let dataSource;
export let fields;
export let schema = {};
export let displayState = {
	sort: undefined,
	desc: false,
	filters: {}
};
export let editBaseUrl;
...

Add the highlighted blocks to the table rendering of the component:

<table class="border-separate w-full">
	<tr>
		{#each Object.keys(fields) as f}
			...
		{/each}
		<th class="action">
			{#if editBaseUrl}
				<a href={editBaseUrl} alt="Create"><span class="fa fa-star-o" /></a>
			{/if}
		</th>
	</tr>
	<tr class="filterRow">
		{#each Object.keys(fields) as f}
			...
		{/each}
		<td class="action" />
	</tr>
	{#await dataSource}
		<tr class="placeholder"><td colspan="2">Waiting for data</td></tr>
	{:then dataSourceContent}
		{#if dataSourceContent && Array.isArray(dataSourceContent)}
			{#each dataSourceContent as item}
				<tr>
					{#each Object.keys(fields) as f}
						<td class={fields[f].class}>{item[f]}</td>
					{/each}
					<td class="action">
						{#if editBaseUrl}
							<a href="{editBaseUrl}/{item[schema['$$idField']]}" alt="Edit"
								><span class="fa fa-edit" /></a
							>
						{/if}
					</td>
				</tr>
	...

Now include the following code in the style block at the bottom of the file:

	...

	.action {
		@apply bg-green-200 text-center;
	}
	.action a {
		@apply border-2 rounded bg-white px-2 py-0.5 hover:bg-orange-200;
	}
</style>

Finally, edit src/routes/saleProducts/+page.svelte and pass the property editBaseUrl to the component:

...
<DataTable
	{dataSource}
	{fields}
	{schema}
	{displayState}
	on:displayStateChanged={displayStateChanged($page.url.pathname)}
	editBaseUrl="/saleProducts/edit"
/>
...

Create and Edit Sale Products

The new functionality is now ready to try. In the top right corner of the data table, click the New button to create a new entity.

You’ll see that the URL addresses the edit form, but it does not include an ID at this point. Enter some test data, click Save, and the form will be redirected to a URL that includes the new ID while the edit content remains unchanged.

Navigate back to the overview page and edit an item to be sure that the mechanism works fully!

Utilize Server-Side Validation

The Web API service can take advantage of validation by using the XAF Validation Module. This is a powerful rule-based system which includes many predefined rule types and the option to create custom ones. It covers simple value-based validation techniques as well as complex structural concerns. All rules can be applied either from code, using attributes, or by editing the Application Model as I described in second part of this blog series. Note that the XAF Validation Module requires a DevExpress Universal Subscription.

Detailed instructions to enable the Validation Module for your Web API service can found in the documentation. The following description is complete for the sample project and replicates parts of the documentation.

Begin by adding the required NuGet packages to your projects. For the Module and WebApi projects, add DevExpress.ExpressApp.Validation. If you’d like to test validation with the Blazor app as well, use the package DevExpress.ExpressApp.Validation.Blazor.

Note that if you are running the sample setup in Docker as I suggested previously, you need to restart the server-side projects before the project file changes are recognized correctly. I recommend to do this once, after you’ve applied all changes described below. By the way, the Svelte app does not need to restarted — it’s ready to go, as it is!

Edit the file Module.cs in your Module project. Add a line to the constructor code, so that the Validation Module is included in the module setup.

RequiredModuleTypes.Add(typeof(DevExpress.ExpressApp.SystemModule.SystemModule));
RequiredModuleTypes.Add(typeof(DevExpress.ExpressApp.Objects.BusinessClassLibraryCustomizationModule));
RequiredModuleTypes.Add(typeof(DevExpress.ExpressApp.Validation.ValidationModule));

Add the service implementation file to the Module project as Services/ValidatingDataService.cs. This is the standard implementation from the docs, which can be used unchanged in many cases. On the other hand, remember that you added this yourself, since it’s also possible to make changes here if you’d like validation to be applied differently for your project.

using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Core;
using DevExpress.ExpressApp.DC;
using DevExpress.ExpressApp.WebApi.Services;
using DevExpress.Persistent.Validation;

namespace XAFApp.WebApi.Core;

public class ValidatingDataService : DataService {
  readonly IValidator validator;
  public ValidatingDataService(IObjectSpaceFactory objectSpaceFactory,
   ITypesInfo typesInfo, IValidator validator)
   : base(objectSpaceFactory, typesInfo) {
    this.validator = validator;
  }

  protected override IObjectSpace CreateObjectSpace(Type objectType) {
    IObjectSpace objectSpace = base.CreateObjectSpace(objectType);
    objectSpace.Committing += ObjectSpace_Committing;
    return objectSpace;
  }

  private void ObjectSpace_Committing(object? sender,
    System.ComponentModel.CancelEventArgs e) {
    IObjectSpace os = (IObjectSpace)sender!;
    var validationResult = validator.RuleSet.ValidateAllTargets(
        os, os.ModifiedObjects, DefaultContexts.Save
    );
    if (validationResult.ValidationOutcome == ValidationOutcome.Error) {
      throw new ValidationException(validationResult);
    }
  }
}

In the file Startup.cs in the Module project, add a line that makes the new service available to the ASP.NET Core infrastructure.

services.AddScoped<IDataService, XAFApp.WebApi.Core.ValidatingDataService>();

services
	.AddXafWebApi(Configuration, options => {
...

Now everything is set up, and you only need to add validation rules. The easiest way to do this is to add attributes to your persistent types. For instance, the following code applies several rules to the two properties of the sample data type:

using DevExpress.Persistent.Base;
using DevExpress.Persistent.BaseImpl.EF;
using DevExpress.Persistent.Validation;

namespace XAFApp.Module.BusinessObjects {
  [DefaultClassOptions]
  public class SaleProduct : BaseObject {
    public SaleProduct() {
    }

    [RuleUniqueValue]
    [RuleRequiredField]
    public virtual string Name { get; set; }

    [RuleRequiredField]
    [RuleValueComparison(ValueComparisonType.GreaterThan, 0)]
    public virtual decimal? Price { get; set; }
  }
}

Please note that there are many documentation topics that cover validation rules in detail. For instance, this page describes everything you need to know about applying rules in code, or by using the Model Editor in Visual Studio. If you would like to implement custom rules, read this documentation page.

With the rules established, now is the time to make sure that your Docker services have been restarted, and that there are no errors in the logs. Then you can attempt to edit data again in the JavaScript frontend, and you will see error messages appear if you violate the validation rules.

This is impressive functionality! There are aspects we plan to improve in the future. For instance, it would be good to include endpoints out of the box that could be used to validate data during the editing process, independently of of the submit button. It is possible to create such endpoints manually now, but we will make this easier.

Conclusion

As usual, here is the link to the GitHub branch for this post: “stage-4”.

Thank you for reading and following along! Next time I will add authentication to the application, and a future post will also cover retrieval of reports.

For related information, please review the following articles: XAF Blazor | Getting Started Tutorials | Common Questions about the New DevExpress Web API Service. You can get your free copy of .NET App Security Library & Web API Service here: https://www.devexpress.com/security-api-free. To learn about the advanced/paid features of our Web API Service, please refer to the following help topic: Obtain a Report from a Web API Controller Endpoint.

Your Feedback Matters!

Please take a moment to reply to the following questions – your feedback will help us shape/define future development strategies.

Free DevExpress Products - Get Your Copy Today

The following free DevExpress product offers remain available. Should you have any questions about the free offers below, please submit a ticket via the DevExpress Support Center at your convenience. We'll be happy to follow-up.