# API conventions

> Every endpoint is an IEndpoint class mapped under /api/v1. Domain exceptions become ProblemDetails responses, the OpenAPI document is built at compile time, and cookie calls need an XSRF token.

Source page: https://appointme.dev/docs/concepts/api-conventions/

* **Base path:** `/api/v1`
* **Endpoints:** `IEndpoint` classes, found by Scrutor
* **Errors:** ProblemDetails, one status per exception type
* **Contract:** `obj/openapi.json`, written on every build

## How a request is handled

**Middleware runs in a fixed order set in `Program.cs`. Then the endpoint turns the request into a message and hands it to Wolverine.**

*Figure 1. One pipeline for every endpoint in every module.*

1. **Middleware** — headers, tenancy, auth, antiforgery

2. **IEndpoint** — binds the request, calls `bus.InvokeAsync`

3. **Handler** — returns a result or throws

4. Then, in parallel:

   * **Success** — a typed result, serialized with `JsonOptions`
   * **Exception** — an `IExceptionHandler` writes ProblemDetails

| Order | Middleware                                             | Does                                                                                                            |
| ----- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| 1     | `UseForwardedHeaders`                                  | Reads the client IP, scheme and host from the proxy (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Original-Host`) |
| 2     | `UseAppointMeSecurityHeaders`                          | Adds the security headers to every response                                                                     |
| 3     | `UseHsts` (outside Development), `UseHttpsRedirection` | HTTPS only                                                                                                      |
| 4     | `UseExceptionHandler`                                  | Runs the exception handlers listed below                                                                        |
| 5     | `UseStaticFiles`                                       | Serves the built SPA from `wwwroot`                                                                             |
| 6     | `UseAppointMeMultiTenancy`                             | Reads `X-Company-Id`. See [Multi-tenancy](/docs/concepts/multi-tenancy/).                                       |
| 7     | `UseAuthentication`, `UseAuthorization`                | Signs the caller in, then applies the endpoint’s policy                                                         |
| 8     | `UseAppointMeAntiforgery`                              | Checks the XSRF token on unsafe `/api` calls                                                                    |

Authentication uses a policy scheme named `Hybrid`. A request with an `Authorization: Bearer …` header goes to JWT bearer validation, and anything else to the `appointme.auth` cookie, which the OIDC code flow sets. The provider is Keycloak or Entra External ID, picked by `Authentication:Provider`.

A fallback authorization policy applies to every endpoint: it needs a signed-in, registered user unless the endpoint calls `.AllowAnonymous()`, as login, signup, the demo login and `GET /api/v1/me` do. The details are on [Identity & principal](/docs/concepts/identity-and-principal/).

## Endpoints: IEndpoint and the api/v1 group

**An endpoint is a class that implements `IEndpoint`. Each module’s assembly is scanned at startup, and every endpoint is mapped into one `api/v1` route group.**

src/AppointMe.Shared/Endpoints/IEndpoint.cs

```cs
public interface IEndpoint
{
    void MapEndpoint(IEndpointRouteBuilder builder);
}
```

Each module’s registration calls `AddEndpoints` with its assembly. The `false` passed to `AddClasses` turns off Scrutor’s public-only filter, so endpoint classes can be `internal sealed`:

src/AppointMe.Shared/Endpoints/EndpointsServiceCollectionExtensions.cs

```cs
public IServiceCollection AddEndpoints(Assembly assembly)
{
    return services.Scan(scan => scan
        .FromAssemblies(assembly)
        .AddClasses(classes => classes.AssignableTo<IEndpoint>(), false)
        .AsImplementedInterfaces()
        .WithTransientLifetime());
}
```

`app.MapEndpoints()` in `Program.cs` builds the group and hands it to every endpoint:

src/AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.cs

```cs
public WebApplication MapEndpoints()
{
    var apiVersionSet = app
        .NewApiVersionSet()
        .HasApiVersion(new ApiVersion(1))
        .ReportApiVersions()
        .Build();


    var versionedGroup = app
        .MapGroup("api/v1")
        .WithApiVersionSet(apiVersionSet);


    var endpoints = app.Services
        .GetRequiredService<IEnumerable<IEndpoint>>();


    foreach (var endpoint in endpoints)
    {
        endpoint.MapEndpoint(versionedGroup);
    }


    return app;
}
```

So `builder.MapPost("/customers", …)` in `RegisterCustomerEndpoint` serves `POST /api/v1/customers`. The endpoints follow these conventions:

| Convention                                                                           | Example                                                                                           |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `internal sealed class <Slice>Endpoint : IEndpoint`, in the slice folder             | `RegisterCustomerEndpoint`                                                                        |
| Routes without the prefix, plural nouns, typed route values                          | `/customers/{id:guid}`                                                                            |
| `.WithName(nameof(Handler))` on every route, which becomes the OpenAPI `operationId` | `RegisterCustomer`, generated as `useRegisterCustomer`                                            |
| `[FromBody]` for a JSON body, `[AsParameters]` for a query-string class              | `RegisterCustomerRequest`, `GetCustomersRequest`                                                  |
| The handler maps to a message and calls `bus.InvokeAsync`, nothing more              | Every endpoint except login, logout and the demo login, which work with the auth schemes directly |
| Typed results                                                                        | `TypedResults.Created(...)`, `Results.NoContent()`, or the response DTO                           |

The full recipe for a new endpoint is [Add an endpoint](/docs/guides/add-an-endpoint/).

### Versioning

There’s one API version. The registration below sets version 1 as the default, and `MapEndpoints` attaches a version set with `HasApiVersion(new ApiVersion(1))` and `ReportApiVersions()` to the group:

src/AppointMe.Api/ApiVersioning/ApiVersioningExtensions.cs

```cs
services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1);
    options.ReportApiVersions = true;
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
```

The group path is the literal text `api/v1`, not a `{version:apiVersion}` route template, and `AssumeDefaultVersionWhenUnspecified` makes every request version 1. Nothing maps a v2 today. Adding one means a second group in `MapEndpoints`.

## Errors become ProblemDetails

**Handlers throw the exceptions in `AppointMe.Shared/Domain/Errors`. A chain of `IExceptionHandler`s turns each one into a ProblemDetails response with a fixed status code.**

| Exception                                                        | Status                                                     | `title`                 | The message goes to                                                   |
| ---------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------- |
| `BadHttpRequestException`                                        | The exception’s own: `400`, or `413` for an oversized body | `Bad Request`           | `detail`                                                              |
| `ValidationException`                                            | `400`                                                      | `Validation Error`      | `error`. `detail` is always “One or more validation errors occurred”. |
| `AccessDeniedException`                                          | `403`                                                      | `Access denied`         | `detail`                                                              |
| `NotFoundException`                                              | `404`                                                      | `Resource Not Found`    | `detail`                                                              |
| `ConflictException`                                              | `409`                                                      | `Conflict`              | `error`, plus `code` when the exception has one                       |
| `DbUpdateConcurrencyException`, thrown or as the inner exception | `409`                                                      | `Conflict`              | A fixed `detail`, plus `code: "concurrency_conflict"`                 |
| Anything else                                                    | `500`                                                      | `Internal Server Error` | A fixed `detail`: “Unexpected error occurred.”                        |

ASP.NET Core asks the handlers in registration order and stops at the first one that handles the exception. `GlobalExceptionHandler` handles everything, so it’s last:

src/AppointMe.Api/ErrorHandling/ErrorHandlingServiceCollectionExtensions.cs

```cs
return services
    .AddProblemDetails()
    .AddExceptionHandler<BadHttpRequestExceptionHandler>()
    .AddExceptionHandler<ValidationExceptionHandler>()
    .AddExceptionHandler<NotFoundExceptionHandler>()
    .AddExceptionHandler<ConflictExceptionHandler>()
    .AddExceptionHandler<AccessDeniedExceptionHandler>()
    .AddExceptionHandler<ConcurrencyExceptionHandler>()
    .AddExceptionHandler<GlobalExceptionHandler>();
```

The SPA reads `error`, then `detail`, then `title` (`src/lib/query-client.ts`). It shows a global toast only for `5xx` and network errors, and leaves `4xx` handling to the component that made the call.

### Request binding errors

In Development, ASP.NET Core’s `ThrowOnBadRequest` is on by default. A missing required value, an unparsable route or query value, or malformed JSON then throws `BadHttpRequestException`, and `BadHttpRequestExceptionHandler` returns it as a `400` with the binder’s message in `detail`, such as `Failed to bind parameter "int Limit" from "abc".` Outside Development the framework writes the `400` itself, without throwing, so the handler isn’t involved. Kestrel limits, such as an oversized body, throw in every environment and keep their own status code.

## The OpenAPI document is built at compile time

**Every `dotnet build` of the API writes `src/AppointMe.Api/obj/openapi.json`, and the frontend’s typed client is generated from that file.**

src/AppointMe.Api/AppointMe.Api.csproj

```xml
<!-- Build-time OpenAPI document (Microsoft.Extensions.ApiDescription.Server): every build writes
     obj/openapi.json (a build artifact, never committed), and the frontend's orval generates the API
     client from it. -->
<OpenApiDocumentsDirectory>obj</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
```

| Piece                                                  | What it does                                                                                                                                                                                                    |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The `Codegen` environment                              | The build starts the app with `appsettings.Codegen.json` placeholders, no database and Wolverine as an in-memory mediator. `CodeGenerationDetection.IsRunningGeneration()` tells startup code it’s in this run. |
| `InvalidateOpenApiDocumentsOnReferenceChange`          | A target in `AppointMe.Api.csproj` that regenerates the document when a module assembly changes, since the endpoints live in the modules                                                                        |
| `PermissionSchemaTransformer`, `RoleSchemaTransformer` | Describe `Permission` as a string enum of every registered permission code, and `Role` as one of the built-in role names                                                                                        |
| `.ExcludeFromDescription()`                            | Keeps an endpoint out of the document, as the demo login does                                                                                                                                                   |
| `npm run generate:api`                                 | Builds the API, then runs orval on `obj/openapi.json` to write `src/AppointMe.Frontend/src/api/client.ts`                                                                                                       |

In Development the running API also serves the document at `https://localhost:7233/openapi/v1.json`. How the generated client is used is on [Typed API client](/docs/frontend/api-client/).

