Skip to main content

Generate OpenAPI specifications

Automatically generate OpenAPI specs and expose REST APIs using TypeScript decorators.

Why Use OpenAPI Endpoints

You need to expose your backend as a standard REST API, whether for third-party integrations, mobile clients, or external services that can't use the Squid Client SDK.

Without OpenAPI, you'd need to manually define routes, write spec files, configure CORS, and keep documentation in sync. With Squid's OpenAPI support, you decorate your methods and get a fully documented REST API:

// Backend: just decorators on a service method
@Route('orders')
export class OrderService extends SquidService {
@Get('{orderId}')
async getOrder(@Path() orderId: string): Promise<Order> {
const order = await this.fetchOrder(orderId);
return this.createOpenApiResponse(order);
}
}

// Consumers call it as a standard REST endpoint:
// GET https://<your-app>.squid.cloud/openapi/orders/abc123

No manual spec writing. No route configuration. Just decorated methods.

Overview

Squid uses tsoa decorators to generate OpenAPI specifications from your TypeScript code. You define endpoints as methods on a SquidService subclass, and Squid generates the spec, handles routing, and serves the API.

When to use OpenAPI endpoints

Use CaseRecommendation
Expose a REST API for external consumersOpenAPI endpoint
Call a backend function from the Squid Client SDKUse Executables
React to database changesUse Triggers
Receive HTTP callbacks from external servicesUse Webhooks

How it works

  1. You add @Route to a class extending SquidService and HTTP method decorators to its methods
  2. Squid discovers the decorated methods at deploy time and generates an OpenAPI spec
  3. External consumers call your endpoints via standard HTTP requests
  4. Squid routes requests, extracts parameters, and calls your method
  5. You return a response using createOpenApiResponse()

Quick Start

Prerequisites

  • A Squid backend project initialized with squid init-backend
  • The @squidcloud/backend package installed

Step 1: Create an OpenAPI endpoint

Add a @Route decorator to a service class and an HTTP method decorator to a method:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Query, Route } from 'tsoa';

@Route('example')
export class ExampleService extends SquidService {
@Get('echo')
async echo(@Query() message: string): Promise<string> {
return this.createOpenApiResponse(message);
}
}

Step 2: Export the service

Ensure your service is exported from the service index file:

service/index.ts
export * from './example-service';

Step 3: Deploy the backend

squid deploy

Step 4: Call the endpoint

curl "https://YOUR_APP_ID-dev.APP_REGION.squid.cloud/openapi/example/echo?message=hello"

The response body will be hello.

Endpoint URLs

There are two endpoint targets to use with OpenAPI.

Specification of your OpenAPI endpoints

To download the spec for your OpenAPI endpoints, append /openapi/spec.json to your base URL.

Your endpoints

Each of your endpoints is available at their given routes, using the template /openapi/{route}/{method} appended to your base URL.

Base URL

Your base URL depends on how you are running your backend.

Local development

When developing locally, endpoints use this base URL:

https://[YOUR_APP_ID]-dev-[YOUR_SQUID_DEVELOPER_ID].[APP_REGION].squid.cloud

So the corresponding endpoints are:

https://[YOUR_APP_ID]-dev-[YOUR_SQUID_DEVELOPER_ID].[APP_REGION].squid.cloud/openapi/spec.json
https://[YOUR_APP_ID]-dev-[YOUR_SQUID_DEVELOPER_ID].[APP_REGION].squid.cloud/openapi/{route}/{method}

Deployed environments

Base URLs for deployed environments depend on whether you want the development or production environment. The URLs differ slightly, where the development environment URL includes -dev after your app ID. So:

Dev:

https://[YOUR_APP_ID]-dev.[APP_REGION].squid.cloud

Prod:

https://[YOUR_APP_ID].[APP_REGION].squid.cloud

For example, the corresponding endpoints for Dev are:

https://[YOUR_APP_ID]-dev.[APP_REGION].squid.cloud/openapi/spec.json
https://[YOUR_APP_ID]-dev.[APP_REGION].squid.cloud/openapi/{route}/{method}

Monitoring

View your OpenAPI controllers, specs, and usage in the Backend tab of the Squid Console under OpenAPI.

