Skip to main content

HTTP Routes & API Standards

Radish CLI generates complete REST API endpoints with standardized request validation and response formats for all entities.

Overview​

For each entity in your blueprint, Radish generates:

.radish/lib/datalayer/routes/api/v1/
├── products/
│ ├── +server.ts # GET (list), POST (create)
│ ├── [id]/
│ │ ├── +server.ts # GET (one), PATCH (update), DELETE
│ │ ├── increment/+server.ts # PATCH (atomic increment)
│ │ ├── versions/+server.ts # GET (version history) - if versioning enabled
│ │ └── revert/[version]/+server.ts # POST (revert) - if full versioning
│ ├── bulk/+server.ts # POST (bulk create with validation)
│ └── count/+server.ts # GET (count)

Standard endpoints:

  • GET /api/v1/{plural} - List entities
  • POST /api/v1/{plural} - Create entity
  • POST /api/v1/{plural}/bulk - Bulk create with validation
  • GET /api/v1/{plural}/{id} - Get entity by ID
  • PATCH /api/v1/{plural}/{id} - Update entity (partial update)
  • DELETE /api/v1/{plural}/{id} - Delete entity
  • PATCH /api/v1/{plural}/{id}/increment - Atomic increment numeric fields
  • GET /api/v1/{plural}/count - Count entities

Note: The service layer provides archive(), restore(), and listArchived() methods, but these are not exposed as HTTP endpoints. Use them from server-side code via the service layer.

Entities without PATCH (update): ApiKey (write-once), Setting (uses set()), LogMessage (append-only), IdempotencyKey (system-managed). All other entities — business and system — support partial update via PATCH.

Bulk Create Endpoint​

POST /api/v1/{plural}/bulk
Content-Type: application/json
Authorization: Bearer <token>

{
"records": [
{ "title": "Product A", "price": 19.99 },
{ "title": "Product B", "price": 29.99 }
],
"validate": true,
"preserveIds": false,
"onError": "collect"
}

Parameters:

FieldTypeDefaultDescription
recordsarray(required)Array of entity records to create
validatebooleantrueApply Zod schema validation to each record
preserveIdsbooleanfalseKeep existing _id fields (for restores/migrations)
onErrorstring"collect""throw" stops on first error, "collect" continues and collects errors

Response:

{
"data": {
"inserted": [
{ "id": "abc123", "title": "Product A", "price": 19.99 },
{ "id": "def456", "title": "Product B", "price": 29.99 }
],
"errors": []
}
}

Permissions Required: system:admin

Request Validation​

All API endpoints automatically validate input using Zod schemas before processing. This ensures type safety and provides clear error messages for invalid data.

Service Method Validation​

Each service method validates its input parameters:

// List/Query operations
list(input: unknown) → productCriteriaSchema.parse(input)
count(input: unknown) → productCriteriaSchema.parse(input)

// CRUD operations
create(input: unknown) → createProductSchema.parse(input)
update(id: string, input: unknown) → updateProductSchema.parse(input)

Query Parameters​

Standard query parameters are validated through entity criteria schemas:

// Example valid query parameters
{
"q": "search term", // Text search
"limit": 25, // Results per page (1-200)
"cursor": "cursor_token", // Pagination cursor (incompatible with sort)
"status": "ACTIVE", // Enum field filter
"tags": ["tag1", "tag2"], // Array field filter
"authorId": "507f1f77...", // ObjectId field filter
"populate": "authorId,categoryId", // Reference population
"includeArchived": false, // Include archived items (default: false)
"dateFrom": "2026-01-01", // Records from this date (ISO format)
"dateTo": "2026-06-30", // Records up to this date
"dateMonth": "2026-03", // Records in a specific month (shorthand for dateFrom+dateTo)
"dateField": "publishedAt", // Target date field (default: createdAt)
"skip": 20, // Offset-based pagination (alternative to cursor)
"offset": 20, // Alias for skip
"fields": "name,price,status", // Field projection (comma-separated)
"sort": { // Sort results (incompatible with cursor)
"field": "createdAt",
"direction": "desc",
"collation": { // Optional MongoDB collation
"locale": "en",
"strength": 2,
"numericOrdering": true
}
}
}

Field Projection​

The fields parameter selects which fields to return, reducing payload size when you only need specific data.

Usage​

# List with projection — returns only name and price
GET /api/v1/products?fields=name,price,status

# Single item with projection
GET /api/v1/products/123?fields=name,price

# Combine with other query params
GET /api/v1/products?fields=name,price&status=published&sort=-price&limit=50

Response with projection:

{
"data": {
"items": [
{ "id": "abc123", "name": "Widget", "price": 29.99, "status": "published" },
{ "id": "def456", "name": "Gadget", "price": 49.99, "status": "published" }
],
"totalCount": 42,
"page": 1,
"totalPages": 3
}
}