## Antiforgery, security headers and JSON

**Cookie-authenticated calls that change data must echo an XSRF token. Every response gets security headers, and JSON uses strict numbers and string enums.**

### Antiforgery

| Piece                  | Value                                                                                         |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| Request-token cookie   | `XSRF-TOKEN`, readable by JavaScript, `Secure`, `SameSite=Lax`, issued on every `GET`         |
| Header that echoes it  | `X-XSRF-TOKEN`                                                                                |
| Cookie token, HttpOnly | `appointme.csrf`                                                                              |
| Checked on             | Any method except `GET`, `HEAD`, `OPTIONS` and `TRACE`, under `/api`, without a bearer header |
| On failure             | `400`, title `Antiforgery Validation Error`                                                   |

src/AppointMe.Api/Antiforgery/AntiforgeryMiddleware.cs

```cs
// Bearer requests carry no ambient credential, so they cannot be forged cross-site;
// only cookie-backed requests need the token pair. Non-/api surfaces protect themselves
// (Hangfire ships its own antiforgery, the OIDC callback is covered by state/correlation).
private static bool RequiresValidation(HttpRequest request) =>
    request.Path.StartsWithSegments(ApiPrefix) && !request.HasBearerTokenHeader();
```

The token is reissued on every `GET` because it’s bound to the signed-in identity, so the first `GET` after login or logout refreshes it. The SPA’s axios instance echoes it with no extra code:

src/AppointMe.Frontend/src/lib/axios.ts

```ts
export const axiosInstance = axios.create({
    baseURL: '/',
    withCredentials: true,
    xsrfCookieName: 'XSRF-TOKEN',
    xsrfHeaderName: 'X-XSRF-TOKEN',
    withXSRFToken: true,
});
```

### Security headers

