Skip to content
Open
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: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
"@signalk/server-api": "2.10.x",
"@signalk/signalk-schema": "^1.7.1",
"@signalk/streams": "5.1.x",
"api-schema-builder": "^2.0.11",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"baconjs": "^1.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.14.1",
Expand Down
1 change: 0 additions & 1 deletion packages/server-admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"font-awesome": "^4.7.0",
"html-webpack-plugin": "^5.0.0-alpha.6",
"jsonlint-mod": "^1.7.6",
"lodash.get": "^4.4.2",
"lodash.remove": "^4.7.0",
"lodash.set": "^4.3.2",
"lodash.uniq": "^4.5.0",
Expand Down
1 change: 0 additions & 1 deletion src/@types/api-schema-builder.d.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/api/course/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { isValidCoordinate } from 'geolib'
import { Responses } from '../'
import { Store } from '../../serverstate/store'

import { buildSchemaSync } from 'api-schema-builder'
import { buildSchemaSync } from '../validation/openapi-validator'
import courseOpenApi from './openApi.json'
import { ResourcesApi } from '../resources'
import { ConfigApp, writeSettingsFile } from '../../config/config'
Expand Down
2 changes: 1 addition & 1 deletion src/api/resources/validate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SignalKResourceType } from '@signalk/server-api'
import { buildSchemaSync } from 'api-schema-builder'
import { buildSchemaSync } from '../validation/openapi-validator'
import { RESOURCES_API_PATH } from '.'
import { createDebug } from '../../debug'
import resourcesOpenApi from './openApi.json'
Expand Down
208 changes: 208 additions & 0 deletions src/api/validation/openapi-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* OpenAPI schema validator using AJV
*
* Replaces api-schema-builder to eliminate lodash.get deprecation warning.
* Provides the same interface: schema[path][method].body.validate(value)
*/

import Ajv, { ValidateFunction, ErrorObject } from 'ajv'
import addFormats from 'ajv-formats'

interface EndpointValidator {
body: {
validate: (value: unknown) => boolean
errors: ErrorObject[] | null | undefined
}
parameters: {
validate: (params: { query?: unknown }) => boolean
errors: ErrorObject[] | null | undefined
}
}

interface OpenApiSchema {
[path: string]: {
[method: string]: EndpointValidator
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type OpenApiSpec = any

/**
* Build validators from an OpenAPI spec
* Compatible with api-schema-builder's buildSchemaSync interface
*/
export function buildSchemaSync(openApiSpec: OpenApiSpec): OpenApiSchema {
const ajv = new Ajv({
allErrors: true,
strict: false,
validateFormats: true
})
addFormats(ajv)

// Register the entire OpenAPI spec so AJV can resolve $refs internally
// We use a custom URI scheme to avoid conflicts
const specId = 'openapi://spec'

// Add all component schemas with proper $id for $ref resolution
// Transform refs inside component schemas too
if (openApiSpec.components?.schemas) {
for (const [name, schema] of Object.entries(
openApiSpec.components.schemas
)) {
const transformedSchema = transformRefs(schema, specId)
const schemaWithId = {
...(transformedSchema as object),
$id: `${specId}/components/schemas/${name}`
}
try {
ajv.addSchema(schemaWithId)
} catch {
// Schema might already be added, continue
}
}
}

const result: OpenApiSchema = {}

if (!openApiSpec.paths) {
return result
}

// Get server base URL if present
const serverUrl = openApiSpec.servers?.[0]?.url || ''

for (const [path, pathItem] of Object.entries(openApiSpec.paths)) {
// Normalize OpenAPI path parameters {id} to Express format :id
// and prepend the server URL
const normalizedPath = (serverUrl + path).replace(/\{(\w+)\}/g, ':$1')
result[normalizedPath] = {}

for (const [method, operation] of Object.entries(
pathItem as Record<string, unknown>
)) {
if (method === 'parameters') continue // Skip path-level parameters

const op = operation as Record<string, unknown>

// Create body validator
let bodyValidator: ValidateFunction | null = null
const requestBody = op.requestBody as Record<string, unknown> | undefined
const content = requestBody?.content as
| Record<string, unknown>
| undefined
const jsonContent = content?.['application/json'] as
| Record<string, unknown>
| undefined
const bodySchema = jsonContent?.schema

if (bodySchema) {
try {
// Transform $ref from OpenAPI format to our registered schemas
const transformedSchema = transformRefs(bodySchema, specId)
bodyValidator = ajv.compile(transformedSchema as object)
} catch (e) {
console.error(
`Failed to compile body schema for ${method} ${path}:`,
e
)
bodyValidator = null
}
}

// Create parameters validator for query params
let paramsValidator: ValidateFunction | null = null
const parameters = op.parameters as
| Array<Record<string, unknown>>
| undefined
const queryParams = parameters?.filter((p) => p.in === 'query') || []

if (queryParams.length > 0) {
const querySchema: Record<string, unknown> = {
type: 'object',
properties: {} as Record<string, unknown>,
required: [] as string[]
}

for (const param of queryParams) {
if (param.schema) {
;(querySchema.properties as Record<string, unknown>)[
param.name as string
] = transformRefs(param.schema, specId)
}
if (param.required) {
;(querySchema.required as string[]).push(param.name as string)
}
}

try {
paramsValidator = ajv.compile({
type: 'object',
properties: {
query: querySchema
}
})
} catch {
paramsValidator = null
}
}

// Create endpoint validator with api-schema-builder compatible interface
const endpoint: EndpointValidator = {
body: {
validate: (value: unknown): boolean => {
if (!bodyValidator) return true
const valid = bodyValidator(value)
endpoint.body.errors = bodyValidator.errors
return valid as boolean
},
errors: null
},
parameters: {
validate: (params: { query?: unknown }): boolean => {
if (!paramsValidator) return true
const valid = paramsValidator(params)
endpoint.parameters.errors = paramsValidator.errors
return valid as boolean
},
errors: null
}
}

result[normalizedPath][method] = endpoint
}
}

return result
}

/**
* Transform OpenAPI $refs to match our registered schema IDs
*/
function transformRefs(schema: unknown, specId: string): unknown {
if (schema === null || typeof schema !== 'object') {
return schema
}

if (Array.isArray(schema)) {
return schema.map((item) => transformRefs(item, specId))
}

const obj = schema as Record<string, unknown>
const result: Record<string, unknown> = {}

for (const [key, value] of Object.entries(obj)) {
if (key === '$ref' && typeof value === 'string') {
// Transform #/components/schemas/Name to openapi://spec/components/schemas/Name
if (value.startsWith('#/components/schemas/')) {
result[key] = `${specId}${value.substring(1)}`
} else {
result[key] = value
}
} else {
result[key] = transformRefs(value, specId)
}
}

return result
}
Loading