The id field is always included. Projection works with populate — projected referenced fields still expand when populated.

Service-Level Projection​

The same select array is available directly in TypeScript:

const result = await productService.list({
select: ['name', 'price'],
limit: 100
});

Reference Population​

The populate parameter allows you to expand referenced entities in the response, avoiding additional API calls. This works for any field defined with a ref in your blueprint.

Basic Population​

# Single field population
GET /api/v1/books?populate=authorId

# Multiple fields (comma-separated)
GET /api/v1/books?populate=authorId,categoryId

# Also works on individual items
GET /api/v1/books/123?populate=authorId

Response with population:

{
"data": {
"items": [{
"id": "507f1f77bcf86cd799439011",
"title": "The Great Book",
"authorId": {
"id": "607f1f77bcf86cd799439012",
"name": "John Doe",
"email": "john@example.com",
"bio": "Bestselling author"
},
"categoryId": {
"id": "707f1f77bcf86cd799439013",
"name": "Fiction",
"description": "Fiction books"
}
}]
}
}

Nested Population​

You can populate references within references using dot notation:

# Populate book's author and the author's department
GET /api/v1/books?populate=authorId.departmentId

# Multiple nested paths
GET /api/v1/books?populate=authorId.departmentId,reviewerId.teamId

Response with nested population:

{
"data": {
"items": [{
"id": "507f1f77bcf86cd799439011",
"title": "The Great Book",
"authorId": {
"id": "607f1f77bcf86cd799439012",
"name": "John Doe",
"departmentId": {
"id": "807f1f77bcf86cd799439014",
"name": "Literature Department",
"head": "Jane Smith"
}
}
}]
}
}

Advanced Population Formats​

The populate parameter accepts multiple formats:

// 1. String format (comma-separated)
?populate=authorId,categoryId

// 2. Array format (for programmatic use)
{
populate: ["authorId", "categoryId"]
}

// 3. Configuration object (with depth control)
{
populate: {
paths: ["authorId.departmentId", "categoryId"],
depth: 2 // Maximum nesting depth (1-5)
}
}

Population Notes​

  • Performance: Population adds database queries, so use judiciously
  • Permissions: Populated entities respect the user's permissions - fields they cannot view will be filtered out
  • Circular References: The system prevents infinite loops in circular references
  • Non-existent References: If a referenced entity doesn't exist (deleted, etc.), the field returns null
  • Array References: Works with array fields like tagIds: objectId[] - each item in the array gets populated

Sorting​

The sort parameter allows you to control the order of results:

Basic Sorting​

# Sort by field (ascending by default)
GET /api/v1/books?sort[field]=title&sort[direction]=asc

# Sort descending
GET /api/v1/books?sort[field]=createdAt&sort[direction]=desc

JSON format (for programmatic use):

{
"sort": {
"field": "title",
"direction": "asc"
}
}

Advanced Sorting with Collation​

MongoDB collation controls how text is compared and sorted:

{
"sort": {
"field": "title",
"direction": "asc",
"collation": {
"locale": "en", // Language locale
"strength": 2, // Case-insensitive (1=strict, 2=case-insensitive)
"numericOrdering": true // Natural number sorting (1, 2, 10)
}
}
}

String Shorthand​

For simple sorting, pass a string prefixed with - for descending:

GET /api/v1/products?sort=-createdAt     # Newest first
GET /api/v1/products?sort=title # A-Z by title

This is equivalent to sort[field]=createdAt&sort[direction]=desc but more concise.

Sorting Notes​

  • Cursor Pagination: When using custom sorting with a cursor, the cursor is ignored and results start from the first page (this allows users to change sorting without breaking pagination)
  • Default Sorting: Without sort, results are sorted by _id (creation order)
  • Field Validation: Sort field must exist in the entity schema
  • Performance: Indexed fields sort faster - all ref and filter fields are auto-indexed
  • Collation: Each entity has sensible default collation (case-insensitive, natural number ordering)

Rate Limiting​

All API routes are automatically rate-limited via the builtin sliding window middleware:

