Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/docs/API_VERSIONING.md
Original file line number Diff line number Diff line change
@@ -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<version>/` (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.
9 changes: 8 additions & 1 deletion backend/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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!';
}
}
3 changes: 2 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('*');
}
}
28 changes: 28 additions & 0 deletions backend/src/common/middleware/version-compatibility.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
9 changes: 8 additions & 1 deletion backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');

Expand Down
36 changes: 36 additions & 0 deletions contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading