Skip to main content

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 typeOperationHTTP endpoint
Instanceasync myAction(args)PUT /models/:pk/myAction
Staticstatic 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.input schema — describes the method parameters; an instance operation adds the primary key fields in ModelName.actionName.request for validation
  • ModelName.actionName.output schema — 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-world post must exist. Create it first with POST /posts.

See also​