Core Concepts

HTTP method decorators

Squid supports all standard HTTP methods via tsoa decorators:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Body, Delete, Get, Patch, Path, Post, Put, Route } from 'tsoa';

interface Item {
id: string;
name: string;
price: number;
}

interface CreateItemRequest {
name: string;
price: number;
}

@Route('items')
export class ItemService extends SquidService {
@Get('{itemId}')
async getItem(@Path() itemId: string): Promise<Item> {
const item: Item = { id: itemId, name: 'Widget', price: 9.99 };
return this.createOpenApiResponse(item);
}

@Post()
async createItem(@Body() data: CreateItemRequest): Promise<Item> {
const newItem: Item = { id: crypto.randomUUID(), ...data };
return this.createOpenApiResponse(newItem, 201);
}

@Put('{itemId}')
async replaceItem(@Path() itemId: string, @Body() data: Item): Promise<Item> {
const updatedItem: Item = { ...data, id: itemId };
return this.createOpenApiResponse(updatedItem);
}

@Patch('{itemId}')
async updateItem(@Path() itemId: string, @Body() data: Partial<Item>): Promise<Item> {
const updatedItem: Item = { id: itemId, name: data.name ?? 'Widget', price: data.price ?? 9.99 };
return this.createOpenApiResponse(updatedItem);
}

@Delete('{itemId}')
async deleteItem(@Path() itemId: string): Promise<void> {
console.log(`Deleting item ${itemId}`);
return this.createOpenApiResponse(undefined, 204);
}
}

Parameter decorators

Extract data from different parts of the HTTP request:

DecoratorSourceExample
@Path()URL path segment/items/{itemId}
@Query()Query string?status=active
@Body()Request bodyJSON payload
@Header()HTTP headerAuthorization header
@UploadedFile(fieldName)Single file uploadForm file input
@UploadedFiles(fieldName)Multiple file uploadsMulti-file form input
@FormField()Form field dataForm text input

All decorators are imported from tsoa.

createOpenApiResponse()

Use this method to build responses with custom status codes and headers:

Backend code
// 200 with body (default)
return this.createOpenApiResponse({ id: '123', name: 'Widget' });

// 201 Created with custom header
return this.createOpenApiResponse({ id: 'new-item' }, 201, { 'x-custom-header': 'created' });

// 204 No Content
return this.createOpenApiResponse(undefined, 204);

// 404 Not Found
return this.createOpenApiResponse({ error: 'Resource not found' }, 404);

Parameters:

ParameterTypeDescription
bodyunknown (optional)The response payload
statusCodenumber (optional)HTTP status code. Defaults to 200 if body is present, 204 if not
headersRecord<string, unknown> (optional)Response headers

throwOpenApiResponse()

Use this method to immediately halt execution and return a response. This is useful for early exits like authorization failures:

Backend code
@Get('protected')
async protectedEndpoint(): Promise<string> {
const apiKey = this.context.headers?.['x-api-key'];
if (!apiKey) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}
return this.createOpenApiResponse('Secret data');
}

OpenAPI context

Every OpenAPI request provides access to raw request details via this.context.openApiContext:

Backend code
@Get('debug')
async debugRequest(): Promise<object> {
const ctx = this.context.openApiContext!;
return this.createOpenApiResponse({
method: ctx.request.method, // 'get', 'post', etc.
path: ctx.request.path, // '/debug'
queryParams: ctx.request.queryParams,
headers: ctx.request.headers,
rawBody: ctx.request.rawBody, // Raw request body string
});
}

File handling

Uploading files:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Post, Route, UploadedFile, UploadedFiles } from 'tsoa';

@Route('files')
export class FileService extends SquidService {
@Post('upload')
async uploadFile(@UploadedFile() file: Express.Multer.File): Promise<object> {
return this.createOpenApiResponse({
filename: file.originalname,
size: file.size,
mimetype: file.mimetype,
});
}

@Post('upload-multiple')
async uploadFiles(@UploadedFiles() files: Express.Multer.File[]): Promise<object> {
return this.createOpenApiResponse(files.map((f) => ({ name: f.originalname, size: f.size })));
}
}

Returning files:

Use the @Produces decorator to specify the response MIME type:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Produces, Route } from 'tsoa';

@Route('files')
export class FileDownloadService extends SquidService {
@Get('download')
@Produces('application/octet-stream')
async downloadFile(): Promise<File> {
const content = new Uint8Array([72, 101, 108, 108, 111]);
const file = new File([content], 'hello.txt', { type: 'text/plain' });
return this.createOpenApiResponse(file);
}
}

Spec documentation decorators

Enhance the generated OpenAPI spec with additional metadata:

DecoratorPurposeExample
@Tags('label')Group endpoints in the spec@Tags('Users')
@Response(code, desc)Document possible response codes@Response(404, 'Not found')
@Produces(mime)Specify response MIME type@Produces('application/octet-stream')

Authentication and Configuration

Configuring the API spec with tsoa.json

Create a tsoa.json file in your backend project root to customize spec generation and define security schemes:

{
"entryFile": "src/service/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["src/**/*.ts"],
"spec": {
"outputDirectory": "dist",
"specVersion": 3,
"securityDefinitions": {
"apiKeyAuth": {
"type": "apiKey",
"name": "my-api-key-header",
"in": "header"
}
}
},
"routes": {
"routesDir": "dist",
"middlewareTemplate": "./node_modules/@squidcloud/local-backend/dist/local-backend/openapi-template.hbs"
}
}

Adding security to endpoints

Use the @Security decorator to mark endpoints as requiring authentication in the generated spec. Apply it at the class level (all endpoints) or method level:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Route, Security } from 'tsoa';

@Route('secure')
@Security('apiKeyAuth')
export class SecureService extends SquidService {
@Get('data')
async getData(): Promise<string> {
return this.createOpenApiResponse('Secure data');
}
}
Important

The @Security decorator only documents the requirement in the OpenAPI spec. You must still implement the validation logic in your method to verify credentials.

Runtime authentication

Validate credentials at runtime using the request context or built-in auth methods:

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Route } from 'tsoa';

@Route('protected')
export class ProtectedService extends SquidService {
@Get('with-api-key')
async withApiKey(): Promise<string> {
const apiKey = this.context.headers?.['x-api-key'];
if (!apiKey || !Object.values(this.apiKeys).includes(apiKey)) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}
return this.createOpenApiResponse('Authenticated data');
}

@Get('with-bearer')
async withBearer(): Promise<string> {
// Use built-in Squid auth (requires Squid auth integration)
this.assertIsAuthenticated();
const user = this.getUserAuth();
return this.createOpenApiResponse(`Hello, ${user?.userId}`);
}
}

For more on authentication methods, see using auth in the backend.

Error Handling

Returning error responses

Use createOpenApiResponse() with appropriate status codes:

Backend code
@Get('{itemId}')
async getItem(@Path() itemId: string): Promise<Item> {
if (!isValidId(itemId)) {
return this.createOpenApiResponse({ error: `Invalid id: ${itemId}` }, 400);
}

const item = await this.findItem(itemId);
if (!item) {
return this.createOpenApiResponse({ error: 'Item not found' }, 404);
}

return this.createOpenApiResponse(item);
}

Interrupting execution with throwOpenApiResponse()

For early exits (e.g., auth failures), use throwOpenApiResponse() to immediately stop processing:

Backend code
@Post('transfer')
async transfer(@Body() data: TransferRequest): Promise<object> {
if (!this.isAuthenticated()) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}

// This code only runs if authenticated
const result = await this.processTransfer(data);
return this.createOpenApiResponse(result);
}

Unhandled exceptions

If your method throws an unhandled error, Squid returns a 500 response with the error message as the body:

Backend code
@Get('risky')
async riskyEndpoint(): Promise<string> {
throw new Error('Something went wrong');
// Returns: 500 with body "Something went wrong"
}

Common errors

ErrorCauseSolution
404 OPENAPI_CONTROLLER_NOT_FOUNDRoute path does not match any @RouteVerify the URL matches your @Route and method path
500 with error messageUnhandled exception in methodAdd error handling with try/catch and createOpenApiResponse()
Missing parametersRequired parameter not provided in requestCheck that query/path/body params match decorator expectations