TierLimitScope
auth20 req/min/api/v1/auth/*
api100 req/minAuthenticated requests (per user)
public60 req/minUnauthenticated requests (per IP)

Rate limit headers are included on every response:

  • x-ratelimit-limit — Maximum requests allowed
  • x-ratelimit-remaining — Requests remaining in window
  • x-ratelimit-reset — Seconds until window resets

When rate-limited, the endpoint returns 429 Too Many Requests with a retry-after header.

Correlation IDs​

Every API response includes an x-correlation-id header — a UUID that traces the request through middleware, services, and telemetry. If the inbound request already has an x-correlation-id header (e.g., from an API gateway or load balancer), it is preserved.

Use correlation IDs for:

  • Tracing a request across service boundaries
  • Filtering logs for a specific request
  • Debugging errors reported by end users

Response Envelopes​

All API responses use standardized envelope formats for consistency and better error handling.

Success Responses​

All successful responses are wrapped in a data envelope:

{
"data": {
// Actual response content here
}
}

Examples:

// Single item
{
"data": {
"id": "507f1f77bcf86cd799439011",
"title": "Example Product",
"status": "ACTIVE",
"createdAt": "2024-01-15T10:30:00Z"
}
}

// List response
{
"data": {
"items": [
{ "id": "507f...", "title": "Item 1" },
{ "id": "608a...", "title": "Item 2" }
],
"nextCursor": "eyJpZCI6IjYwOGE..."
}
}

// Count response
{
"data": 42
}

Error Responses​

All error responses follow a consistent structure:

{
"error": {
"message": "Human-readable error description",
"code": "MACHINE_READABLE_ERROR_CODE",
"details": {
// Optional additional error details
}
}
}

Error Codes Reference​

Client Errors (4xx)​

CodeHTTP StatusDescriptionExample
VALIDATION_ERROR400Input validation failedInvalid email format, missing required field
BUSINESS_RULE_VIOLATION400Business logic constraint violatedCannot delete system role
OWNER_REQUIRED400Owner ID required for operationAdmin creating entity without ownerId
INVALID_OPERATION400Operation not allowed in current stateCannot archive already archived item
UNAUTHORIZED401Authentication requiredMissing or invalid auth token
FORBIDDEN403Access deniedInsufficient permissions for operation
NOT_FOUND404Resource not foundEntity ID doesn't exist
CONFLICT409Resource conflictUnique constraint violation
RATE_LIMITED429Too many requestsRate limit exceeded
UNPROCESSABLE_ENTITY422Valid data but unprocessableExternal service dependency failed

Server Errors (5xx)​

CodeHTTP StatusDescription
INTERNAL_ERROR500Internal server error
SERVICE_UNAVAILABLE503Service temporarily unavailable

Error Response Examples​

Validation Error​

{
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"details": {
"fieldErrors": {
"email": ["Invalid email format"],
"age": ["Expected number, received string"]
},
"formErrors": []
}
}
}

Permission Error​

{
"error": {
"message": "Access denied",
"code": "FORBIDDEN"
}
}

Business Rule Violation​

{
"error": {
"message": "Cannot delete system role",
"code": "BUSINESS_RULE_VIOLATION",
"details": {
"roleKey": "ADMIN",
"isSystem": true
}
}
}

Not Found Error​

{
"error": {
"message": "Resource not found",
"code": "NOT_FOUND"
}
}

Generated Error Utilities​

Radish generates error utilities for consistent error handling:

import { createError, ErrorCodes, isAppError } from '@generated/datalayer/core/util/errors';

// Create specific errors
throw createError.validation('Invalid email format', {
fieldErrors: { email: ['Invalid format'] }
});

throw createError.forbidden('Access denied');
throw createError.notFound('Product not found');
throw createError.businessRule('Cannot delete system role', { roleKey: 'ADMIN' });

// Check error types
if (isAppError(error)) {
console.log(error.code); // ErrorCodes.FORBIDDEN
console.log(error.status); // 403
console.log(error.details); // Additional details
}

Client Integration​

TypeScript Client​

import type { Product, CreateProduct, ProductCriteria } from '@generated/datalayer/contracts';

// Define response types
interface ApiResponse<T> {
data: T;
}

interface ApiError {
error: {
message: string;
code: string;
details?: any;
};
}

type ProductListResponse = ApiResponse<{
items: Product[];
nextCursor?: string;
}>;

type ProductResponse = ApiResponse<Product>;

// API client
async function listProducts(
criteria: ProductCriteria
): Promise<{ items: Product[]; nextCursor?: string }> {
const params = new URLSearchParams();

if (criteria.q) params.append('q', criteria.q);
if (criteria.limit) params.append('limit', String(criteria.limit));
if (criteria.cursor) params.append('cursor', criteria.cursor);
if (criteria.populate) params.append('populate', criteria.populate);

const response = await fetch(`/api/v1/products?${params}`, {
headers: { 'Authorization': `Bearer ${token}` }
});

if (!response.ok) {
const { error } = await response.json() as ApiError;
throw new Error(error.message);
}

const { data } = await response.json() as ProductListResponse;
return data;
}

async function createProduct(input: CreateProduct): Promise<Product> {
const response = await fetch('/api/v1/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
});

if (!response.ok) {
const { error } = await response.json() as ApiError;
throw new Error(error.message);
}

const { data } = await response.json() as ProductResponse;
return data;
}

Error Handling Pattern​

async function handleApiCall<T>(
request: () => Promise<Response>
): Promise<T> {
try {
const response = await request();
const data = await response.json();

if (!response.ok) {
const error = data.error;

switch (error.code) {
case 'VALIDATION_ERROR':
// Show field-level validation errors
showValidationErrors(error.details.fieldErrors);
break;

case 'UNAUTHORIZED':
// Redirect to login
redirectToLogin();
break;

case 'FORBIDDEN':
// Show access denied message
showAccessDenied();
break;

case 'NOT_FOUND':
// Show 404 page or message
show404();
break;

default:
// Show generic error message
showError(error.message);
}

throw new Error(error.message);
}

// Success - unwrap data envelope
return data.data;
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}

// Usage
const products = await handleApiCall<{ items: Product[]; nextCursor?: string }>(
() => fetch('/api/v1/products', {
headers: { 'Authorization': `Bearer ${token}` }
})
);

React Hook Example​

import { useState, useEffect } from 'react';
import type { Product, ProductCriteria } from '@generated/datalayer/contracts';

function useProducts(criteria: ProductCriteria) {
const [data, setData] = useState<{ items: Product[]; nextCursor?: string }>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();

useEffect(() => {
async function fetchProducts() {
try {
setLoading(true);
const params = new URLSearchParams();

if (criteria.q) params.append('q', criteria.q);
if (criteria.limit) params.append('limit', String(criteria.limit));

const response = await fetch(`/api/v1/products?${params}`, {
headers: { 'Authorization': `Bearer ${token}` }
});

if (!response.ok) {
const { error } = await response.json();
throw new Error(error.message);
}

const { data } = await response.json();
setData(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}

fetchProducts();
}, [criteria.q, criteria.limit]);

return { data, loading, error };
}

// Component usage
function ProductList() {
const { data, loading, error } = useProducts({ limit: 20 });

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

return (
<ul>
{data?.items.map(product => (
<li key={product.id}>{product.title}</li>
))}
</ul>
);
}

SvelteKit Integration​

// src/routes/products/+page.server.ts
import { ProductService } from '@generated/datalayer/services';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals, url }) => {
const service = new ProductService(locals.auth);

const result = await service.list({
q: url.searchParams.get('q') || undefined,
limit: 20,
populate: 'categoryId'
});

return {
products: result.items,
nextCursor: result.nextCursor
};
};

Fastify Integration​

import Fastify from 'fastify';
import { productRoutes } from '.radish/lib/datalayer/routes/api/v1/products';

const fastify = Fastify();

// Register generated routes
fastify.register(productRoutes, { prefix: '/api/v1' });

await fastify.listen({ port: 3000 });

Troubleshooting​

400 VALIDATION_ERROR​

Cause: Input data doesn't match the schema.

Solution:

  • Check error.details.fieldErrors for specific field issues
  • Verify required fields are provided
  • Ensure field types match (string, number, etc.)
  • Use Schema suffix imports: createProductSchema.parse(input)

401 UNAUTHORIZED​

Cause: Missing or invalid authentication token.

Solution:

  • Include Authorization: Bearer <token> header
  • Verify token hasn't expired
  • Check token was obtained from /api/v1/auth/login

403 FORBIDDEN​

Cause: User lacks required permissions.

Solution:

  • Check user's roles: GET /api/v1/auth/me
  • Verify user has appropriate permissions for the operation
  • Review role definitions in {app}.roles.json

404 NOT_FOUND​

Cause: Entity with given ID doesn't exist.

Solution:

  • Verify the ID is correct
  • Check if entity was deleted or archived
  • Use includeArchived=true if searching for archived items

Best Practices​

1. Always Use Type-Safe Contracts​

// ✅ Good - Type-safe with validation
import { createProductSchema } from '@generated/datalayer/contracts';
import type { CreateProduct } from '@generated/datalayer/contracts';

const input: CreateProduct = { ... };
const validated = createProductSchema.parse(input);

// ❌ Bad - No type safety
const input = { ... };
fetch('/api/v1/products', { body: JSON.stringify(input) });

2. Handle Errors Consistently​

// ✅ Good - Comprehensive error handling
try {
const { data } = await response.json();
return data;
} catch (error) {
if (error.code === 'VALIDATION_ERROR') {
// Show field errors
} else if (error.code === 'FORBIDDEN') {
// Handle permission denied
}
}

3. Use Pagination​

// ✅ Good - Paginated requests
const result = await service.list({ limit: 20, cursor: nextCursor });

// ❌ Bad - Fetching all data
const result = await service.list({ limit: 999999 });

4. Populate Sparingly​

// ✅ Good - Only populate what you need
?populate=authorId

// ❌ Bad - Over-populating
?populate=authorId.departmentId.managerId.officeId

Next Steps​