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/server/routes/api/v1/
├── products/
│ ├── +server.ts # GET (list), POST (create)
│ ├── [id]/
│ │ ├── +server.ts # GET (one), PUT (update), DELETE
│ │ ├── archive/+server.ts # POST (archive)
│ │ ├── restore/+server.ts # POST (restore)
│ │ ├── versions/+server.ts # GET (version history) - if versioning enabled
│ │ └── revert/[version]/+server.ts # POST (revert) - if full versioning
│ ├── count/+server.ts # GET (count)
│ └── archived/+server.ts # GET (list archived)

Standard endpoints:

  • GET /api/v1/{plural} - List entities
  • POST /api/v1/{plural} - Create entity
  • GET /api/v1/{plural}/{id} - Get entity by ID
  • PUT /api/v1/{plural}/{id} - Update entity
  • DELETE /api/v1/{plural}/{id} - Delete entity
  • POST /api/v1/{plural}/{id}/archive - Archive entity
  • POST /api/v1/{plural}/{id}/restore - Restore entity
  • GET /api/v1/{plural}/count - Count entities
  • GET /api/v1/{plural}/archived - List archived entities

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)
listArchived(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)
"sort": { // Sort results (incompatible with cursor)
"field": "createdAt",
"direction": "desc",
"collation": { // Optional MongoDB collation
"locale": "en",
"strength": 2,
"numericOrdering": true
}
}
}

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)
}
}
}

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)

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
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/server/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/server/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.yml

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