Skip to content

@saif/platform-typespec

Reference for the SAIF.Platform TypeSpec conventions library β€” the operation templates, status-code models, auth helpers, and eventing contracts that Forge templates generate against.

This page documents Forge's conventions layer, not TypeSpec itself

For the TypeSpec language β€” models, decorators, templates, is vs extends, versioning β€” read the TypeSpec documentation. This page only covers what @saif/platform-typespec adds on top.


πŸ“‹ Summary

@saif/platform-typespec is the library every Forge API contract imports. It supplies:

Area Namespace members Source
Operation templates ResourceRead, ResourceList, ResourceCreate, ResourceCreateOrReplace, ResourcePartialUpdate, ResourceDelete, and the action variants lib/operations/
Status-code models Ok, Created, Accepted, NoContent, BadRequest, NotFound, AuthErrors, ServerError, and the rest of 2xx/3xx/4xx/5xx lib/status-codes/
Auth helpers Scopes<>, Roles<> lib/openapi/oauth.tsp
Server helpers MockingServer<> lib/openapi/server.tsp
Eventing Topic<>, Event<>, BaseEvent lib/eventing/

Everything lives in the SAIF.Platform namespace. One import and one using make the whole surface available:

main.tsp
import "@saif/platform-typespec";

using SAIF.Platform;

The value of the library is that error contracts, status codes, and route shapes are decided once. An operation written as ResourceRead<Order> already returns RFC 7807 Problem Details for 400, 401, 403, 404, and 5xx without you writing a single error model.


πŸ›€οΈ Operation Templates

Operation templates are used with TypeSpec's is keyword inside an interface. Each one applies @autoRoute, the correct HTTP verb, the matching @…Resource REST decorator, and a default error union.

@autoRoute
@added(Versions.v1)
interface Orders {
  get is ResourceRead<Order>;
  all is ResourceList<Order>;
  post is ResourceCreate<Order>;
  put is ResourceCreateOrReplace<Order>;
  patch is ResourcePartialUpdate<Order>;
  delete is ResourceDelete<Order>;
}

Routes come from the model's @resource name and @key property, so @route is never written by hand.

CRUD templates

Template Verb Route Default success Default errors
ResourceRead<Resource> GET /{collection}/{key} OkResponse<Resource> (200) BadRequest \| NotFound \| AuthErrors \| ServerError
ResourceList<Resource> GET /{collection} OkResponse<Resource[]> (200) BadRequest \| AuthErrors \| ServerError
ResourceCreate<Resource> POST /{collection} CreatedResponse<Resource> (201) BadRequest \| AuthErrors \| ServerError
ResourceCreateOrReplace<Resource> PUT /{collection}/{key} OkResponse<Resource> \| CreatedResponse<Resource> (200/201) BadRequest \| NotFound \| AuthErrors \| ServerError
ResourcePartialUpdate<Resource> PATCH /{collection}/{key} OkResponse<Resource> (200) BadRequest \| NotFound \| AuthErrors \| ServerError
ResourceDelete<Resource> DELETE /{collection}/{key} NoContent (204) BadRequest \| AuthErrors \| ServerError

AuthErrors is an alias for Unauthorized | Forbidden, so every template above already covers 401 and 403.

Action templates

Actions hang off a resource (/{collection}/{key}/{action}) or its collection (/{collection}/{action}). Use them when an operation is a verb rather than a CRUD effect on the resource itself.

Template Verb Target Default success Default errors
ResourceAction<Resource> POST instance OkResponse<Resource> (200) BadRequest \| NotFound \| AuthErrors \| ServerError
ResourceActionAsync<Resource> POST instance Accepted (202) BadRequest \| NotFound \| AuthErrors \| ServerError
ResourceCollectionAction<Resource> POST collection OkResponse<Resource> (200) BadRequest \| AuthErrors \| ServerError
ResourceCollectionActionAsync<Resource> POST collection Accepted (202) BadRequest \| AuthErrors \| ServerError
ResourceReadAction<Resource> GET instance OkResponse<Resource> (200) BadRequest \| NotFound \| AuthErrors \| ServerError
ResourceCollectionReadAction<Resource> GET collection OkResponse<Resource> (200) BadRequest \| AuthErrors \| ServerError

The POST action templates take a Body parameter that defaults to void, so an action with no request body needs no extra arguments:

@autoRoute
interface Orders {
  // POST /orders/{id}/cancel
  cancel is ResourceAction<Order>;

  // POST /orders/{id}/refund with a request body
  refund is ResourceAction<Order, {}, RefundRequest>;

  // POST /orders/export β†’ 202 Accepted
  export is ResourceCollectionActionAsync<Order>;
}

Template parameters

Templates fall into two positional signatures. Supply {} for any parameter you want to leave at its default.

No Body parameter β€” ResourceRead, ResourceList, ResourceCreate, ResourceCreateOrReplace, ResourceDelete, ResourceReadAction, ResourceCollectionReadAction. The body is derived from Resource itself (via ResourceCreateableProperties<T> for creates), not supplied as an argument.

Position Parameter Purpose
1 Resource The resource model. Drives the route, the key parameters, and the derived body.
2 Parameters Extra request parameters merged in β€” query strings, headers, additional path segments.
3 Response Overrides the success response.
4 Error Overrides the error union.

Has a Body parameter β€” ResourcePartialUpdate, ResourceAction, ResourceActionAsync, ResourceCollectionAction, ResourceCollectionActionAsync.

Position Parameter Purpose
1 Resource The resource model. Drives the route and the key parameters.
2 Parameters Extra request parameters merged in β€” query strings, headers, additional path segments.
3 Body Overrides the request body model. Defaults to ResourceUpdateableProperties<Resource> for ResourcePartialUpdate, void for action templates.
4 Response Overrides the success response.
5 Error Overrides the error union.

{} at the Body position is a real empty model, not \"skip this argument\"

{} satisfies Body extends {} as an actual empty-object body, distinct from omitting the argument to fall back to the default. Only omit trailing arguments to keep defaults; do not pass {} positionally unless you mean an empty body.

Adding query parameters:

model OrderFilter {
  @query status?: OrderStatus;
  @query skip?: int32 = 0;
  @query take?: int32 = 20;
}

@autoRoute
interface Orders {
  all is ResourceList<Order, OrderFilter>;
}

Extending the error union β€” keep the defaults and add to them rather than replacing them:

@autoRoute
interface Orders {
  post is ResourceCreate<
    Order,
    {},
    CreatedResponse<Order>,
    BadRequest | Conflict | AuthErrors | ServerError
  >;
}

Overriding Error replaces the whole union

The Error parameter is not additive. If you pass a value, restate every error the operation can return, including AuthErrors and ServerError. Dropping them silently removes 401, 403, and 5xx from the generated OpenAPI and from every generated client.

Overriding the response:

model OrderPage {
  items: Order[];
  totalCount: int32;
  continuationToken?: string;
}

@autoRoute
interface Orders {
  all is ResourceList<Order, OrderFilter, OkResponse<OrderPage>>;
}

Body-shaping models

The create and update templates derive their request bodies from the resource, so you do not define separate request models:

Model Used by Effect
ResourceCreateableProperties<T> ResourceCreate Applies Lifecycle.Create visibility, dropping read-only properties. Emits as {Name}Create.
ResourceUpdateableProperties<T> ResourcePartialUpdate Makes properties optional for a partial update. Emits as {Name}Update.
ResourceInstanceParameters<T, P> instance operations Merges P with the resource's @key properties.
ResourceCollectionParameters<T, P> collection operations Merges P with the parent resource's keys.

Mark server-owned fields with @visibility(Lifecycle.Read) so they are excluded from create bodies automatically.

Base operation templates

ReadOperation, ActionOperation, CreateOperation, UpdateOperation, and DeleteOperation are the unrouted primitives the resource templates are built from. Use them only when you need an operation that is not resource-shaped β€” for everything else, the Resource* templates carry the routing and REST metadata you would otherwise write by hand.


πŸ”’ Status-Code Models

Success and redirection

Bare status-code markers with no body. Combine them with a body in a response model, or use them directly for empty responses.

Range Models
2xx Ok (200), Created (201), Accepted (202), NonAuthoritativeInformation (203), NoContent (204), ResetContent (205), PartialContent (206), MultiStatus (207), AlreadyReported (208), IMUsed (226)
3xx MultipleChoices (300), MovedPermanently (301), Found (302), SeeOther (303), NotModified (304), TemporaryRedirect (307), PermanentRedirect (308)

Success and Redirection match any status in their range, for cases where the exact code is not fixed.

Two wrappers pair a status code with a body:

  • OkResponse<T> β€” 200 with T as the body
  • CreatedResponse<T> β€” 201 with T as the body

Errors

Every error model extends ProblemDetails, so all error responses are RFC 7807 with Content-Type: application/problem+json.

Example 404 response body
{
  "type": "https://example.com/probs/order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 8f3a1c02 does not exist.",
  "instance": "/orders/8f3a1c02",
  "requestId": "0HN7GQ1V2K3M4",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

requestId, traceId, and errors are SAIF extensions to the RFC. errors is an array of { key, values } pairs and carries field-level validation failures.

Model / alias Status Notes
BadRequest 400 Validation failures. Populate errors with the offending fields.
Unauthorized 401 Missing or invalid token.
Forbidden 403 Valid token, insufficient scope or role.
AuthErrors 401, 403 Alias for Unauthorized \| Forbidden. Present in every template default.
NotFound 404
Conflict 409 Optimistic-concurrency and duplicate-key failures.
UnprocessableEntity 422 Semantically invalid but well-formed request.
TooManyRequests 429 Rate limiting.
ClientError 400–499 Matches any 4xx.
InternalServerError 500
ServiceUnavailable 503
GatewayTimeout 504
ServerError 500–599 Matches any 5xx. Present in every template default.

The full set of RFC-defined 4xx and 5xx codes is available β€” MethodNotAllowed, NotAcceptable, RequestTimeout, Gone, PreconditionFailed, PayloadTooLarge, UnsupportedMediaType, Locked, UpgradeRequired, NotImplemented, BadGateway, and the rest β€” each named after its RFC reason phrase. See lib/status-codes/client-errors.tsp and lib/status-codes/server-errors.tsp for the complete list.

Do not hand-roll error models

A custom @error model NotFoundError { code: string; message: string } produces a response shape no other SAIF API returns, and no shared client handler understands. Use NotFound and put the specifics in detail and type.

Building custom status models

StatusCode<N> and StatusCodeRange<Min, Max> are the primitives behind every model above. Reach for them only when you need a code the library does not name.

model PaymentRequired extends ProblemDetails {
  status: int32 = 402;
  ...StatusCode<402>;
  ...OmitProperties<ProblemDetailsProperties, "title" | "status" | "statusCode">;
}

πŸ” Auth Helpers

Scopes<> and Roles<> are OAuth2 authorization-code flows preconfigured for the platform. One @useAuth declaration covers both identity providers; the generated APIM policies check the correct claim per provider.

@useAuth(Scopes<["Client.Read"]> | Roles<["App.Read"]>)
namespace MyApp.Api;
Helper Checks Claim Granted to
Scopes<[...]> Client.* scopes scp Calling applications
Roles<[...]> App.* roles roles (Entra) / user-groups (Okta) Users via Business Roles, or applications

The | union means either satisfies the requirement β€” an Okta caller presenting Client.Read and an Entra caller presenting the App.Read role both pass.

Declare the default at the namespace and override per operation where a stronger permission is needed:

@autoRoute
interface Orders {
  get is ResourceRead<Order>;

  @useAuth(Scopes<["Client.Write"]> | Roles<["App.Write"]>)
  post is ResourceCreate<Order>;

  @useAuth(Scopes<["Client.Delete"]> | Roles<["App.Admin"]>)
  delete is ResourceDelete<Order>;
}

Never reference platform-managed delegation scopes

user_impersonation (Entra), user-groups (Okta), and the Experience-API access scope are handled by the platform. Do not name them in @useAuth.

Permission names must follow the permission naming conventions and be defined in both providers β€” see App Permissions.

The URLs in the emitted OpenAPI are placeholders

Scopes<> and Roles<> declare https://example.com/oauth2/* endpoints. Real authorization, token, and refresh URLs are injected by APIM at deployment time β€” do not replace them in your contract.


🎭 Server Helpers

MockingServer<> marks a @server entry as a mock endpoint by adding mocking_server: "true" to its variables. Requests routed to that server are served by WireMock Cloud instead of your backend.

@server("http://localhost:21000", "localhost development endpoint")
@server(
  "https://myproject.wiremockapi.cloud/",
  "WireMock Cloud endpoint",
  MockingServer
)
namespace MyApp.Api;

Pair it with @extension("x-mocking", true) to make mocking the default for the service, then override per operation with @extension("x-mocking", false). See the mocking guides for running mocks locally.


πŸ“‘ Eventing

The eventing contracts model a publish endpoint for a Service Bus topic.

Member Purpose
BaseEvent Empty marker model that all events extend.
Event<"Name"> Adds the eventId key plus the X-Event-Type and X-Correlation-Id headers. @resource("Name") supplies the route segment.
Topic<TEvent> Interface with a single post is ResourceCreate<TEvent> operation.
@autoRoute
interface OrderPlacedTopic extends Topic<OrderPlacedEvent> {}

model OrderPlacedEvent is Event<"OrderPlaced"> {
  orderId: string;
  customerId: string;
  total: decimal;
}

That produces POST /OrderPlaced, with eventId supplied by the caller, X-Event-Type fixed to OrderPlaced, and an optional X-Correlation-Id for tracing. Event Service APIs authenticate with a subscription key rather than OAuth:

@useAuth(ApiKeyAuth<ApiKeyLocation.header, "Ocp-Apim-Subscription-Key">)

See the Event Service guide for the surrounding infrastructure.


βœ… Conventions Checklist

  • @saif/platform-typespec imported and using SAIF.Platform; declared
  • Operations use Resource* templates rather than hand-written @route + verb decorators
  • No hand-rolled @error models β€” errors come from the status-code namespace
  • Any Error override still includes AuthErrors and ServerError
  • @useAuth pairs Scopes<> with Roles<> using platform-convention permission names
  • Read-only resource properties marked @visibility(Lifecycle.Read)
  • MockingServer applied to the WireMock @server entry

🧩 Linting

The library registers a linter with no rules today (src/linter.ts), so none of the conventions above are machine-enforced. Treat this page and the checklist as the contract, and review contract changes by hand.


πŸ“š Resources