ข้ามไปยังเนื้อหา

4 · REST API + Scalar

เนื้อหานี้ยังไม่ได้แปลเป็นภาษาไทย แสดงเป็นภาษาอังกฤษแทน

You already have GET / POST. In this step you add the missing verbs, customise the OpenAPI document, and dial in the Scalar UI so the API explorer feels production-ready.

Create src/Acme.Store.Modules.Products/Behaviors/UpdateProductBehavior.cs:

using Acme.Store.Modules.Products.Domain;
using Cephalon.AspNetCore.Behaviors;
using Cephalon.Ids.Sfid;
using Microsoft.AspNetCore.Http;
namespace Acme.Store.Modules.Products.Behaviors;
public sealed record UpdateProductPayload(string Name, string Sku, decimal Price);
public sealed class UpdateProductBehavior : IRestBehavior
{
public RestRoute Route => RestRoute.Put("/products/{id}");
public async Task<IResult> Handle(Sfid id, UpdateProductPayload payload, IProductCatalog catalog, CancellationToken ct)
{
var product = await catalog.FindAsync(id, ct);
if (product is null) return Results.NotFound();
product.Name = payload.Name;
product.Sku = payload.Sku;
product.Price = payload.Price;
await catalog.UpdateAsync(product, ct);
return Results.NoContent();
}
}

Create src/Acme.Store.Modules.Products/Behaviors/DeleteProductBehavior.cs:

using Acme.Store.Modules.Products.Domain;
using Cephalon.AspNetCore.Behaviors;
using Cephalon.Ids.Sfid;
using Microsoft.AspNetCore.Http;
namespace Acme.Store.Modules.Products.Behaviors;
public sealed class DeleteProductBehavior : IRestBehavior
{
public RestRoute Route => RestRoute.Delete("/products/{id}");
public async Task<IResult> Handle(Sfid id, IProductCatalog catalog, CancellationToken ct)
{
var removed = await catalog.DeleteAsync(id, ct);
return removed ? Results.NoContent() : Results.NotFound();
}
}

Extend IProductCatalog and EfProductCatalog with UpdateAsync and DeleteAsync. Then register the new behaviors in ProductsModule.cs:

protected override void ConfigureRestBehaviors(IRestBehaviorBuilder builder)
{
builder.MapProfile<ListProductsBehavior>();
builder.MapProfile<GetProductBehavior>();
builder.MapProfile<CreateProductBehavior>();
builder.MapProfile<UpdateProductBehavior>();
builder.MapProfile<DeleteProductBehavior>();
}

Each behavior can publish metadata that Scalar picks up. Update ListProductsBehavior to demonstrate:

public sealed class ListProductsBehavior : IRestBehavior
{
public RestRoute Route => RestRoute.Get("/products")
.WithSummary("List products")
.WithDescription("Returns the full product catalog in creation order.")
.WithTags("Catalog")
.Produces<IReadOnlyList<Product>>(200);
public Task<IResult> Handle(IProductCatalog catalog, CancellationToken ct) =>
catalog.ListAsync(ct).ContinueWith<IResult>(t => Results.Ok(t.Result));
}

The same WithSummary/WithDescription/WithTags calls apply to the rest. Set them now — Scalar will group endpoints by tag and show the descriptions inline.

In src/Acme.Store.Host/Program.cs, configure the OpenAPI document and Scalar UI:

var app = builder.Services
.AddCephalonAspNetCore(options =>
{
options.OpenApi.Title = "Acme.Store API";
options.OpenApi.Version = "v1";
options.OpenApi.Description = "Public REST API for the Acme Store backend.";
options.OpenApi.Contact = new() { Name = "Acme Platform Team", Email = "platform@acme.example" };
options.OpenApi.License = new() { Name = "Proprietary" };
options.Scalar.Theme = ScalarTheme.Default;
options.Scalar.Layout = ScalarLayout.Modern;
})
.AddModulesFromAssemblies(/* ... */)
.Build(builder);

Restart and open http://localhost:5000/scalar/v1. The new title, description, and contact info should appear in the header. Tags group the endpoints.

When you start versioning your API surface, declare it on the route:

public RestRoute Route => RestRoute.Get("/v1/products")
.WithApiVersion("1.0");

The host adapter picks the version up automatically and wires Asp.Versioning headers. Behaviors that omit the version map to the default.

By default the host serialises domain types directly. For a stable public contract, define explicit response models:

public sealed record ProductResponse(string Id, string Name, string Sku, decimal Price, DateTimeOffset CreatedAt);

Map in the behavior:

public Task<IResult> Handle(IProductCatalog catalog, CancellationToken ct) =>
catalog.ListAsync(ct).ContinueWith<IResult>(t =>
Results.Ok(t.Result.Select(p => new ProductResponse(p.Id.ToString(), p.Name, p.Sku, p.Price, p.CreatedAt))));

The OpenAPI schema now reflects the response shape exactly.

The host adapter ships with ProblemDetails defaults. To shape errors consistently, throw typed exceptions and let the configured handler map them:

throw new ValidationException("price must be non-negative");

The mapper produces:

{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Validation failed",
"status": 400,
"detail": "price must be non-negative",
"traceId": "00-abc...-01"
}

A complete error contract is part of the Reference → Configuration overview — the dedicated Error handling page is planned for 0.2.0-preview.

Terminal window
curl -X PUT http://localhost:5000/products/<id> -H "Content-Type: application/json" -d '{"name":"USB-C Hub V2","sku":"HB-USBC","price":69}'
curl -X DELETE http://localhost:5000/products/<id>

Both should return 204 No Content on success and 404 Not Found for an unknown id. Open Scalar again and confirm the documentation reflects the new endpoints.

  • Full CRUD over /products.
  • OpenAPI metadata grouped by tag.
  • Scalar UI with custom title, description, and contact info.
  • Explicit response models for public contract stability.

Step 5 → Eventing.