Awesome-omni-skill api-specifications
Guidance for Splits Network REST API design, implementation, and documentation
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/api-specifications" ~/.claude/skills/diegosouzapw-awesome-omni-skill-api-specifications && rm -rf "$T"
skills/development/api-specifications/SKILL.mdAPI Specifications Skill
This skill provides guidance for designing, implementing, and documenting REST APIs in the Splits Network platform.
Purpose
Help developers create consistent, well-documented REST APIs that follow Splits Network standards:
- V2 Architecture: Standardized 5-route CRUD pattern
- Response Format: Consistent
envelope{ data, pagination } - Access Control: Role-based filtering via shared access context
- Event Publishing: Domain events for state changes
- Documentation: Clear endpoint specs and examples
When to Use This Skill
Use this skill when:
- Creating new REST API endpoints
- Migrating V1 endpoints to V2 architecture
- Documenting API contracts
- Implementing pagination, filtering, or search
- Adding validation or error handling
- Publishing domain events
V2 API Architecture Standards
1. Standardized 5-Route Pattern
Every V2 resource follows this exact pattern:
// 1. LIST - Role-scoped collection GET /api/v2/:resource?search=X&status=Y&sort_by=Z&page=1&limit=25 Response: { data: [...], pagination: { total, page, limit, total_pages } } // 2. GET BY ID - Single resource GET /api/v2/:resource/:id?include=related1,related2 Response: { data: {...} } // 3. CREATE - New resource POST /api/v2/:resource Body: { field1: value1, field2: value2, ... } Response: { data: {...} } // 4. UPDATE - Single method handles ALL updates PATCH /api/v2/:resource/:id Body: { field1: newValue1, status: newStatus, ... } Response: { data: {...} } // 5. DELETE - Soft delete DELETE /api/v2/:resource/:id Response: { data: { message: 'Deleted successfully' } }
2. Response Format Standard
ALL responses MUST use the wrapped envelope:
// Success response reply.send({ data: <payload> }) // List response with pagination reply.send({ data: [...], pagination: { total, page, limit, total_pages } }) // Error response reply.code(400).send({ error: { code: "ERROR_CODE", message: "..." } })
NEVER return unwrapped data:
reply.send(payload) is incorrect.
3. Domain-Based Folder Structure
services/<service>/src/v2/ ├── shared/ # Shared V2 utilities │ ├── events.ts # EventPublisher class │ ├── helpers.ts # requireUserContext, validation │ └── pagination.ts # PaginationParams, PaginationResponse ├── <domain>/ # Domain folder (e.g., jobs, candidates) │ ├── types.ts # Domain-specific types │ ├── repository.ts # Data access with role-based filtering │ └── service.ts # Business logic, validation, events └── routes.ts # All V2 routes (imports from domains)
4. Repository Pattern with Access Context
import { resolveAccessContext } from "@splits-network/shared-access-context"; export class ResourceRepository { constructor(private supabase: SupabaseClient) {} async list(clerkUserId: string, filters: ResourceFilters) { const context = await resolveAccessContext(clerkUserId, this.supabase); const query = this.supabase.from("resources").select("*"); // Apply role-based filtering from access context if (context.role === "recruiter") { query.eq("user_id", context.userId); } else if (context.role === "company_admin") { query.in("company_id", context.accessibleCompanyIds); } // Platform admins see everything (no filter) // Apply search/sorting filters if (filters.search) { query.ilike("name", `%${filters.search}%`); } if (filters.sort_by) { query.order(filters.sort_by, { ascending: filters.sort_order !== "desc", }); } return query; } }
5. Service Layer with Events
export class ResourceServiceV2 { constructor( private repository: ResourceRepository, private events: EventPublisher, ) {} async create(clerkUserId: string, data: ResourceCreate) { // Validate input this.validateResourceData(data); // Create via repository const resource = await this.repository.create(clerkUserId, data); // Publish event after successful creation await this.events.publish("resource.created", { resourceId: resource.id, createdBy: clerkUserId, }); return resource; } async update(id: string, clerkUserId: string, updates: ResourceUpdate) { // Smart validation based on what's being updated if (updates.status) { this.validateStatusTransition(updates.status); } const updated = await this.repository.update(id, clerkUserId, updates); // Publish event await this.events.publish("resource.updated", { resourceId: id, changes: Object.keys(updates), updatedBy: clerkUserId, }); return updated; } }
6. Route Implementation
import { FastifyInstance } from "fastify"; export async function resourceRoutes( app: FastifyInstance, service: ResourceServiceV2, ) { // LIST app.get("/api/v2/resources", async (request, reply) => { const clerkUserId = request.headers["x-clerk-user-id"] as string; const { search, status, sort_by, sort_order, page = 1, limit = 25, } = request.query as any; const result = await service.list(clerkUserId, { search, status, sort_by, sort_order, page, limit, }); return reply.send(result); // Service returns { data, pagination } }); // GET BY ID app.get("/api/v2/resources/:id", async (request, reply) => { const clerkUserId = request.headers["x-clerk-user-id"] as string; const { id } = request.params as { id: string }; const { include } = request.query as { include?: string }; const resource = await service.getById(id, clerkUserId, include); return reply.send({ data: resource }); }); // CREATE app.post("/api/v2/resources", async (request, reply) => { const clerkUserId = request.headers["x-clerk-user-id"] as string; const data = request.body as ResourceCreate; const resource = await service.create(clerkUserId, data); return reply.code(201).send({ data: resource }); }); // UPDATE app.patch("/api/v2/resources/:id", async (request, reply) => { const clerkUserId = request.headers["x-clerk-user-id"] as string; const { id } = request.params as { id: string }; const updates = request.body as ResourceUpdate; const resource = await service.update(id, clerkUserId, updates); return reply.send({ data: resource }); }); // DELETE app.delete("/api/v2/resources/:id", async (request, reply) => { const clerkUserId = request.headers["x-clerk-user-id"] as string; const { id } = request.params as { id: string }; await service.delete(id, clerkUserId); return reply.send({ data: { message: "Deleted successfully" } }); }); }
Query Parameters
List Endpoints
Standard query parameters for list endpoints:
interface StandardListParams { page?: number; // Page number (1-based) limit?: number; // Items per page (default 25, max 100) search?: string; // Search term (service-specific fields) sort_by?: string; // Field to sort by sort_order?: "asc" | "desc"; // Sort direction filters?: Record<string, any>; // Domain-specific filters include?: string; // Comma-separated related resources }
Examples:
/api/v2/jobs?page=1&limit=25&search=engineer&status=active/api/v2/applications?candidate_id=123&stage=screen&sort_by=created_at&sort_order=desc/api/v2/candidates?include=documents,applications
Get By ID Endpoints
Support
include parameter for related data:
// GET /api/v2/applications/:id?include=candidate,job,ai_review if (includes.includes("candidate")) { application.candidate = await this.getCandidateData( application.candidate_id, ); }
Pagination
All list endpoints must return pagination metadata:
interface PaginationResponse { total: number; // Total items across all pages page: number; // Current page number (1-based) limit: number; // Items per page total_pages: number; // Total number of pages } // Example response { "data": [...], "pagination": { "total": 1000, "page": 1, "limit": 25, "total_pages": 40 } }
Error Handling
Return structured error responses:
// 400 Bad Request - Validation error reply.code(400).send({ error: { code: "VALIDATION_ERROR", message: "Invalid input data", details: { field: "email", reason: "Invalid email format" }, }, }); // 401 Unauthorized - Missing/invalid auth reply.code(401).send({ error: { code: "UNAUTHORIZED", message: "Authentication required", }, }); // 403 Forbidden - Insufficient permissions reply.code(403).send({ error: { code: "FORBIDDEN", message: "Insufficient permissions to access this resource", }, }); // 404 Not Found - Resource doesn't exist reply.code(404).send({ error: { code: "NOT_FOUND", message: "Resource not found", details: { resourceId: id }, }, }); // 409 Conflict - Duplicate or constraint violation reply.code(409).send({ error: { code: "CONFLICT", message: "Resource already exists", details: { constraint: "unique_email" }, }, }); // 500 Internal Server Error - Unexpected error reply.code(500).send({ error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred", }, });
Event Publishing
Publish domain events for significant state changes:
// After successful create await this.events.publish("resource.created", { resourceId: resource.id, companyId: resource.company_id, createdBy: clerkUserId, }); // After successful update await this.events.publish("resource.updated", { resourceId: id, changes: Object.keys(updates), updatedBy: clerkUserId, }); // After successful delete await this.events.publish("resource.deleted", { resourceId: id, deletedBy: clerkUserId, }); // Domain-specific events await this.events.publish("application.stage_changed", { applicationId: id, oldStage: oldStage, newStage: newStage, changedBy: clerkUserId, });
API Documentation
Document each endpoint with:
- Purpose: What the endpoint does
- Path: Full path with parameters
- Method: HTTP method (GET, POST, PATCH, DELETE)
- Auth: Required authentication (Clerk JWT)
- Access: Who can access (roles, scoping rules)
- Query Params: Optional parameters
- Request Body: Expected payload structure
- Response: Success response format
- Errors: Possible error codes and meanings
- Example: Full request/response example
Example Documentation
### List Jobs Returns a paginated list of jobs based on role-based access control. **Endpoint**: `GET /api/v2/jobs` **Authentication**: Required (Clerk JWT) **Access Control**: - **Recruiters**: See assigned jobs only - **Company Users**: See jobs from their organization - **Platform Admins**: See all jobs **Query Parameters**: - `page` (number, optional): Page number (default: 1) - `limit` (number, optional): Items per page (default: 25, max: 100) - `search` (string, optional): Search in job title - `status` (string, optional): Filter by status (active, paused, closed) - `company_id` (uuid, optional): Filter by company - `sort_by` (string, optional): Sort field (created_at, title) - `sort_order` (string, optional): Sort direction (asc, desc) **Response**: `200 OK` ```json { "data": [ { "id": "123e4567-e89b-12d3-a456-426614174000", "title": "Senior Software Engineer", "company_id": "123e4567-e89b-12d3-a456-426614174001", "status": "active", "location": "San Francisco, CA", "created_at": "2026-01-13T10:00:00Z" } ], "pagination": { "total": 100, "page": 1, "limit": 25, "total_pages": 4 } } ```
Errors:
: Missing or invalid authentication401 Unauthorized
: Insufficient permissions403 Forbidden
: Server error500 Internal Server Error
Example Request:
curl -X GET "https://api.splits.network/api/v2/jobs?page=1&limit=25&status=active" \ -H "Authorization: Bearer <clerk-jwt>"
## Common Patterns ### Current User Access Pattern For user-specific singleton resources, use the `/me` alias pattern: ```typescript // ✅ RECOMMENDED - Use /me alias on existing GET by ID endpoint GET /api/v2/candidates/me // Resolves to user's actual ID, returns { data: {...} } // Implementation in route handler app.get('/api/v2/candidates/:id', async (request, reply) => { const clerkUserId = request.headers['x-clerk-user-id'] as string; let { id } = request.params; // Resolve "me" to actual user ID if (id === 'me') { const context = await resolveAccessContext(clerkUserId, supabase); id = context.userId; // Actual UUID } // Standard getById logic - no special handling needed const candidate = await service.getById(id, clerkUserId); return reply.send({ data: candidate }); });
Benefits:
- Intuitive: Clear what
means/me - Performant: Direct ID lookup (not filtered list query)
- Correct Shape: Returns
(singleton, not array){ data: {...} } - No New Endpoint: Just an alias within existing GET by ID route
- Still Secure: Access context validates user can access resolved ID
Single Update Method
One update method handles ALL updates with smart validation:
// ❌ WRONG - Multiple update endpoints PATCH /api/v2/jobs/:id/status PATCH /api/v2/jobs/:id/title PATCH /api/v2/jobs/:id/close // ✅ CORRECT - Single update endpoint PATCH /api/v2/jobs/:id Body: { status: "closed" } or { title: "New Title" } or any field
Include Parameters vs Child Endpoints
Use include parameters for related data, NOT child endpoints:
// ❌ WRONG - Child endpoints GET /api/v2/applications/:id/documents GET /api/v2/applications/:id/ai-review // ✅ CORRECT - Include parameters GET /api/v2/applications/:id?include=documents,ai_review GET /api/v2/documents?application_id=:id GET /api/v2/ai-reviews?application_id=:id
Data Enrichment with JOINs
Services share the same database (all tables in
public schema) and can enrich data with JOINs:
// Enrich applications with candidate and job data const enrichedApplications = await this.supabase.from("applications").select(` *, candidate:candidates(*), job:jobs(*), recruiter:recruiters(*) `); // Note: All domain tables (applications, candidates, jobs, recruiters, etc.) // are in the public schema. No cross-schema queries needed.
References
- API Response Format:
docs/guidance/api-response-format.md - Pagination Standard:
docs/guidance/pagination.md - V2 Architecture Guide:
docs/migration/v2/V2-ARCHITECTURE-IMPLEMENTATION-GUIDE.md - Service Architecture:
docs/guidance/service-architecture-pattern.md - Access Context:
packages/shared-access-context/README.md
Examples from Production
V2 Services:
- Identity Service:
services/identity-service/src/v2/ - ATS Service:
services/ats-service/src/v2/ - Network Service:
services/network-service/src/v2/ - Billing Service:
services/billing-service/src/v2/ - Notification Service:
services/notification-service/src/v2/
API Gateway Routes:
- Gateway V2 Proxy:
services/api-gateway/src/routes/v2/