diff --git a/backend/docs/API_VERSIONING.md b/backend/docs/API_VERSIONING.md new file mode 100644 index 000000000..1a9e72c26 --- /dev/null +++ b/backend/docs/API_VERSIONING.md @@ -0,0 +1,40 @@ +# API Versioning Policy + +## Strategy +SkillSync uses **URI Path Versioning** to support future breaking changes without disrupting existing clients. All API endpoints are prefixed with `/api/v/` (e.g., `/api/v1/health`). + +## Configuration +- The default version for all controllers is **v1** unless specified otherwise. +- Versioning is configured in `main.ts` using NestJS `VersioningType.URI`. +- The global prefix is set to `api`. + +## Creating New Versions +You can specify a version at the **controller level** or **method level**: + +```typescript +// Controller-level versioning (applies to all methods) +@Controller({ version: '2', path: 'users' }) +export class UsersControllerV2 { ... } + +// Method-level versioning +@Controller('app') +export class AppController { + @Version('1') + @Get() + oldRoute() { ... } + + @Version('2') + @Get() + newRoute() { ... } +} +``` + +## Deprecation Timeline +- **Notification:** When a new API version is released, the previous version will be marked as deprecated. +- **Header:** Deprecated versions will respond with a `Deprecation: true` HTTP header. +- **Grace Period:** Deprecated versions will remain functional for at least **6 months** to allow clients to migrate. +- **Monitoring:** The `VersionCompatibilityMiddleware` logs all usage of deprecated API endpoints to identify clients still using old versions. + +## Graceful Handling +- Unsupported or removed versions will return a `404 Not Found` response. +- Missing version in the URI (e.g., `/api/users`) will result in a `404 Not Found` response, as NestJS strictly enforces the URI version prefix once enabled. diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index cce879ee6..f48859f9f 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,12 +1,19 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Version } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} + @Version('1') @Get() getHello(): string { return this.appService.getHello(); } + + @Version('2') + @Get() + getHelloV2(): string { + return 'Hello from V2!'; + } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1fcbfa8b3..e460d35d5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { AppDataSource } from './database/data-source'; import { HttpLoggerMiddleware } from './common/middleware/http-logger.middleware'; +import { VersionCompatibilityMiddleware } from './common/middleware/version-compatibility.middleware'; import { AuthModule } from './auth/auth.module'; import { RedisModule } from './redis/redis.module'; import { HealthModule } from './health/health.module'; @@ -40,6 +41,6 @@ import { MetricsMiddleware } from './monitoring/metrics.middleware'; }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { - consumer.apply(HttpLoggerMiddleware, MetricsMiddleware).forRoutes('*'); + consumer.apply(HttpLoggerMiddleware, MetricsMiddleware, VersionCompatibilityMiddleware).forRoutes('*'); } } diff --git a/backend/src/common/middleware/version-compatibility.middleware.ts b/backend/src/common/middleware/version-compatibility.middleware.ts new file mode 100644 index 000000000..ecf4fc476 --- /dev/null +++ b/backend/src/common/middleware/version-compatibility.middleware.ts @@ -0,0 +1,28 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class VersionCompatibilityMiddleware implements NestMiddleware { + private readonly logger = new Logger(VersionCompatibilityMiddleware.name); + // List of versions that are currently deprecated + // Note: 'v1' is the current version, so we might mock 'v0' as deprecated for testing + private readonly deprecatedVersions = ['v0']; + + use(req: Request, res: Response, next: NextFunction) { + // URL pattern with global prefix 'api' and version 'vX' -> /api/vX/something + const urlParts = req.originalUrl.split('?')[0].split('/'); + + // Check if the route has the prefix and a version part + // e.g., urlParts = ['', 'api', 'v1', ...] + const versionPart = urlParts.length > 2 && urlParts[1] === 'api' ? urlParts[2] : null; + + if (versionPart && this.deprecatedVersions.includes(versionPart)) { + this.logger.warn( + `Deprecated API version (${versionPart}) accessed by IP: ${req.ip} for route: ${req.originalUrl}`, + ); + res.setHeader('Deprecation', 'true'); + } + + next(); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index cef047f53..149e02826 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,4 +1,4 @@ -import { HttpStatus, ValidationPipe } from '@nestjs/common'; +import { HttpStatus, ValidationPipe, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; @@ -17,6 +17,13 @@ async function bootstrap() { ? ['error', 'warn'] : ['log', 'error', 'warn', 'debug', 'verbose'], }); + + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + app.disable('x-powered-by'); app.set('trust proxy', process.env.TRUST_PROXY === 'true'); diff --git a/contract/src/test.rs b/contract/src/test.rs index 3723f9798..158dd960b 100644 --- a/contract/src/test.rs +++ b/contract/src/test.rs @@ -130,6 +130,42 @@ mod test_multi_session { (env, buyer, seller, treasury, token_id, admin) } + #[test] + fn test_initialize_sets_admin_and_treasury() { + let (env, _, _, treasury, _, admin) = setup(); + let cid = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &cid); + client.initialize(&admin, &treasury, &604800); + + assert_eq!(client.get_treasury(), treasury); + + // Admin authorization is verified by checking if admin can successfully call an admin-only function + client.set_platform_fee(&100); + assert_eq!(client.get_platform_fee(), 100); + } + + #[test] + #[should_panic(expected = "already initialized")] + fn test_initialize_twice_reverts() { + let (env, _, _, treasury, _, admin) = setup(); + let cid = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &cid); + + client.initialize(&admin, &treasury, &604800); + client.initialize(&admin, &treasury, &604800); + } + + #[test] + #[should_panic(expected = "not initialized")] + fn test_uninitialized_contract_reverts() { + let (env, _, _, _, _, _) = setup(); + let cid = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &cid); + + // Attempting an admin action without initialization should revert + client.set_platform_fee(&100); + } + #[test] fn test_lock_funds_success() { let (env, buyer, seller, treasury, token_id, admin) = setup();