diff --git a/.gitignore b/.gitignore index f00599659..26eb6ff37 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ test/server-test-config/ssl-key.pem test/server-test-config/plugin-config-data/ docs/dist + +# TSOA generated files +src/api/generated/ diff --git a/docs/develop/rest-api/tsoa_migration_guide.md b/docs/develop/rest-api/tsoa_migration_guide.md new file mode 100644 index 000000000..edc38797a --- /dev/null +++ b/docs/develop/rest-api/tsoa_migration_guide.md @@ -0,0 +1,207 @@ +# TSOA Migration Guide for SignalK Server + +## Overview + +This guide documents the pattern for migrating SignalK REST API endpoints from traditional Express handlers to TSOA controllers with TypeScript decorators, runtime validation, and automatic OpenAPI generation. + +## Benefits of TSOA Migration + +1. **Type Safety**: Full TypeScript support with compile-time type checking +2. **Runtime Validation**: Automatic request/response validation based on TypeScript types +3. **OpenAPI Generation**: Automatic OpenAPI/Swagger documentation from code +4. **Decorator-based Routing**: Clean, declarative API definitions +5. **Reduced Boilerplate**: Less manual validation and error handling code + +## Migration Pattern + +### Step 1: Create TSOA Controller + +Create a new controller file with TSOA decorators: + +```typescript +import { Controller, Get, Route, Tags, Security, Request } from 'tsoa' +import express from 'express' + +@Route('vessels/self/navigation') +@Tags('Navigation') +export class CourseController extends Controller { + @Get('course') + @Security('signalK', ['read']) + public async getCourseInfo( + @Request() request: express.Request + ): Promise { + const app = request.app as any + const api = app.courseApi + return api.getCourseInfo() + } +} +``` + +### Step 2: Configure TSOA + +Update `tsoa.json` to include your controller: + +```json +{ + "controllerPathGlobs": ["src/api/*/YourController.ts"], + "spec": { + "outputDirectory": "src/api/generated", + "specFileBaseName": "your-api" + } +} +``` + +### Step 3: Implement Parallel Endpoint (Recommended) + +For safe migration, implement a parallel endpoint first: + +```typescript +@Get('course-tsoa') // Parallel endpoint for gradual migration +``` + +This allows: + +- A/B testing in production +- Gradual client migration +- Easy rollback if issues arise + +### Step 4: Merge OpenAPI Specifications + +Create a merger to combine TSOA-generated and existing static specs: + +```typescript +export function getMergedSpec(): OpenApiDescription { + const tsoaSpec = // Read TSOA-generated spec + const staticSpec = // Read existing static spec + + // Merge specs, preferring TSOA for migrated endpoints + return mergedSpec +} +``` + +### Step 5: Authentication Integration + +Ensure TSOA authentication works with SignalK's security: + +```typescript +// src/api/tsoa-auth.ts +export async function expressAuthentication( + request: express.Request, + securityName: string, + scopes?: string[] +): Promise { + if (securityName === 'signalK') { + // Check SignalK authentication + if (!request.skIsAuthenticated) { + throw new Error('Authentication required') + } + return request.skPrincipal + } +} +``` + +### Step 6: Testing + +Create comprehensive tests for both endpoints: + +```typescript +describe('TSOA Migration', () => { + it('should return identical responses from both endpoints', async () => { + const [originalData, tsoaData] = await Promise.all([ + fetch('/api/course'), + fetch('/api/course-tsoa') + ]) + expect(tsoaData).to.deep.equal(originalData) + }) +}) +``` + +## Migration Checklist + +- [ ] Create TSOA controller with TypeScript interfaces +- [ ] Add TSOA configuration to `tsoa.json` +- [ ] Update build scripts to include TSOA generation +- [ ] Implement authentication middleware +- [ ] Create parallel endpoint for testing +- [ ] Write comprehensive tests +- [ ] Update OpenAPI documentation +- [ ] Test in staging environment +- [ ] Monitor performance metrics +- [ ] Gradually migrate clients +- [ ] Remove old endpoint (after full migration) + +## Common Patterns + +### Accessing SignalK APIs + +```typescript +const app = request.app as any +const api = app.yourApi // Access singleton API instances +``` + +### Error Handling + +```typescript +if (!api) { + this.setStatus(500) + throw new Error('API not initialized') +} +``` + +### Response Status Codes + +```typescript +@SuccessResponse(200, 'Success') +@Response(404, 'Not found') +@Response(500, 'Internal error') +``` + +## Best Practices + +1. **Keep Controllers Thin**: Controllers should only handle HTTP concerns +2. **Reuse Existing Logic**: Call existing API methods rather than duplicating +3. **Document Everything**: Use JSDoc comments for better OpenAPI output +4. **Test Thoroughly**: Ensure identical behavior between old and new endpoints +5. **Monitor Migration**: Track usage of both endpoints during migration + +## Troubleshooting + +### Issue: Routes Not Found + +- Ensure TSOA routes are registered after API initialization +- Check that controllers are included in `controllerPathGlobs` + +### Issue: Authentication Failing + +- Verify `expressAuthentication` properly checks SignalK auth +- Ensure security middleware is applied before TSOA routes + +### Issue: Type Validation Errors + +- Check that TypeScript interfaces match actual data +- Use optional properties (?) for nullable fields + +## Example: Course API Migration + +The Course API migration demonstrates the complete pattern: + +1. **Controller**: `src/api/course/CourseController.ts` +2. **Types**: Defined `CourseInfo` interface +3. **Auth**: Integrated with SignalK security +4. **Tests**: Comprehensive parallel endpoint testing +5. **OpenAPI**: Merged TSOA and static specifications + +## Next Steps + +After successful migration of one endpoint: + +1. Identify next endpoint for migration +2. Follow the same pattern +3. Share learnings with team +4. Update this guide with new insights + +## Resources + +- [TSOA Documentation](https://tsoa-community.github.io/docs/) +- [SignalK API Specification](http://signalk.org/specification/) +- [OpenAPI Specification](https://swagger.io/specification/) diff --git a/package.json b/package.json index 5384ed436..0a3c3fc70 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "An implementation of a [Signal K](http://signalk.org) server for boats.", "main": "index.js", "scripts": { - "build": "tsc --build", + "build:tsoa": "tsoa spec-and-routes", + "build": "npm run build:tsoa && tsc --build", "build:all": "npm run build:workspaces && npm run build && npm run build:docs", "build:docs": "typedoc", "build:workspaces": "npm run build --workspaces --if-present", "watch": "tsc --build -w", + "watch:tsoa": "nodemon --watch src/api --ext ts --exec tsoa spec-and-routes", "prettier": "prettier --write .", "lint": "eslint --fix", "format": "npm run prettier && npm run lint", @@ -111,11 +113,13 @@ "ms": "^2.1.2", "ncp": "^2.0.0", "primus": "^7.0.0", + "reflect-metadata": "^0.2.2", "selfsigned": "^2.4.1", "semver": "^7.5.4", "split": "^1.0.0", "stat-mode": "^1.0.0", "swagger-ui-express": "^4.5.0", + "tsoa": "^6.6.0", "unzipper": "^0.10.10", "uuid": "^8.1.0", "ws": "^7.0.0" @@ -147,7 +151,9 @@ "@types/express": "^4.17.1", "@types/lodash": "^4.14.139", "@types/mocha": "^10.0.1", + "@types/multer": "^2.0.0", "@types/ncp": "^2.0.5", + "@types/node": "^24.2.0", "@types/semver": "^7.1.0", "@types/split": "^1.0.0", "@types/swagger-ui-express": "^4.1.3", diff --git a/src/api/course/CourseController.ts b/src/api/course/CourseController.ts new file mode 100644 index 000000000..5e4d8f3e8 --- /dev/null +++ b/src/api/course/CourseController.ts @@ -0,0 +1,104 @@ +import { + Controller, + Get, + Route, + Tags, + Response, + SuccessResponse, + Request, + Security +} from 'tsoa' +import express from 'express' + +/** + * Course information interface + */ +export interface CourseInfo { + /** ISO 8601 timestamp of course start */ + startTime: string | null + /** Estimated time of arrival in ISO 8601 format */ + targetArrivalTime: string | null + /** Arrival circle radius in meters */ + arrivalCircle: number + /** Active route information */ + activeRoute: { + href: string + pointIndex: number + pointTotal: number + reverse: boolean + name?: string + } | null + /** Next waypoint or location */ + nextPoint: { + type: 'Location' | 'RoutePoint' | 'VesselPosition' + position: { + latitude: number + longitude: number + } + href?: string + } | null + /** Previous waypoint or location */ + previousPoint: { + type: 'Location' | 'RoutePoint' | 'VesselPosition' + position: { + latitude: number + longitude: number + } + href?: string + } | null +} + +/** + * Course navigation controller for SignalK server + * Provides endpoints for managing vessel navigation course information + */ +@Route('vessels/self/navigation') +@Tags('Navigation') +export class CourseController extends Controller { + /** + * Get current course information + * @summary Returns the vessel's current course/navigation information + * @returns {CourseInfo} Current course information including destination, route, and arrival details + * @example response: + * { + * "startTime": "2024-01-01T00:00:00Z", + * "targetArrivalTime": "2024-01-01T12:00:00Z", + * "arrivalCircle": 50, + * "activeRoute": { + * "href": "/resources/routes/123", + * "pointIndex": 2, + * "pointTotal": 5, + * "reverse": false, + * "name": "Test Route" + * }, + * "nextPoint": { + * "type": "Location", + * "position": { "latitude": -35.5, "longitude": 138.7 } + * }, + * "previousPoint": { + * "type": "VesselPosition", + * "position": { "latitude": -35.45, "longitude": 138 } + * } + * } + */ + @Get('course-tsoa') // Parallel endpoint for gradual migration + @Security('signalK', ['read']) + @SuccessResponse(200, 'Course information retrieved successfully') + @Response(404, 'No active course') + public async getCourseInfo( + @Request() request: express.Request + ): Promise { + // Access the CourseApi singleton from the app object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = request.app as any + const courseApi = app.courseApi + + if (!courseApi) { + this.setStatus(500) + throw new Error('CourseApi not initialized') + } + + // Get the course info from the singleton and return it directly + return courseApi.getCourseInfo() + } +} diff --git a/src/api/course/index.ts b/src/api/course/index.ts index f473941e5..6ab94cca3 100644 --- a/src/api/course/index.ts +++ b/src/api/course/index.ts @@ -94,6 +94,14 @@ export class CourseApi { this.parseSettings() } + /** + * Get current course information + * Public method for TSOA controller access + */ + public getCourseInfo(): CourseInfo { + return this.courseInfo + } + async start() { return new Promise(async (resolve) => { this.initCourseRoutes() diff --git a/src/api/course/openApi.ts b/src/api/course/openApi.ts index 0380379be..bb50f49f1 100644 --- a/src/api/course/openApi.ts +++ b/src/api/course/openApi.ts @@ -1,8 +1,21 @@ import { OpenApiDescription } from '../swagger' -import courseApiDoc from './openApi.json' +import { getMergedCourseSpec } from './openApiMerger' +// Lazy load the merged spec to ensure TSOA spec file is available +let cachedMergedSpec: OpenApiDescription | null = null + +/** + * Course API OpenAPI specification with TSOA-generated and static endpoints merged + * TSOA handles the GET endpoint with runtime validation + * Static spec handles PUT/POST/DELETE operations + */ export const courseApiRecord = { name: 'course', path: '/signalk/v2/api/vessels/self/navigation', - apiDoc: courseApiDoc as unknown as OpenApiDescription + get apiDoc(): OpenApiDescription { + if (!cachedMergedSpec) { + cachedMergedSpec = getMergedCourseSpec() + } + return cachedMergedSpec + } } diff --git a/src/api/course/openApiMerger.ts b/src/api/course/openApiMerger.ts new file mode 100644 index 000000000..2ee63ef18 --- /dev/null +++ b/src/api/course/openApiMerger.ts @@ -0,0 +1,85 @@ +import { OpenApiDescription } from '../swagger' +import courseStaticDoc from './openApi.json' +import * as fs from 'fs' +import * as path from 'path' + +/** + * Merges TSOA-generated OpenAPI spec with static JSON spec + * + * This function combines: + * - TSOA-generated GET endpoint with runtime validation and TypeScript types + * - Static OpenAPI spec for POST/PUT/DELETE operations + * + * The merger ensures backward compatibility while enabling gradual migration + * to TSOA for type-safe, validated endpoints. + * + * @returns {OpenApiDescription} Merged OpenAPI specification + */ +export function getMergedCourseSpec(): OpenApiDescription { + // Read TSOA-generated spec if it exists + const tsoaSpecPath = path.join(__dirname, '../generated/course-tsoa.json') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let tsoaSpec: any = { paths: {}, components: {} } + + if (fs.existsSync(tsoaSpecPath)) { + try { + tsoaSpec = JSON.parse(fs.readFileSync(tsoaSpecPath, 'utf8')) + } catch (error) { + console.warn('Failed to read TSOA spec, using static only:', error) + } + } + + // Deep clone static spec as base + const mergedSpec = JSON.parse(JSON.stringify(courseStaticDoc)) + + // Replace GET endpoint with TSOA version if available + const tsoaGetPath = + tsoaSpec.paths?.['/vessels/self/navigation/course-tsoa']?.get + if (tsoaGetPath) { + // Ensure path exists in merged spec + if (!mergedSpec.paths['/course']) { + mergedSpec.paths['/course'] = {} + } + + // Copy TSOA GET endpoint but adjust the path + const adjustedGet = JSON.parse(JSON.stringify(tsoaGetPath)) + + // Update operation ID to avoid conflicts + if (adjustedGet.operationId) { + adjustedGet.operationId = 'getCourseInfo' + } + + // Replace GET with TSOA version, keep other methods from static + mergedSpec.paths['/course'].get = adjustedGet + } + + // Include parallel endpoint for gradual migration + if (tsoaGetPath && tsoaSpec.paths?.['/vessels/self/navigation/course-tsoa']) { + mergedSpec.paths['/course-tsoa'] = { + get: tsoaGetPath + } + } + + // Merge component schemas + if (tsoaSpec.components?.schemas) { + mergedSpec.components = mergedSpec.components || {} + mergedSpec.components.schemas = mergedSpec.components.schemas || {} + + // Add TSOA schemas (CourseInfo, etc.) + Object.entries(tsoaSpec.components.schemas).forEach(([key, schema]) => { + // Prefer TSOA schemas for types that are migrated + if (key === 'CourseInfo' || key.startsWith('CourseInfo_')) { + mergedSpec.components.schemas[key] = schema + } + }) + } + + // Ensure proper API metadata + mergedSpec.info = mergedSpec.info || {} + mergedSpec.info.title = 'Course API' + mergedSpec.info.version = '2.0.0' + mergedSpec.info.description = + 'Course and navigation management (Hybrid: TSOA + Static)' + + return mergedSpec as unknown as OpenApiDescription +} diff --git a/src/api/index.ts b/src/api/index.ts index 206298579..d793b27a3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,6 +6,7 @@ import { FeaturesApi } from './discovery' import { ResourcesApi } from './resources' import { AutopilotApi } from './autopilot' import { SignalKApiId, WithFeatures } from '@signalk/server-api' +import { registerTsoaRoutes } from './swagger' export interface ApiResponse { state: 'FAILED' | 'COMPLETED' | 'PENDING' @@ -74,6 +75,9 @@ export const startApis = ( courseApi.start(), featuresApi.start(), autopilotApi.start() - ]) + ]).then(() => { + // Register TSOA routes after APIs are initialized + registerTsoaRoutes(app) + }) return apiList } diff --git a/src/api/swagger.ts b/src/api/swagger.ts index 194461a3b..05d69b613 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -11,6 +11,7 @@ import { discoveryApiRecord } from './discovery/openApi' import { appsApiRecord } from './apps/openApi' import { PluginId, PluginManager } from '../interfaces/plugins' import { Brand } from '@signalk/server-api' +import * as fs from 'fs' export type OpenApiDescription = Brand @@ -100,3 +101,35 @@ export function mountSwaggerUi(app: IRouter & PluginManager, path: string) { ) app.get(`${SERVERROUTESPREFIX}/openapi/:api`, apiDefinitionHandler) } + +export function registerTsoaRoutes(app: IRouter & any) { + // Register TSOA routes if they exist + const routesPath = __dirname + '/generated/routes.js' + if (fs.existsSync(routesPath)) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { RegisterRoutes } = require('./generated/routes') + + // First, apply SignalK's authentication middleware to TSOA paths + // This ensures req.skIsAuthenticated and req.skPrincipal are set + if (app.securityStrategy && app.securityStrategy.addWriteMiddleware) { + // For now, apply read-only auth to all TSOA routes + // In production, we'd differentiate based on HTTP method and path + // The addWriteMiddleware pattern shows us we need to apply http_authorize first + // Since we can't access http_authorize directly, we'll register routes directly on app + // and rely on the global http_authorize middleware that's already registered + } + + // Register TSOA routes directly on the app + // This ensures they inherit the global authentication middleware + RegisterRoutes(app) + + console.log('TSOA routes registered successfully') + return true + } catch (error) { + console.warn('Failed to register TSOA routes:', error) + return false + } + } + return false +} diff --git a/src/api/tsoa-auth.ts b/src/api/tsoa-auth.ts new file mode 100644 index 000000000..e675672e3 --- /dev/null +++ b/src/api/tsoa-auth.ts @@ -0,0 +1,79 @@ +import express from 'express' + +/** + * TSOA authentication middleware for SignalK security integration + * This function is called by TSOA for routes decorated with @Security + */ +export async function expressAuthentication( + request: express.Request, + securityName: string, + scopes?: string[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + if (securityName === 'signalK') { + return new Promise((resolve, reject) => { + // Check if security is enabled + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = request.app as any + const securityStrategy = app.securityStrategy + + // If security is disabled (dummysecurity), allow all requests + if (securityStrategy && !securityStrategy.supportsLogin) { + // dummysecurity doesn't have supportsLogin method + resolve({ identifier: 'dummy', permissions: 'admin' }) + return + } + + // Check if user is authenticated (set by SignalK's http_authorize middleware) + if (!request.skIsAuthenticated) { + // Check if readonly access is allowed + if ( + securityStrategy && + securityStrategy.allowReadOnly && + securityStrategy.allowReadOnly() + ) { + // For read-only operations, allow unauthenticated access + if (!scopes || scopes.length === 0 || scopes.includes('read')) { + resolve({ identifier: 'AUTO', permissions: 'readonly' }) + return + } + } + reject(new Error('Authentication required')) + return + } + + // Check permissions based on scopes + const userPermissions = request.skPrincipal?.permissions + + // Check for write permissions + if (scopes?.includes('write')) { + if ( + !userPermissions || + !['admin', 'readwrite'].includes(userPermissions) + ) { + reject(new Error('Write permission required')) + return + } + } + + // Check for admin permissions + if (scopes?.includes('admin')) { + if (userPermissions !== 'admin') { + reject(new Error('Admin permission required')) + return + } + } + + // Check for read permissions (default - all authenticated users) + if (scopes?.includes('read') || !scopes || scopes.length === 0) { + // All authenticated users have at least read access + resolve(request.skPrincipal) + return + } + + resolve(request.skPrincipal) + }) + } + + return Promise.reject(new Error('Unknown security method')) +} diff --git a/src/types/signalk-express.d.ts b/src/types/signalk-express.d.ts new file mode 100644 index 000000000..a80491159 --- /dev/null +++ b/src/types/signalk-express.d.ts @@ -0,0 +1,29 @@ +/** + * Type declarations for SignalK Express extensions + */ + +declare global { + namespace Express { + interface Request { + /** + * SignalK authentication status + */ + skIsAuthenticated?: boolean + + /** + * SignalK principal/user information + */ + skPrincipal?: { + identifier: string + permissions: 'admin' | 'readwrite' | 'readonly' + } + + /** + * User logged in status + */ + userLoggedIn?: boolean + } + } +} + +export {} diff --git a/test/api/course/openapi-endpoint.test.ts b/test/api/course/openapi-endpoint.test.ts new file mode 100644 index 000000000..9919a17d6 --- /dev/null +++ b/test/api/course/openapi-endpoint.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { expect } from 'chai' +import { startServer } from '../../ts-servertestutilities' + +describe('Course API OpenAPI endpoint', () => { + let stop: () => Promise + let host: string + + beforeEach(async () => { + const result = await startServer() + stop = result.stop + host = result.host + }) + + afterEach(async () => { + await stop() + }) + + it('should serve merged OpenAPI spec with both TSOA and static endpoints', async () => { + const response = await fetch(`${host}/skServer/openapi/course`) + expect(response.status).to.equal(200) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec: any = await response.json() + + // Check that spec has merged content + expect(spec).to.exist + expect(spec.info).to.exist + expect(spec.info.title).to.equal('Course API') + expect(spec.info.description).to.include('Hybrid: TSOA + Static') + + // Check paths exist + expect(spec.paths).to.exist + + // Original GET endpoint should be replaced with TSOA version + expect(spec.paths['/course']).to.exist + expect(spec.paths['/course'].get).to.exist + expect(spec.paths['/course'].get.operationId).to.equal('getCourseInfo') + + // Other methods should still exist from static spec + expect(spec.paths['/course/destination']).to.exist + expect(spec.paths['/course/destination'].put).to.exist + expect(spec.paths['/course/arrivalCircle']).to.exist + expect(spec.paths['/course/activeRoute']).to.exist + + // During migration, test endpoint should also exist + expect(spec.paths['/course-tsoa']).to.exist + expect(spec.paths['/course-tsoa'].get).to.exist + + // Check that CourseInfo schema is present + if (spec.components && spec.components.schemas) { + expect(spec.components.schemas.CourseInfo).to.exist + } + }) + + it('should have proper servers configuration', async () => { + const response = await fetch(`${host}/skServer/openapi/course`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec: any = await response.json() + + expect(spec.servers).to.exist + expect(spec.servers).to.be.an('array') + expect(spec.servers[0]).to.exist + expect(spec.servers[0].url).to.equal( + '/signalk/v2/api/vessels/self/navigation' + ) + }) +}) diff --git a/test/api/course/tsoa-migration.test.ts b/test/api/course/tsoa-migration.test.ts new file mode 100644 index 000000000..620cf33b1 --- /dev/null +++ b/test/api/course/tsoa-migration.test.ts @@ -0,0 +1,244 @@ +import { expect } from 'chai' +import { startServer } from '../../ts-servertestutilities' + +/* eslint-disable @typescript-eslint/no-unused-expressions */ +describe('TSOA Migration - Course API GET endpoint', () => { + let stop: () => Promise + let selfPut: (path: string, body: object) => Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let selfGetJson: (path: string) => Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let sendDelta: (path: string, value: any) => Promise + let host: string + + beforeEach(async () => { + const result = await startServer() + stop = result.stop + selfPut = result.selfPut + selfGetJson = result.selfGetJson + sendDelta = result.sendDelta + host = result.host + }) + + afterEach(async () => { + await stop() + }) + + describe('Parallel endpoint testing', () => { + it('should have both original and TSOA endpoints available', async () => { + // Test original endpoint + const originalData = await selfGetJson('navigation/course') + + // Test TSOA endpoint - try with fetch directly + const tsoaResponse = await fetch( + `${host}/signalk/v2/api/vessels/self/navigation/course-tsoa` + ) + expect(tsoaResponse.status).to.equal(200) + const tsoaData = await tsoaResponse.json() + + // Both should return course info structure + expect(originalData).to.have.keys( + 'startTime', + 'targetArrivalTime', + 'arrivalCircle', + 'activeRoute', + 'nextPoint', + 'previousPoint' + ) + + expect(tsoaData).to.have.keys( + 'startTime', + 'targetArrivalTime', + 'arrivalCircle', + 'activeRoute', + 'nextPoint', + 'previousPoint' + ) + }) + + it('should return identical responses from both endpoints', async () => { + // Set up course data + await sendDelta('navigation.position', { + latitude: -35.45, + longitude: 138.0 + }) + + await selfPut('navigation/course/destination', { + position: { latitude: -35.5, longitude: 138.7 } + }) + + // Wait a moment for delta processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Get responses from both endpoints + const [originalData, tsoaData] = await Promise.all([ + selfGetJson('navigation/course'), + selfGetJson('navigation/course-tsoa') + ]) + + // Response bodies should be identical + expect(tsoaData).to.deep.equal(originalData) + }) + + it('should handle empty course state identically', async () => { + const [originalData, tsoaData] = await Promise.all([ + selfGetJson('navigation/course'), + selfGetJson('navigation/course-tsoa') + ]) + + // Both should return empty course info + const emptyCourse = { + startTime: null, + targetArrivalTime: null, + arrivalCircle: 0, + activeRoute: null, + nextPoint: null, + previousPoint: null + } + + expect(originalData).to.deep.equal(emptyCourse) + expect(tsoaData).to.deep.equal(emptyCourse) + }) + + it('should handle course with destination identically', async () => { + // Set position + await sendDelta('navigation.position', { + latitude: -35.45, + longitude: 138.0 + }) + + // Set destination + await selfPut('navigation/course/destination', { + position: { latitude: -35.5, longitude: 138.7 } + }) + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + const [originalData, tsoaData] = await Promise.all([ + selfGetJson('navigation/course'), + selfGetJson('navigation/course-tsoa') + ]) + + // Both should have destination set + expect(originalData.nextPoint).to.exist + expect(originalData.nextPoint.type).to.equal('Location') + expect(originalData.nextPoint.position).to.deep.equal({ + latitude: -35.5, + longitude: 138.7 + }) + + expect(tsoaData.nextPoint).to.exist + expect(tsoaData.nextPoint.type).to.equal('Location') + expect(tsoaData.nextPoint.position).to.deep.equal({ + latitude: -35.5, + longitude: 138.7 + }) + + // Previous point should be vessel position + expect(originalData.previousPoint).to.exist + expect(originalData.previousPoint.type).to.equal('VesselPosition') + expect(originalData.previousPoint.position).to.deep.equal({ + latitude: -35.45, + longitude: 138 + }) + + expect(tsoaData.previousPoint).to.deep.equal(originalData.previousPoint) + + // Start time should be set and match + expect(originalData.startTime).to.exist + expect(tsoaData.startTime).to.equal(originalData.startTime) + }) + }) + + describe('Performance comparison', () => { + it('should have similar response times', async () => { + const iterations = 10 + const originalTimes: number[] = [] + const tsoaTimes: number[] = [] + + for (let i = 0; i < iterations; i++) { + // Test original endpoint + const originalStart = Date.now() + await selfGetJson('navigation/course') + originalTimes.push(Date.now() - originalStart) + + // Test TSOA endpoint + const tsoaStart = Date.now() + await selfGetJson('navigation/course-tsoa') + tsoaTimes.push(Date.now() - tsoaStart) + } + + const avgOriginal = originalTimes.reduce((a, b) => a + b, 0) / iterations + const avgTsoa = tsoaTimes.reduce((a, b) => a + b, 0) / iterations + + console.log( + `Average response times - Original: ${avgOriginal}ms, TSOA: ${avgTsoa}ms` + ) + + // TSOA should not be significantly slower (within 20ms) + expect(Math.abs(avgTsoa - avgOriginal)).to.be.lessThan(20) + }) + }) + + describe('Concurrent request handling', () => { + it('should handle concurrent requests correctly', async () => { + // Set up some course data + await sendDelta('navigation.position', { + latitude: -35.45, + longitude: 138.0 + }) + await selfPut('navigation/course/destination', { + position: { latitude: -35.5, longitude: 138.7 } + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Make concurrent requests to both endpoints + const requests = [] + for (let i = 0; i < 5; i++) { + requests.push( + selfGetJson('navigation/course'), + selfGetJson('navigation/course-tsoa') + ) + } + + const responses = await Promise.all(requests) + + // All responses should have the same data + const firstData = JSON.stringify(responses[0]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + responses.forEach((response: any) => { + expect(JSON.stringify(response)).to.equal(firstData) + }) + }) + }) + + describe('OpenAPI spec validation', () => { + it.skip('should have TSOA endpoint documented in OpenAPI spec', async () => { + // Try the full path for OpenAPI spec + const response = await fetch(`${host}/signalk/v2/openapi/course`) + expect(response.status).to.equal(200) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec: any = await response.json() + + // Check that the spec has been merged + expect(spec).to.exist + expect(spec.paths).to.exist + + // During migration, both endpoints should be documented + if (spec.paths && spec.paths['/course-tsoa']) { + expect(spec.paths['/course-tsoa'].get).to.exist + expect(spec.paths['/course-tsoa'].get.operationId).to.exist + expect(spec.paths['/course-tsoa'].get.responses).to.exist + expect(spec.paths['/course-tsoa'].get.responses['200']).to.exist + } + + // Original endpoints should still be documented + expect(spec.paths['/course']).to.exist + expect(spec.paths['/course/destination']).to.exist + expect(spec.paths['/course/arrivalCircle']).to.exist + }) + }) +}) diff --git a/tsconfig.base.json b/tsconfig.base.json index b30c808f7..0b8c73c50 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,7 +8,9 @@ "resolveJsonModule": true, "composite": true, "declaration": true, - "declarationMap": true + "declarationMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "ts-node": { "files": true diff --git a/tsoa.json b/tsoa.json new file mode 100644 index 000000000..3feb9d853 --- /dev/null +++ b/tsoa.json @@ -0,0 +1,34 @@ +{ + "entryFile": "src/index.ts", + "noImplicitAdditionalProperties": "throw-on-extras", + "controllerPathGlobs": ["src/api/course/CourseController.ts"], + "spec": { + "outputDirectory": "src/api/generated", + "specVersion": 3, + "basePath": "/signalk/v2/api", + "specFileBaseName": "course-tsoa", + "name": "Course API (TSOA)", + "version": "2.0.0", + "license": "Apache-2.0", + "tags": [ + { + "name": "Navigation", + "description": "Course and navigation operations" + } + ], + "securityDefinitions": { + "signalK": { + "type": "apiKey", + "name": "JAUTHENTICATION", + "in": "cookie", + "description": "SignalK JWT authentication token" + } + } + }, + "routes": { + "routesDir": "src/api/generated", + "basePath": "/signalk/v2/api", + "middleware": "express", + "authenticationModule": "./src/api/tsoa-auth.ts" + } +}