Skip to content

[Expert] Implement API versioning, deprecation strategy, and backwards-compatible schema evolution #12

Description

@DeFiVC

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

  1. Adding a required field to a response breaks clients that don't expect it
  2. Changing a field type (e.g., score: numberscore: { value: number, max: number }) breaks serialization
  3. Renaming or removing an endpoint breaks all callers
  4. No way to sunset old API versions gracefully
  5. 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

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignadvancedAdvanced difficultyenhancementNew feature or requesttypescriptTypeScript language

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions