Skip to main content

@api Decorator

Attach the @api decorator to Model or Frame class methods to create REST API endpoints.Basic usage:
class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<User> {
    return this.findById(id);
  }

  @api({ httpMethod: "POST" })
  async createUser(params: UserSaveParams): Promise<User> {
    return this.save(params);
  }

  @api({ httpMethod: "PUT" })
  async updateUser(id: number, params: UserSaveParams): Promise<User> {
    return this.save({ id, ...params });
  }

  @api({ httpMethod: "DELETE" })
  async deleteUser(id: number): Promise<void> {
    await this.del(id);
  }
}
Auto-generated endpoints:
GET    /user/findById?id=1
POST   /user/createUser
PUT    /user/updateUser
DELETE /user/deleteUser?id=1
httpMethod (required):
@api({ httpMethod: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" })
guards (authentication/authorization):
// Authentication and authorization required
@api({
  httpMethod: "POST",
  guards: ["user", "admin"]  // user login required, admin permission required
})
async adminOnly() { }

// Public API (no authentication required)
@api({
  httpMethod: "GET"
  // Omit guards to allow access without authentication
})
async publicApi() { }
Creating Public APIs: Omit the guards option to create a public API accessible to anyone without authentication. Use this for endpoints that don’t require login or permission checks.
path (custom path):
@api({
  httpMethod: "GET",
  path: "/custom/path"  // Use /custom/path instead of /user/method
})
async customPath() { }
Query parameters (single values):
@api({ httpMethod: "GET" })
async getUser(id: number, includeProfile?: boolean): Promise<User> {
  // GET /user/getUser?id=1&includeProfile=true
}
Query parameters (object):
@api({ httpMethod: "GET" })
async listUsers(listParams: UserListParams): Promise<ListResult<User>> {
  // GET /user/listUsers?num=20&page=1&orderBy=id-desc
  return this.list("A", listParams);
}
Single object:
@api({ httpMethod: "POST" })
async createUser(params: UserSaveParams): Promise<User> {
  // POST /user/createUser
  // Body: { name: "John", email: "john@example.com" }
  return this.save(params);
}
Multiple parameters:
@api({ httpMethod: "POST" })
async updateProfile(
  userId: number,
  profile: ProfileSaveParams
): Promise<Profile> {
  // POST /user/updateProfile
  // Body: { userId: 1, profile: { bio: "..." } }
  return ProfileModel.save({ user_id: userId, ...profile });
}
Single file:
import { api, upload, Sonamu } from "sonamu";

@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadAvatar(): Promise<{ url: string; filename: string }> {
  // POST /user/uploadAvatar
  // Content-Type: multipart/form-data

  const { files } = Sonamu.getContext();
  const file = files?.[0]; // Use first file

  const key = `avatars/${Date.now()}_${file.filename}`;
  const url = await file.saveToDisk(key);

  return { url, filename: file.filename };
}
Multiple files:
import { api, upload, Sonamu } from "sonamu";

@api({ httpMethod: "POST" })
@upload({ mode: "multiple" })
async uploadFiles(): Promise<{ files: Array<{ url: string; filename: string }> }> {
  // POST /user/uploadFiles
  // Content-Type: multipart/form-data

  const { files } = Sonamu.getContext();

  const results = [];
  for (const file of files) {
    const key = `files/${Date.now()}_${file.filename}`;
    const url = await file.saveToDisk(key);
    results.push({ url, filename: file.filename });
  }

  return { files: results };
}
BadRequestException (400):
import { BadRequestException } from "sonamu";

@api({ httpMethod: "POST" })
async createUser(params: UserSaveParams): Promise<User> {
  if (!params.email) {
    throw new BadRequestException("Email is required");
  }
  return this.save(params);
}
UnauthorizedException (401):
import { UnauthorizedException } from "sonamu";

@api({ httpMethod: "GET" })
async getMyProfile(): Promise<User> {
  const userId = Sonamu.getContext().user?.id;
  if (!userId) {
    throw new UnauthorizedException("Login is required");
  }
  return this.findById(userId);
}
Permission error (403):
import { UnauthorizedException } from "sonamu";

@api({ httpMethod: "DELETE" })
async deleteUser(id: number): Promise<void> {
  const currentUser = Sonamu.getContext().user;
  if (currentUser?.role !== "admin") {
    throw new UnauthorizedException("Admin permission required");
  }
  await this.del(id);
}
NotFoundException (404):
import { NotFoundException } from "sonamu";

@api({ httpMethod: "GET" })
async getUserById(id: number): Promise<User> {
  const user = await this.findById(id);
  if (!user) {
    throw new NotFoundException("User not found");
  }
  return user;
}

Context and Session

import { Sonamu } from "sonamu";

@api({ httpMethod: "GET" })
async getMyProfile(): Promise<User> {
  const context = Sonamu.getContext();
  const userId = context.user?.id;

  if (!userId) {
    throw new UnauthorizedException("Login is required");
  }

  return this.findById(userId);
}
const context = Sonamu.getContext();

// User info
context.user           // Logged-in user object
context.userId         // User ID

// Request info
context.ip             // Client IP
context.userAgent      // User-Agent header

// Fastify objects
context.request        // Fastify Request
context.reply          // Fastify Reply

// Session
context.session        // Session object
Session configuration:
// sonamu.config.ts
export default {
  server: {
    session: {
      secret: process.env.SESSION_SECRET || "my-secret",
      cookie: {
        maxAge: 24 * 60 * 60 * 1000,  // 24 hours
        secure: process.env.NODE_ENV === "production"
      }
    }
  }
} satisfies SonamuConfig;
Save session:
@api({ httpMethod: "POST" })  // Omit guards = public API
async login(email: string, password: string): Promise<User> {
  const user = await this.findOne("A", { email });

  if (!user || !await bcrypt.compare(password, user.password)) {
    throw new UnauthorizedException("Invalid email or password");
  }

  const context = Sonamu.getContext();
  context.session.userId = user.id;
  context.session.role = user.role;

  return user;
}
Read session:
@api({ httpMethod: "GET" })
async getMyInfo(): Promise<User> {
  const context = Sonamu.getContext();
  const userId = context.session.userId;

  if (!userId) {
    throw new UnauthorizedException("Login is required");
  }

  return this.findById(userId);
}
Delete session:
@api({ httpMethod: "POST" })
async logout(): Promise<{ success: boolean }> {
  const context = Sonamu.getContext();
  context.session.destroy();
  return { success: true };
}

Authentication and Authorization

Guard configuration:
// sonamu.config.ts
export default {
  server: {
    auth: {
      guards: {
        user: async (request) => {
          const userId = request.session.userId;
          if (!userId) return null;
          return UserModel.findById(userId);
        },
        admin: async (request) => {
          const user = request.session.user;
          if (!user || user.role !== "admin") return null;
          return user;
        }
      }
    }
  }
} satisfies SonamuConfig;
Using Guards:
// user login required
@api({ httpMethod: "GET", guards: ["user"] })
async getMyProfile(): Promise<User> {
  const userId = Sonamu.getContext().user?.id;
  return this.findById(userId!);
}

// admin permission required
@api({ httpMethod: "DELETE", guards: ["admin"] })
async deleteUser(id: number): Promise<void> {
  await this.del(id);
}

// user or admin
@api({ httpMethod: "GET", guards: ["user", "admin"] })
async viewStats(): Promise<Stats> {
  // ...
}

Response Format

Default response:
@api({ httpMethod: "GET" })
async getUser(id: number): Promise<User> {
  return this.findById(id);
}

// Response:
// {
//   "id": 1,
//   "name": "John",
//   "email": "john@example.com"
// }
Custom response:
@api({ httpMethod: "GET" })
async getUserWithStats(id: number): Promise<{
  user: User;
  stats: { postCount: number; commentCount: number };
}> {
  const user = await this.findById(id);
  const postCount = await PostModel.count({ user_id: id });
  const commentCount = await CommentModel.count({ user_id: id });

  return {
    user,
    stats: { postCount, commentCount }
  };
}
Setting headers:
@api({ httpMethod: "GET" })
async downloadFile(id: number): Promise<Buffer> {
  const file = await FileModel.findById(id);

  const context = Sonamu.getContext();
  context.reply.header("Content-Type", "application/pdf");
  context.reply.header("Content-Disposition", `attachment; filename="${file.name}"`);

  return file.buffer;
}
Using ListResult:
@api({ httpMethod: "GET" })
async listUsers(listParams: UserListParams): Promise<ListResult<UserA>> {
  // ListResult = { rows: T[], total: number }
  return this.list("A", listParams);
}

// Response:
// {
//   "rows": [
//     { "id": 1, "name": "John" },
//     { "id": 2, "name": "Jane" }
//   ],
//   "total": 100
// }

Frontend Integration

Service files auto-generated:When Model files change, {entity}.service.ts is auto-generated.
// web/src/services/user/user.service.ts (auto-generated)
export const UserService = {
  async findById(id: number): Promise<User> {
    const response = await axios.get("/user/findById", { params: { id } });
    return response.data;
  },

  async createUser(params: UserSaveParams): Promise<User> {
    const response = await axios.post("/user/createUser", params);
    return response.data;
  }
};
Using in React:
import { UserService } from "@/services/user/user.service";

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    UserService.findById(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}
Using TanStack Query hooks:
import { useUserFindById } from "@/services/user/user.service";

function UserProfile({ userId }: { userId: number }) {
  const { data: user, error, isLoading } = useUserFindById({ id: userId });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{user?.name}</div>;
}