Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
207 changes: 207 additions & 0 deletions docs/develop/rest-api/tsoa_migration_guide.md
Original file line number Diff line number Diff line change
@@ -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<CourseInfo> {
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<any> {
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/)
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions src/api/course/CourseController.ts
Original file line number Diff line number Diff line change
@@ -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<CourseInfo> {
// 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()
}
}
8 changes: 8 additions & 0 deletions src/api/course/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(async (resolve) => {
this.initCourseRoutes()
Expand Down
17 changes: 15 additions & 2 deletions src/api/course/openApi.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading