Model Actions
Actions are model methods decorated with @Action() (or @Operation()) that are automatically exposed as HTTP endpoints. They are the primary way to add domain-specific behavior to a model beyond standard CRUD.
@Operation decorator
import { Operation } from "@webda/core";
@Operation() can decorate:
- Instance methods — applied to a specific model instance (e.g.
POST /posts/:slug/publish) - Static methods — applied at the collection level (e.g.
POST /users/login)
Instance operation example
// From sample-apps/blog-system/src/models/Post.ts
import { Operation } from "@webda/core";
import { Model, WEBDA_PRIMARY_KEY } from "@webda/models";
export class Post extends Model {
[WEBDA_PRIMARY_KEY] = ["slug"] as const;
slug!: string;
title!: string;
status!: "draft" | "published" | "archived";
/**
* Publish the post to a social platform.
* Accessible as: POST /posts/:slug/publish
*/
@Operation({ rest: { method: "post", path: "publish" } })
async publish(destination: "linkedin" | "twitter"): Promise<string> {
return `${destination}_${this.slug}_${Date.now()}`;
}
}
Endpoint: POST /posts/hello-world/publish
Request body: { "destination": "linkedin" }
Response: "linkedin_hello-world_1714045200000"
Static operation example
// From sample-apps/blog-system/src/models/User.ts
import { Operation, useContext, WebdaError } from "@webda/core";
import { UuidModel } from "@webda/models";
export class User extends UuidModel {
email!: string;
username!: string;
/**
* Log in with email and password.
* Accessible as: POST /users/login
*/
@Operation({ rest: { method: "post", path: "login" } })
static async login(email: string, password: string): Promise<boolean> {
const user = (await User.getRepository().query(`email = '${email}' LIMIT 1`)).results.pop();
if (!user || !user.password.verify(password)) {
throw new WebdaError.Forbidden("Invalid email or password");
}
return true;
}
/**
* Log out the current user.
* Accessible as: POST /users/logout
*/
@Operation({ rest: { method: "post", path: "logout" } })
static async logout(): Promise<void> {
const context = useContext();
if (!context.getCurrentUserId()) {
throw new WebdaError.Unauthorized("Not authenticated");
}
context.getSession()["logout"]?.();
}
/**
* Follow another user.
* Accessible as: POST /users/:uuid/follow
*/
@Operation({ rest: { method: "post", path: "follow" } })
async follow(target: User): Promise<true> {
if (target.getPrimaryKey() === this.getPrimaryKey()) {
throw new WebdaError.BadRequest("Cannot follow yourself");
}
const existing = (await this.following.query(`followingId = '${target.getPrimaryKey()}' LIMIT 1`)).results.pop();
if (existing) {
throw new WebdaError.BadRequest("Already following this user");
}
this.emit("Follow", { user: this, target });
return true;
}
}
URL conventions
| Method type | Operation | HTTP endpoint |
|---|---|---|
| Instance | async myAction(args) | PUT /models/:pk/myAction |
| Static | static async myAction(args) | PUT /models/myAction |
The default HTTP method is PUT. Set rest.method to change it; @Action({ methods: ["POST"] }) is also supported.
The primary key segments match all of the model's [WEBDA_PRIMARY_KEY] fields in declaration order. Static actions
have no implicit primary key.
rest.path is relative to the model collection. For an instance action, missing primary key placeholders are
prepended automatically:
@Action({ rest: { method: "post", path: "{uuid}/assign{?notify?}" } })
async assign(userId: string, role: string, notify?: boolean): Promise<void> {
// POST /models/id/assign?notify=true with { "userId": "u1", "role": "editor" }
// calls instance.assign("u1", "editor", true)
}
Behavior actions use the same binding relative to the behavior attribute. The existing rest.route option remains
supported ("." is the bare attribute route). Model and behavior operations validate the parent primary key and
action parameters before loading the instance. See REST input binding
for query templates, URL conversion, GET/DELETE defaults and rest.body.
Input / output schemas
The @Operation decorator's arguments are also analyzed by @webda/compiler. For each operation, webdac build generates:
ModelName.actionName.inputschema — describes the method parameters; an instance operation adds the primary key fields inModelName.actionName.requestfor validationModelName.actionName.outputschema — describes the response
These schemas appear in webda.module.json under schemas:
WebdaSample/Post.publish.input
WebdaSample/Post.publish.output
WebdaSample/User.login.input
WebdaSample/User.login.output
OpenAPI metadata
Pass OpenAPI metadata to @Operation() to enrich the generated OpenAPI spec:
@Operation({
description: "Publish the post to a social media platform",
rest: { method: "post", path: "publish" },
openapi: { post: { responses: { "200": { description: "Publication token" } } } }
})
async publish(destination: "linkedin" | "twitter"): Promise<string> {
// ...
}
GraphQL exposure
Operations are also exposed in the auto-generated GraphQL schema as mutations:
mutation {
publishPost(slug: "hello-world", destination: "linkedin")
}
mutation {
loginUser(email: "alice@example.com", password: "secret123")
}
Instance operations take the primary key field(s) as GraphQL arguments. Static operations take only the method's own parameters.
Migrating context-style actions
Actions with typed parameters now receive those values, just like service operations. An instance action no longer
receives the operation context in place of its first ordinary parameter. Read the caller with useContext(), or
retain an explicitly typed OperationContext / IOperationContext parameter; the compiler records its position and
the runtime injects it without including it in the input schema. Actions with no input schema keep the legacy
context fallback. Rebuild older modules to regenerate their parameter schemas.
Verify
# Start the blog-system server and test the publish operation
cd sample-apps/blog-system
# (server running at https://localhost:18080)
curl -sk -X POST https://localhost:18080/posts/hello-world/publish \
-H "Content-Type: application/json" \
-d '{"destination": "linkedin"}' | jq .
Expected response:
"linkedin_hello-world_1714045200000"
Note: The
hello-worldpost must exist. Create it first withPOST /posts.
See also
- Defining Models — model base classes and field declarations
- Permissions — controlling who can call operations
- Lifecycle — events emitted around operations
- @webda/core Routing — how routes are derived from operations