Best Practices

Security

  1. Always validate credentials at runtime since @Security only documents the requirement in the spec
  2. Use throwOpenApiResponse() for auth failures to prevent further execution
  3. Validate file uploads by checking type and size before processing

API design

  1. Use descriptive route paths (e.g., @Route('users') not @Route('u'))
  2. Return appropriate status codes (201 for creation, 204 for deletion, 400 for bad input)
  3. Use @Tags to organize endpoints in the generated spec
  4. Document responses with @Response decorators for non-200 status codes

Performance

  1. Keep response payloads small by returning only necessary data
  2. Validate input early to avoid unnecessary processing
  3. Use @Produces to set correct content types for non-JSON responses

Code Examples

CRUD API

Backend code
import { SquidService } from '@squidcloud/backend';
import { Body, Delete, Get, Patch, Path, Post, Query, Response, Route, Tags } from 'tsoa';

interface Product {
id: string;
name: string;
price: number;
}

@Route('products')
@Tags('Products')
export class ProductService extends SquidService {
@Get()
@Response(200, 'List of products')
async listProducts(@Query() category?: string): Promise<Product[]> {
const products = this.squid.collection<Product>('products');
let query = products.query();
if (category) {
query = query.where('category', '==', category);
}
const results = await query.snapshot();
return this.createOpenApiResponse(results);
}

@Get('{productId}')
@Response(404, 'Product not found')
async getProduct(@Path() productId: string): Promise<Product> {
const ref = this.squid.collection<Product>('products').doc(productId);
const product = await ref.snapshot();
if (!product) {
return this.createOpenApiResponse({ error: 'Product not found' }, 404);
}
return this.createOpenApiResponse(product);
}

@Post()
@Response(201, 'Product created')
async createProduct(@Body() data: Omit<Product, 'id'>): Promise<Product> {
const id = crypto.randomUUID();
const product = { id, ...data };
await this.squid.collection<Product>('products').doc(id).insert(product);
return this.createOpenApiResponse(product, 201);
}

@Patch('{productId}')
async updateProduct(@Path() productId: string, @Body() data: Partial<Product>): Promise<Product> {
const ref = this.squid.collection<Product>('products').doc(productId);
await ref.update(data);
const updated = await ref.snapshot();
return this.createOpenApiResponse(updated);
}

@Delete('{productId}')
@Response(204, 'Product deleted')
async deleteProduct(@Path() productId: string): Promise<void> {
await this.squid.collection<Product>('products').doc(productId).delete();
return this.createOpenApiResponse(undefined, 204);
}
}

API key-protected endpoint

Backend code
import { SquidService } from '@squidcloud/backend';
import { Body, Post, Route, Security } from 'tsoa';

@Route('webhooks')
@Security('apiKeyAuth')
export class WebhookReceiverService extends SquidService {
@Post('ingest')
async ingestData(@Body() payload: Record<string, unknown>): Promise<object> {
// Validate API key at runtime
const apiKey = this.context.headers?.['x-api-key'];
if (!apiKey || !Object.values(this.apiKeys).includes(apiKey)) {
this.throwOpenApiResponse({ body: 'UNAUTHORIZED', statusCode: 401 });
}

// Process the payload
await this.squid.collection('events').doc(crypto.randomUUID()).insert({
payload,
receivedAt: new Date().toISOString(),
});

return this.createOpenApiResponse({ status: 'accepted' }, 201);
}
}

External API proxy

Backend code
import { SquidService } from '@squidcloud/backend';
import { Get, Query, Route } from 'tsoa';

@Route('weather')
export class WeatherProxyService extends SquidService {
@Get('current')
async getCurrentWeather(@Query() city: string): Promise<object> {
const apiKey = this.secrets['WEATHER_API_KEY'] as string;

const response = await fetch(`https://api.weather.example.com/v1/current?city=${encodeURIComponent(city)}`, { headers: { 'X-API-Key': apiKey } });

if (!response.ok) {
return this.createOpenApiResponse({ error: `Weather API returned ${response.status}` }, response.status);
}

const data = await response.json();
return this.createOpenApiResponse(data);
}
}

See Also