Description
The API has no versioning strategy. All endpoints are under /api/ with no version prefix. As the platform evolves (adding fields, changing response shapes, deprecating endpoints), there is no way to maintain backwards compatibility for existing clients. Breaking changes will immediately break the frontend, mobile apps, and any third-party integrations.
Problem Analysis
Current endpoint structure
POST /api/auth/challenge
POST /api/auth/verify
GET /api/users/me
PUT /api/users/me
GET /api/users/me/progress
GET /api/courses
GET /api/courses/:id
POST /api/courses/:id/enroll
POST /api/quizzes/generate
POST /api/quizzes/:id/submit
POST /api/rewards/claim
GET /api/rewards/history
POST /api/credentials/mint
GET /api/credentials
Risks without versioning
- Adding a required field to a response breaks clients that don't expect it
- Changing a field type (e.g.,
score: number → score: { value: number, max: number }) breaks serialization
- Renaming or removing an endpoint breaks all callers
- No way to sunset old API versions gracefully
- No contract testing between frontend and backend
Required Implementation
A. URL-Based Versioning
Route structure:
/api/v1/auth/challenge ← Current (stable)
/api/v1/auth/verify
/api/v1/users/me
...
/api/v2/auth/challenge ← Future (when breaking changes needed)
/api/v2/users/me
B. Version Router Setup
// New file: src/routes/versioning.ts
import type { FastifyInstance } from "fastify";
import { registerV1Routes } from "./v1/index.js";
export async function registerVersionedRoutes(app: FastifyInstance) {
// V1 routes (current)
app.register(async function v1(app) {
app.addHook("onRequest", async (request) => {
request.apiVersion = "v1";
});
await registerV1Routes(app);
}, { prefix: "/api/v1" });
// Future: V2 routes
// app.register(v2Routes, { prefix: "/api/v2" });
// Redirect /api/* to /api/v1/* for backwards compatibility
app.setNotFoundHandler(async (request, reply) => {
if (request.url.startsWith("/api/") && !request.url.startsWith("/api/v")) {
const newPath = request.url.replace("/api/", "/api/v1/");
return reply.redirect(301, newPath);
}
return reply.status(404).send({ error: "Not found" });
});
}
C. Deprecation Headers
// New file: src/middleware/deprecation.ts
export function deprecationHeader(version: string, sunsetDate: string) {
return async (request: any, reply: any) => {
reply.header("Deprecation", `true`);
reply.header("Sunset", new Date(sunsetDate).toUTCString());
reply.header("Link", `</api/v${parseInt(version) + 1}>; rel="successor-version"`);
};
}
// Usage when deprecating V1:
app.addHook("onRequest", deprecationHeader("1", "2025-06-01"));
D. Response Envelope with Version Info
// Standard response format
interface ApiResponse<T> {
success: boolean;
data: T;
meta: {
version: string;
timestamp: string;
requestId: string;
};
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// Middleware to wrap all responses
app.addHook("onSend", async (request, reply, payload) => {
if (reply.statusCode >= 200 && reply.statusCode < 300) {
try {
const body = JSON.parse(payload);
if (!body.meta) { // Don't double-wrap
const wrapped = {
success: true,
data: body,
meta: {
version: request.apiVersion ?? "v1",
timestamp: new Date().toISOString(),
requestId: request.id,
},
};
return JSON.stringify(wrapped);
}
} catch {
// Non-JSON response, pass through
}
}
return payload;
});
E. Contract Testing Between Frontend and Backend
// New file: tests/contract/api-contract.test.ts
import { describe, it, expect } from "vitest";
import { buildApp } from "../../src/server.js";
import type { FastifyInstance } from "fastify";
describe("API Contract Tests", () => {
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp();
await app.ready();
});
describe("GET /api/v1/courses", () => {
it("response shape matches contract", async () => {
const res = await app.inject({ method: "GET", url: "/api/v1/courses" });
const body = JSON.parse(res.payload);
// Contract: must have these fields
expect(body).toHaveProperty("success", true);
expect(body).toHaveProperty("data");
expect(body).toHaveProperty("meta.version");
expect(body.data).toHaveProperty("courses");
expect(body.data).toHaveProperty("total");
expect(Array.isArray(body.data.courses)).toBe(true);
// Each course must have these fields
if (body.data.courses.length > 0) {
const course = body.data.courses[0];
expect(course).toHaveProperty("id");
expect(course).toHaveProperty("title");
expect(course).toHaveProperty("description");
expect(course).toHaveProperty("difficulty");
expect(course).toHaveProperty("enrolledCount");
expect(typeof course.enrolledCount).toBe("number");
}
});
});
describe("POST /api/v1/quizzes/:id/submit", () => {
it("response shape matches contract", async () => {
// ... test submission response shape ...
});
});
});
F. Version-Specific Type Definitions
// New file: src/types/api/v1.ts
export interface CourseListResponseV1 {
courses: CourseSummaryV1[];
total: number;
}
export interface CourseSummaryV1 {
id: string;
title: string;
description: string | null;
difficulty: string;
enrolledCount: number;
isEnrolled: boolean;
}
// When V2 adds new fields:
// export interface CourseSummaryV2 extends CourseSummaryV1 {
// category: string;
// estimatedHours: number;
// rewardTokenAmount: number;
// }
G. Migration Guide Template
# API Migration Guide: V1 → V2
## Breaking Changes
- `GET /api/v2/courses` response now includes `category` field
- `POST /api/v2/quizzes/submit` now requires `moduleId` in request body
## Deprecated Endpoints
- `GET /api/v1/users/me/progress` → Use `GET /api/v2/users/me/progress-summary`
Sunset: 2025-06-01
## Timeline
- V1 available until: 2025-09-01
- V1 returns deprecation headers starting: 2025-03-01
- V2 available from: 2025-01-01
Files to create
- New:
src/routes/versioning.ts — version router
- New:
src/routes/v1/index.ts — V1 route registrations
- New:
src/middleware/deprecation.ts — deprecation headers
- New:
src/types/api/v1.ts — V1 type contracts
- New:
tests/contract/api-contract.test.ts — contract tests
- Modify:
src/server.ts — register versioned routes
- Move existing route registrations into
src/routes/v1/
Testing Requirements
- All existing endpoints still work under
/api/v1/*
/api/courses redirects to /api/v1/courses with 301
- Deprecation headers present when enabled
- Contract tests verify response shapes match type definitions
- Frontend still works after versioning migration (no breaking changes)
- Test that adding a new V2 route doesn't affect V1 behavior
References
Description
The API has no versioning strategy. All endpoints are under
/api/with no version prefix. As the platform evolves (adding fields, changing response shapes, deprecating endpoints), there is no way to maintain backwards compatibility for existing clients. Breaking changes will immediately break the frontend, mobile apps, and any third-party integrations.Problem Analysis
Current endpoint structure
Risks without versioning
score: number→score: { value: number, max: number }) breaks serializationRequired Implementation
A. URL-Based Versioning
Route structure:
B. Version Router Setup
C. Deprecation Headers
D. Response Envelope with Version Info
E. Contract Testing Between Frontend and Backend
F. Version-Specific Type Definitions
G. Migration Guide Template
Files to create
src/routes/versioning.ts— version routersrc/routes/v1/index.ts— V1 route registrationssrc/middleware/deprecation.ts— deprecation headerssrc/types/api/v1.ts— V1 type contractstests/contract/api-contract.test.ts— contract testssrc/server.ts— register versioned routessrc/routes/v1/Testing Requirements
/api/v1/*/api/coursesredirects to/api/v1/courseswith 301References