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 entitiesPOST /api/v1/{plural}- Create entityGET /api/v1/{plural}/{id}- Get entity by IDPUT /api/v1/{plural}/{id}- Update entityDELETE /api/v1/{plural}/{id}- Delete entityPOST /api/v1/{plural}/{id}/archive- Archive entityPOST /api/v1/{plural}/{id}/restore- Restore entityGET /api/v1/{plural}/count- Count entitiesGET /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
refandfilterfields 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)
| Code | HTTP Status | Description | Example |
|---|---|---|---|
VALIDATION_ERROR | 400 | Input validation failed | Invalid email format, missing required field |
BUSINESS_RULE_VIOLATION | 400 | Business logic constraint violated | Cannot delete system role |
OWNER_REQUIRED | 400 | Owner ID required for operation | Admin creating entity without ownerId |
INVALID_OPERATION | 400 | Operation not allowed in current state | Cannot archive already archived item |
UNAUTHORIZED | 401 | Authentication required | Missing or invalid auth token |
FORBIDDEN | 403 | Access denied | Insufficient permissions for operation |
NOT_FOUND | 404 | Resource not found | Entity ID doesn't exist |
CONFLICT | 409 | Resource conflict | Unique constraint violation |
UNPROCESSABLE_ENTITY | 422 | Valid data but unprocessable | External service dependency failed |
Server Errors (5xx)
| Code | HTTP Status | Description |
|---|---|---|
INTERNAL_ERROR | 500 | Internal server error |
SERVICE_UNAVAILABLE | 503 | Service 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.fieldErrorsfor 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=trueif 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
- Contracts - Schema definitions for validation
- Services - Service layer API
- Authentication - Auth & permissions
- Metadata - Runtime schema introspection