| Header                                | Value                                                                                                    |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `X-Content-Type-Options`              | `nosniff`                                                                                                |
| `X-Frame-Options`                     | `DENY`                                                                                                   |
| `Referrer-Policy`                     | `strict-origin-when-cross-origin`                                                                        |
| `Content-Security-Policy-Report-Only` | `default-src 'self'` and related directives. It’s report-only while the policy is tuned against the SPA. |
| `Strict-Transport-Security`           | From `UseHsts`, outside Development: 365 days, with `includeSubDomains`                                  |

`SecurityHeadersMiddleware` sets its headers in `Response.OnStarting`, so they survive the exception handler clearing the response. A test in `AppointMe.Api.Tests` covers that case.

### JSON

src/AppointMe.Api/Json/JsonOptionsExtensions.cs

```cs
return services.Configure<JsonOptions>(options =>
{
    options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
    options.SerializerOptions.Converters.Add(new RoleJsonConverter());
});
```

`Strict` number handling rejects a number sent as a string, such as `"10"`. Enums travel as names (`"Female"`), and roles travel as their name.

## Map a new exception

| Step                 | Where                                      | What                                                                                                      |
| -------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| 1. Add the exception | `AppointMe.Shared/Domain/Errors/`          | Derive from `DomainException`, which carries an optional `Code`                                           |
| 2. Write the handler | `AppointMe.Api/ErrorHandling/`             | An `internal sealed` `IExceptionHandler` that sets the status and writes through `IProblemDetailsService` |
| 3. Register it       | `ErrorHandlingServiceCollectionExtensions` | `.AddExceptionHandler<…>()` anywhere before `GlobalExceptionHandler`                                      |
| 4. Test it           | `AppointMe.Api.Tests/ErrorHandling/`       | Follow `BadHttpRequestExceptionHandlerTests`: status, body and “doesn’t handle other exceptions”          |

## Gotchas

| Symptom                                                                | Cause                                                                                                                              | Fix                                                                                                            |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `400 Antiforgery Validation Error` from curl or Postman                | The call used the auth cookie but no `X-XSRF-TOKEN` header                                                                         | Copy the `XSRF-TOKEN` cookie from a `GET` into the header, or call with a bearer token                         |
| The UI says “One or more validation errors occurred”                   | The client read `detail`                                                                                                           | Read `error` first, as `query-client.ts` does                                                                  |
| A `ValidationException` code never reaches the client                  | `ValidationExceptionHandler` doesn’t write `Code`. Only `ConflictExceptionHandler` passes an exception’s `Code` through as `code`. | Branch on the status and message, or add `code` to `ValidationExceptionHandler`                                |
| `DELETE /api/v1/customers/{id}` returns `200`, but `PUT` returns `204` | The delete endpoint’s handler returns a plain `Task`, and the update returns `Results.NoContent()`                                 | Return `Results.NoContent()` if you want `204` everywhere                                                      |
| `dotnet build` fails in the OpenAPI step after you add startup code    | The build-time run has no database and placeholder config                                                                          | Skip that code when `CodeGenerationDetection.IsRunningGeneration()` is true, as `Program.cs` does for Hangfire |

### Why exceptions instead of a Result type?

A handler returns its success value and throws for the failure cases, and one folder, `AppointMe.Api/ErrorHandling`, decides what each failure looks like over HTTP. Handlers stay short, the value objects can validate from anywhere (`Email.Create` throws, wherever it’s called), and an exception thrown in a handler comes back out of `InvokeAsync`, where the exception handlers pick it up.

The cost: the failure cases aren’t visible in a handler’s signature, and exceptions cost more than return values. With a handful of exception types and failures that end a request anyway, that trade is cheap here.

### Why generate the OpenAPI document at build time?

Generating from a running API needs the backend up and a workaround for its local TLS certificate. A build-time document needs only `dotnet build`, and a contract change shows up as a diff in `src/api/` when you regenerate. The price is the `Codegen` environment: startup code that needs real infrastructure has to check for it.

## Next

* [Add an endpoint](/docs/guides/add-an-endpoint/) (Guide)
* [Identity & principal](/docs/concepts/identity-and-principal/) (Concept)
* [Typed API client](/docs/frontend/api-client/) (Concept)
