Projektstart

This commit is contained in:
2026-01-22 15:49:12 +01:00
parent 7212eb6f7a
commit 57e5f652f8
10637 changed files with 2598792 additions and 64 deletions

11
backend/node_modules/@fastify/swagger/lib/constants.js generated vendored Normal file
View File

@@ -0,0 +1,11 @@
'use strict'
const xConsume = 'x-consume'
const xResponseDescription = 'x-response-description'
const xExamples = 'x-examples'
module.exports = {
xConsume,
xResponseDescription,
xExamples
}

View File

@@ -0,0 +1,39 @@
'use strict'
const { addHook } = require('../util/add-hook')
const { resolveSwaggerFunction } = require('../util/resolve-swagger-function')
module.exports = function (fastify, opts, done) {
opts = Object.assign({}, {
exposeRoute: false,
hiddenTag: 'X-HIDDEN',
hideUntagged: false,
stripBasePath: true,
openapi: null,
swagger: {},
transform: null,
transformObject: null,
decorator: 'swagger',
refResolver: {
buildLocalReference (json, _baseUri, _fragment, i) {
if (!json.title && json.$id) {
json.title = json.$id
}
return `def-${i}`
}
},
convertConstToEnum: true
}, opts)
const { routes, Ref } = addHook(fastify, opts)
const cache = {
object: null,
string: null
}
const swagger = resolveSwaggerFunction(opts, cache, routes, Ref)
fastify.decorate(opts.decorator, swagger)
done()
}

View File

@@ -0,0 +1,84 @@
'use strict'
const path = require('node:path')
const fs = require('node:fs')
const yaml = require('yaml')
module.exports = function (fastify, opts, done) {
if (!opts.specification) return done(new Error('specification is missing in the module options'))
if (typeof opts.specification !== 'object') return done(new Error('specification is not an object'))
let swaggerObject = {}
if (!opts.specification.path && !opts.specification.document) {
return done(new Error('both specification.path and specification.document are missing, should be path to the file or swagger document spec'))
} else if (opts.specification.path) {
if (typeof opts.specification.path !== 'string') return done(new Error('specification.path is not a string'))
if (!fs.existsSync(path.resolve(opts.specification.path))) return done(new Error(`${opts.specification.path} does not exist`))
const extName = path.extname(opts.specification.path).toLowerCase()
if (['.yaml', '.json'].indexOf(extName) === -1) return done(new Error("specification.path extension name is not supported, should be one from ['.yaml', '.json']"))
if (opts.specification.postProcessor && typeof opts.specification.postProcessor !== 'function') return done(new Error('specification.postProcessor should be a function'))
if (opts.specification.baseDir && typeof opts.specification.baseDir !== 'string') return done(new Error('specification.baseDir should be string'))
if (!opts.specification.baseDir) {
opts.specification.baseDir = path.resolve(path.dirname(opts.specification.path))
} else {
while (opts.specification.baseDir.endsWith('/')) {
opts.specification.baseDir = opts.specification.baseDir.slice(0, -1)
}
}
// read
const source = fs.readFileSync(
path.resolve(opts.specification.path),
'utf8'
)
switch (extName) {
case '.yaml':
swaggerObject = yaml.parse(source)
break
case '.json':
swaggerObject = JSON.parse(source)
break
}
// apply postProcessor, if one was passed as an argument
if (opts.specification.postProcessor) {
swaggerObject = opts.specification.postProcessor(swaggerObject)
}
} else {
if (typeof opts.specification.document !== 'object') return done(new Error('specification.document is not an object'))
swaggerObject = opts.specification.document
}
fastify.decorate(opts.decorator || 'swagger', swagger)
const cache = {
swaggerObject: null,
swaggerString: null
}
function swagger (opts) {
if (opts?.yaml) {
if (cache.swaggerString) return cache.swaggerString
} else {
if (cache.swaggerObject) return cache.swaggerObject
}
if (opts?.yaml) {
const swaggerString = yaml.stringify(swaggerObject, { strict: false })
cache.swaggerString = swaggerString
return swaggerString
}
cache.swaggerObject = swaggerObject
return swaggerObject
}
done()
}

View File

@@ -0,0 +1,85 @@
'use strict'
const yaml = require('yaml')
const { shouldRouteHide } = require('../../util/should-route-hide')
const { prepareDefaultOptions, prepareOpenapiObject, prepareOpenapiMethod, prepareOpenapiSchemas, normalizeUrl, resolveServerUrls } = require('./utils')
module.exports = function (opts, cache, routes, Ref) {
let ref
const defOpts = prepareDefaultOptions(opts)
return function (opts) {
if (opts?.yaml) {
if (cache.string) return cache.string
} else {
if (cache.object) return cache.object
}
// Base Openapi info
const openapiObject = prepareOpenapiObject(defOpts)
ref = Ref()
openapiObject.components.schemas = prepareOpenapiSchemas(defOpts, {
...openapiObject.components.schemas,
...(ref.definitions().definitions)
}, ref)
const serverUrls = resolveServerUrls(defOpts.servers)
for (const route of routes) {
const transformResult = route.config?.swaggerTransform !== undefined
? route.config.swaggerTransform
? route.config.swaggerTransform({ schema: route.schema, url: route.url, route, openapiObject })
: {}
: defOpts.transform
? defOpts.transform({ schema: route.schema, url: route.url, route, openapiObject })
: {}
const schema = transformResult.schema || route.schema
const shouldRouteHideOpts = {
hiddenTag: defOpts.hiddenTag,
hideUntagged: defOpts.hideUntagged
}
if (shouldRouteHide(schema, shouldRouteHideOpts)) continue
let url = transformResult.url || route.url
url = normalizeUrl(url, serverUrls, defOpts.stripBasePath)
const openapiRoute = Object.assign({}, openapiObject.paths[url])
const openapiMethod = prepareOpenapiMethod(defOpts, schema, ref, openapiObject, url)
if (route.links) {
for (const statusCode of Object.keys(route.links)) {
if (!openapiMethod.responses[statusCode]) {
throw new Error(`missing status code ${statusCode} in route ${route.path}`)
}
openapiMethod.responses[statusCode].links = route.links[statusCode]
}
}
// route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH']
const methods = typeof route.method === 'string' ? [route.method] : route.method
for (const method of methods) {
openapiRoute[method.toLowerCase()] = openapiMethod
}
openapiObject.paths[url] = openapiRoute
}
const transformObjectResult = defOpts.transformObject
? defOpts.transformObject({ openapiObject })
: openapiObject
if (opts?.yaml) {
cache.string = yaml.stringify(transformObjectResult, { strict: false })
return cache.string
}
cache.object = transformObjectResult
return cache.object
}
}

View File

@@ -0,0 +1,602 @@
'use strict'
const { readPackageJson } = require('../../util/read-package-json')
const { formatParamUrl } = require('../../util/format-param-url')
const { resolveLocalRef } = require('../../util/resolve-local-ref')
const { resolveSchemaReference } = require('../../util/resolve-schema-reference')
const { xResponseDescription, xConsume, xExamples } = require('../../constants')
const { rawRequired } = require('../../symbols')
const { generateParamsSchema } = require('../../util/generate-params-schema')
const { hasParams } = require('../../util/match-params')
function prepareDefaultOptions (opts) {
const openapi = opts.openapi
const info = openapi.info || null
const servers = openapi.servers || null
const components = openapi.components || null
const security = openapi.security || null
const tags = openapi.tags || null
const externalDocs = openapi.externalDocs || null
const stripBasePath = opts.stripBasePath
const transform = opts.transform
const transformObject = opts.transformObject
const hiddenTag = opts.hiddenTag
const hideUntagged = opts.hideUntagged
const extensions = []
const convertConstToEnum = opts.convertConstToEnum
for (const [key, value] of Object.entries(opts.openapi)) {
if (key.startsWith('x-')) {
extensions.push([key, value])
}
}
return {
...openapi,
info,
servers,
components,
security,
tags,
externalDocs,
stripBasePath,
transform,
transformObject,
hiddenTag,
extensions,
hideUntagged,
convertConstToEnum
}
}
function prepareOpenapiObject (opts) {
const pkg = readPackageJson()
const openapiObject = {
openapi: '3.0.3',
info: {
version: pkg.version || '1.0.0',
title: pkg.name || ''
},
components: { schemas: {} },
paths: {}
}
if (opts.openapi) openapiObject.openapi = opts.openapi
if (opts.info) openapiObject.info = opts.info
if (opts.servers) openapiObject.servers = opts.servers
if (opts.components) openapiObject.components = Object.assign({}, opts.components, { schemas: Object.assign({}, opts.components.schemas) })
if (opts.paths) openapiObject.paths = opts.paths
if (opts.webhooks) openapiObject.webhooks = opts.webhooks
if (opts.security) openapiObject.security = opts.security
if (opts.tags) openapiObject.tags = opts.tags
if (opts.externalDocs) openapiObject.externalDocs = opts.externalDocs
for (const [key, value] of opts.extensions) {
// "x-" extension can not be typed
openapiObject[key] = value
}
return openapiObject
}
function normalizeUrl (url, serverUrls, stripBasePath) {
if (!stripBasePath) return formatParamUrl(url)
serverUrls.forEach(function (serverUrl) {
const basePath = serverUrl.startsWith('/') ? serverUrl : new URL(serverUrl).pathname
if (url.startsWith(basePath) && basePath !== '/') {
url = url.replace(basePath, '')
}
})
return formatParamUrl(url)
}
function resolveServerUrls (servers) {
const resolvedUrls = []
const findVariablesRegex = /\{([^{}]+)\}/gu // As for OpenAPI v3 spec url variables are named in brackets, e.g. {foo}
servers = Array.isArray(servers) ? servers : []
for (const server of servers) {
const originalUrl = server.url
const variables = server.variables
let url = originalUrl
const matches = url.matchAll(findVariablesRegex)
for (const [nameInBrackets, name] of matches) {
const value = variables?.[name]?.default
if (value === undefined) {
throw new Error(`Server URL ${originalUrl} could not be resolved. Make sure to provide a default value for each URL variable.`)
}
url = url.replace(nameInBrackets, value)
}
resolvedUrls.push(url)
}
return resolvedUrls
}
function convertExamplesArrayToObject (examples) {
return examples.reduce((examplesObject, example, index) => {
if (typeof example === 'object') {
examplesObject['example' + (index + 1)] = { value: example }
} else {
examplesObject[example] = { value: example }
}
return examplesObject
}, {})
}
// For supported keys read:
// https://swagger.io/docs/specification/describing-parameters/
function plainJsonObjectToOpenapi3 (opts, container, jsonSchema, externalSchemas, securityIgnores = []) {
const obj = convertJsonSchemaToOpenapi3(opts, resolveLocalRef(jsonSchema, externalSchemas))
let toOpenapiProp
switch (container) {
case 'cookie':
case 'header':
case 'query':
toOpenapiProp = function (propertyName, jsonSchemaElement) {
let result = {
in: container,
name: propertyName,
required: jsonSchemaElement.required
}
const media = schemaToMedia(jsonSchemaElement)
// complex serialization in query or cookie, eg. JSON
// https://swagger.io/docs/specification/describing-parameters/#schema-vs-content
if (jsonSchemaElement[xConsume]) {
media.schema.required = jsonSchemaElement[rawRequired]
result.content = {
[jsonSchemaElement[xConsume]]: media
}
delete result.content[jsonSchemaElement[xConsume]].schema[xConsume]
} else {
result = { ...media, ...result }
}
// description should be optional
if (jsonSchemaElement.description) result.description = jsonSchemaElement.description
// optionally add serialization format style
if (jsonSchema.style) result.style = jsonSchema.style
if (jsonSchema.explode != null) result.explode = jsonSchema.explode
if (jsonSchema.allowReserved === true && container === 'query') {
result.allowReserved = jsonSchema.allowReserved
}
return result
}
break
case 'path':
toOpenapiProp = function (propertyName, jsonSchemaElement) {
const media = schemaToMedia(jsonSchemaElement)
const result = {
...media,
in: container,
name: propertyName,
required: true
}
// description should be optional
if (jsonSchemaElement.description) result.description = jsonSchemaElement.description
return result
}
break
}
return Object.keys(obj)
.filter((propKey) => (!securityIgnores.includes(propKey)))
.map((propKey) => {
const jsonSchema = toOpenapiProp(propKey, obj[propKey])
if (jsonSchema.schema) {
// it is needed as required in schema is invalid prop - delete only if needed
if (jsonSchema.schema.required !== undefined) delete jsonSchema.schema.required
// it is needed as description in schema is invalid prop - delete only if needed
if (jsonSchema.schema.description !== undefined) delete jsonSchema.schema.description
}
return jsonSchema
})
}
const schemaTypeToNestedSchemas = {
object: (schema) => {
return [
...Object.values(schema.properties || {}),
...Object.values(schema.patternProperties || {}),
...Object.values(schema.additionalProperties || {})
]
},
array: (schema) => {
return [
...(schema.items ? [schema.items] : []),
...(schema.contains ? [schema.contains] : [])
]
}
}
function resolveSchemaExamples (schema) {
const example = schema[xExamples] ?? schema.examples?.[0]
if (typeof example !== 'undefined') {
schema.example = example
}
delete schema[xExamples]
delete schema.examples
}
function resolveSchemaExamplesRecursive (schema) {
resolveSchemaExamples(schema)
const getNestedSchemas = schemaTypeToNestedSchemas[schema.type]
const nestedSchemas = getNestedSchemas?.(schema) ?? []
for (const nestedSchema of nestedSchemas) {
resolveSchemaExamplesRecursive(nestedSchema)
}
}
function schemaToMedia (schema) {
const media = { schema }
if (schema.examples?.length === 1) {
media.example = schema.examples[0]
delete schema.examples
} else if (schema.examples?.length > 1) {
media.examples = convertExamplesArrayToObject(schema.examples)
// examples is invalid property of media object schema
delete schema.examples
}
if (schema[xExamples]) {
media.examples = schema[xExamples]
delete schema[xExamples]
}
return media
}
function schemaToMediaRecursive (schema) {
const media = schemaToMedia(schema)
resolveSchemaExamplesRecursive(schema)
return media
}
function resolveBodyParams (opts, body, schema, consumes, ref) {
const resolved = convertJsonSchemaToOpenapi3(opts, ref.resolve(schema))
if (resolved.content?.[Object.keys(resolved.content)[0]].schema) {
for (const contentType in schema.content) {
body.content[contentType] = schemaToMediaRecursive(resolved.content[contentType].schema)
}
} else {
if ((Array.isArray(consumes) && consumes.length === 0) || consumes === undefined) {
consumes = ['application/json']
}
const media = schemaToMediaRecursive(resolved)
consumes.forEach((consume) => {
body.content[consume] = media
})
if (resolved?.required?.length) {
body.required = true
}
if (resolved?.description) {
body.description = resolved.description
}
}
}
function resolveCommonParams (opts, container, parameters, schema, ref, sharedSchemas, securityIgnores) {
const schemasPath = '#/components/schemas/'
let resolved = convertJsonSchemaToOpenapi3(opts, ref.resolve(schema))
// if the resolved definition is in global schema
if (resolved.$ref?.startsWith(schemasPath)) {
const parts = resolved.$ref.split(schemasPath)
const pathParts = parts[1].split('/')
resolved = pathParts.reduce((resolved, pathPart) => resolved[pathPart], ref.definitions().definitions)
}
const arr = plainJsonObjectToOpenapi3(opts, container, resolved, { ...sharedSchemas, ...ref.definitions().definitions }, securityIgnores)
arr.forEach(swaggerSchema => parameters.push(swaggerSchema))
}
function findReferenceDescription (rawSchema, ref) {
const resolved = resolveSchemaReference(rawSchema, ref)
return resolved?.description
}
// https://swagger.io/docs/specification/describing-responses/
function resolveResponse (opts, fastifyResponseJson, produces, ref) {
// if the user does not provided an out schema
if (!fastifyResponseJson) {
return { 200: { description: 'Default Response' } }
}
const responsesContainer = {}
const statusCodes = Object.keys(fastifyResponseJson)
statusCodes.forEach(statusCode => {
const rawJsonSchema = fastifyResponseJson[statusCode]
const resolved = convertJsonSchemaToOpenapi3(opts, ref.resolve(rawJsonSchema))
/**
* 2xx require to be all upper-case
* converts statusCode to upper case only when it is not "default"
*/
if (statusCode !== 'default') {
statusCode = statusCode.toUpperCase()
}
const response = {
description: resolved[xResponseDescription] ||
rawJsonSchema.description ||
findReferenceDescription(rawJsonSchema, ref) ||
'Default Response'
}
// add headers when there are any.
if (rawJsonSchema.headers) {
response.headers = {}
Object.keys(rawJsonSchema.headers).forEach(function (key) {
const header = {
schema: { ...rawJsonSchema.headers[key] }
}
if (rawJsonSchema.headers[key].description) {
header.description = rawJsonSchema.headers[key].description
// remove invalid field
delete header.schema.description
}
response.headers[key] = header
})
// remove invalid field
delete resolved.headers
}
// add schema when type is not 'null'
if (rawJsonSchema.type !== 'null') {
if (resolved.content?.[Object.keys(resolved.content)[0]].schema) {
response.content = resolved.content
} else {
const content = {}
if ((Array.isArray(produces) && produces.length === 0) || produces === undefined) {
produces = ['application/json']
}
delete resolved[xResponseDescription]
const media = schemaToMediaRecursive(resolved)
for (const produce of produces) {
content[produce] = media
}
response.content = content
}
}
responsesContainer[statusCode] = response
})
return responsesContainer
}
function resolveCallbacks (opts, schema, ref) {
const callbacksContainer = {}
// Iterate over each callback event
for (const eventName in schema) {
if (!schema[eventName]) {
continue
}
// Create an empty object to house the future iterations
callbacksContainer[eventName] = {}
const eventSchema = schema[eventName]
// Iterate over each callbackUrl for the event
for (const callbackUrl in eventSchema) {
if (!callbackUrl || !eventSchema[callbackUrl]) {
continue
}
// Create an empty object to house the future iterations
callbacksContainer[eventName][callbackUrl] = {}
const callbackSchema = eventSchema[callbackUrl]
// Iterate over each httpMethod for the callbackUrl
for (const httpMethodName in callbackSchema) {
if (!httpMethodName || !callbackSchema[httpMethodName]) {
continue
}
const httpMethodSchema = callbackSchema[httpMethodName]
const httpMethodContainer = {}
if (httpMethodSchema.requestBody) {
httpMethodContainer.requestBody = convertJsonSchemaToOpenapi3(
opts,
ref.resolve(httpMethodSchema.requestBody)
)
}
// If a response is not provided, set a 2XX default response
httpMethodContainer.responses = httpMethodSchema.responses
? convertJsonSchemaToOpenapi3(opts, ref.resolve(httpMethodSchema.responses))
: { '2XX': { description: 'Default Response' } }
// Set the schema at the appropriate location in the response object
callbacksContainer[eventName][callbackUrl][httpMethodName] = httpMethodContainer
}
}
}
return callbacksContainer
}
function prepareOpenapiMethod (opts, schema, ref, openapiObject, url) {
const openapiMethod = {}
const parameters = []
// Parse out the security prop keys to ignore
const securityIgnores = [
...(openapiObject?.security || []),
...(schema?.security || [])
]
.reduce((acc, securitySchemeGroup) => {
Object.keys(securitySchemeGroup).forEach((securitySchemeLabel) => {
const scheme = openapiObject.components.securitySchemes[securitySchemeLabel]
const isBearer = scheme.type === 'http' && scheme.scheme === 'bearer'
const category = isBearer ? 'header' : scheme.in
const name = isBearer ? 'authorization' : scheme.name
if (!acc[category]) {
acc[category] = []
}
acc[category].push(name)
})
return acc
}, {})
// All the data the user can give us, is via the schema object
if (schema) {
if (schema.operationId) openapiMethod.operationId = schema.operationId
if (schema.summary) openapiMethod.summary = schema.summary
if (schema.tags) openapiMethod.tags = schema.tags
if (schema.description) openapiMethod.description = schema.description
if (schema.externalDocs) openapiMethod.externalDocs = schema.externalDocs
if (schema.querystring) resolveCommonParams(opts, 'query', parameters, schema.querystring, ref, openapiObject.definitions, securityIgnores.query)
if (schema.body) {
openapiMethod.requestBody = { content: {} }
resolveBodyParams(opts, openapiMethod.requestBody, schema.body, schema.consumes, ref)
}
if (schema.params) resolveCommonParams(opts, 'path', parameters, schema.params, ref, openapiObject.definitions)
if (schema.headers) resolveCommonParams(opts, 'header', parameters, schema.headers, ref, openapiObject.definitions, securityIgnores.header)
// TODO: need to documentation, we treat it same as the querystring
// fastify do not support cookies schema in first place
if (schema.cookies) resolveCommonParams(opts, 'cookie', parameters, schema.cookies, ref, openapiObject.definitions, securityIgnores.cookie)
if (parameters.length > 0) openapiMethod.parameters = parameters
if (schema.deprecated) openapiMethod.deprecated = schema.deprecated
if (schema.security) openapiMethod.security = schema.security
if (schema.servers) openapiMethod.servers = schema.servers
if (schema.callbacks) openapiMethod.callbacks = resolveCallbacks(opts, schema.callbacks, ref)
for (const key of Object.keys(schema)) {
if (key.startsWith('x-')) {
openapiMethod[key] = schema[key]
}
}
}
// If there is no schema or schema.params, we need to generate them
if ((!schema || !schema.params) && hasParams(url)) {
const schemaGenerated = generateParamsSchema(url)
resolveCommonParams(opts, 'path', parameters, schemaGenerated.params, ref, openapiObject.definitions)
openapiMethod.parameters = parameters
}
openapiMethod.responses = resolveResponse(opts, schema ? schema.response : null, schema ? schema.produces : null, ref)
return openapiMethod
}
function convertJsonSchemaToOpenapi3 (opts, jsonSchema) {
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
return jsonSchema
}
if (Array.isArray(jsonSchema)) {
return jsonSchema.map((s) => convertJsonSchemaToOpenapi3(opts, s))
}
const openapiSchema = { ...jsonSchema }
if (Object.hasOwn(openapiSchema, '$ref') && Object.keys(openapiSchema).length !== 1) {
for (const key of Object.keys(openapiSchema).filter(k => k !== '$ref')) {
delete openapiSchema[key]
continue
}
}
for (const key of Object.keys(openapiSchema)) {
const value = openapiSchema[key]
if (key === '$id' || key === '$schema' || key === 'definitions') {
// TODO: this breaks references to the definition properties
delete openapiSchema[key]
continue
}
if (key === '$ref') {
openapiSchema.$ref = value.replace('definitions', 'components/schemas')
continue
}
if (opts.convertConstToEnum && key === 'const') {
// OAS 3.1 supports `const` but it is not supported by `swagger-ui`
// https://swagger.io/docs/specification/data-models/keywords/
// TODO: check if enum property already exists
// TODO: this breaks references to the const property
openapiSchema.enum = [openapiSchema.const]
delete openapiSchema.const
continue
}
if (key === 'patternProperties') {
// TODO: check if additionalProperties property already exists
// TODO: this breaks references to the additionalProperties properties
// TODO: patternProperties actually allowed in the openapi schema, but should
// always start with "x-" prefix
const propertyJsonSchema = Object.values(openapiSchema.patternProperties)[0]
const propertyOpenapiSchema = convertJsonSchemaToOpenapi3(opts, propertyJsonSchema)
openapiSchema.additionalProperties = propertyOpenapiSchema
delete openapiSchema.patternProperties
continue
}
if (key === 'properties') {
openapiSchema[key] = {}
for (const propertyName of Object.keys(value)) {
const propertyJsonSchema = value[propertyName]
const propertyOpenapiSchema = convertJsonSchemaToOpenapi3(opts, propertyJsonSchema)
openapiSchema[key][propertyName] = propertyOpenapiSchema
}
continue
}
openapiSchema[key] = convertJsonSchemaToOpenapi3(opts, value)
}
return openapiSchema
}
function prepareOpenapiSchemas (opts, jsonSchemas, ref) {
const openapiSchemas = {}
for (const schemaName of Object.keys(jsonSchemas)) {
const jsonSchema = { ...jsonSchemas[schemaName] }
const resolvedJsonSchema = ref.resolve(jsonSchema, { externalSchemas: [jsonSchemas] })
const openapiSchema = convertJsonSchemaToOpenapi3(opts, resolvedJsonSchema)
resolveSchemaExamplesRecursive(openapiSchema)
openapiSchemas[schemaName] = openapiSchema
}
return openapiSchemas
}
module.exports = {
prepareDefaultOptions,
prepareOpenapiObject,
prepareOpenapiMethod,
prepareOpenapiSchemas,
resolveServerUrls,
normalizeUrl
}

View File

@@ -0,0 +1,77 @@
'use strict'
const yaml = require('yaml')
const { shouldRouteHide } = require('../../util/should-route-hide')
const { prepareDefaultOptions, prepareSwaggerObject, prepareSwaggerMethod, normalizeUrl, prepareSwaggerDefinitions } = require('./utils')
module.exports = function (opts, cache, routes, Ref) {
let ref
const defOpts = prepareDefaultOptions(opts)
return function (opts) {
if (opts?.yaml) {
if (cache.string) return cache.string
} else {
if (cache.object) return cache.object
}
const swaggerObject = prepareSwaggerObject(defOpts)
ref = Ref()
swaggerObject.definitions = prepareSwaggerDefinitions({
...swaggerObject.definitions,
...(ref.definitions().definitions)
}, ref)
for (const route of routes) {
const transformResult = route.config?.swaggerTransform !== undefined
? route.config.swaggerTransform
? route.config.swaggerTransform({ schema: route.schema, url: route.url, route, swaggerObject })
: {}
: defOpts.transform
? defOpts.transform({ schema: route.schema, url: route.url, route, swaggerObject })
: {}
const schema = transformResult.schema || route.schema
const shouldRouteHideOpts = {
hiddenTag: defOpts.hiddenTag,
hideUntagged: defOpts.hideUntagged
}
if (shouldRouteHide(schema, shouldRouteHideOpts)) continue
let url = transformResult.url || route.url
url = normalizeUrl(url, defOpts.basePath, defOpts.stripBasePath)
const swaggerRoute = Object.assign({}, swaggerObject.paths[url])
const swaggerMethod = prepareSwaggerMethod(schema, ref, swaggerObject, url)
if (route.links) {
throw new Error('Swagger (Open API v2) does not support Links. Upgrade to OpenAPI v3 (see @fastify/swagger readme)')
}
// route.method should be either a String, like 'POST', or an Array of Strings, like ['POST','PUT','PATCH']
const methods = typeof route.method === 'string' ? [route.method] : route.method
for (const method of methods) {
swaggerRoute[method.toLowerCase()] = swaggerMethod
}
swaggerObject.paths[url] = swaggerRoute
}
const transformObjectResult = defOpts.transformObject
? defOpts.transformObject({ swaggerObject })
: swaggerObject
if (opts?.yaml) {
cache.string = yaml.stringify(transformObjectResult, { strict: false })
return cache.string
}
cache.object = transformObjectResult
return cache.object
}
}

View File

@@ -0,0 +1,354 @@
'use strict'
const { readPackageJson } = require('../../util/read-package-json')
const { formatParamUrl } = require('../../util/format-param-url')
const { resolveLocalRef } = require('../../util/resolve-local-ref')
const { resolveSchemaReference } = require('../../util/resolve-schema-reference')
const { xResponseDescription, xConsume } = require('../../constants')
const { generateParamsSchema } = require('../../util/generate-params-schema')
const { hasParams } = require('../../util/match-params')
function prepareDefaultOptions (opts) {
const swagger = opts.swagger
const info = swagger.info || null
const host = swagger.host || null
const schemes = swagger.schemes || null
const consumes = swagger.consumes || null
const produces = swagger.produces || null
const definitions = swagger.definitions || null
const paths = swagger.paths || null
const basePath = swagger.basePath || null
const securityDefinitions = swagger.securityDefinitions || null
const security = swagger.security || null
const tags = swagger.tags || null
const externalDocs = swagger.externalDocs || null
const stripBasePath = opts.stripBasePath
const transform = opts.transform
const transformObject = opts.transformObject
const hiddenTag = opts.hiddenTag
const hideUntagged = opts.hideUntagged
const extensions = []
for (const [key, value] of Object.entries(opts.swagger)) {
if (key.startsWith('x-')) {
extensions.push([key, value])
}
}
return {
info,
host,
schemes,
consumes,
produces,
definitions,
paths,
basePath,
securityDefinitions,
security,
tags,
externalDocs,
stripBasePath,
transform,
transformObject,
hiddenTag,
extensions,
hideUntagged
}
}
function prepareSwaggerObject (opts) {
const pkg = readPackageJson()
const swaggerObject = {
swagger: '2.0',
info: {
version: pkg.version || '1.0.0',
title: pkg.name || ''
},
definitions: {},
paths: {}
}
if (opts.info) swaggerObject.info = opts.info
if (opts.host) swaggerObject.host = opts.host
if (opts.schemes) swaggerObject.schemes = opts.schemes
if (opts.basePath) swaggerObject.basePath = opts.basePath
if (opts.consumes) swaggerObject.consumes = opts.consumes
if (opts.produces) swaggerObject.produces = opts.produces
if (opts.definitions) swaggerObject.definitions = opts.definitions
if (opts.paths) swaggerObject.paths = opts.paths
if (opts.securityDefinitions) swaggerObject.securityDefinitions = opts.securityDefinitions
if (opts.security) swaggerObject.security = opts.security
if (opts.tags) swaggerObject.tags = opts.tags
if (opts.externalDocs) swaggerObject.externalDocs = opts.externalDocs
for (const [key, value] of opts.extensions) {
// "x-" extension can not be typed
swaggerObject[key] = value
}
return swaggerObject
}
function normalizeUrl (url, basePath, stripBasePath) {
let path
if (stripBasePath && url.startsWith(basePath)) {
path = url.replace(basePath, '')
} else {
path = url
}
if (!path.startsWith('/')) {
path = '/' + String(path)
}
return formatParamUrl(path)
}
// For supported keys read:
// https://swagger.io/docs/specification/2-0/describing-parameters/
function plainJsonObjectToSwagger2 (container, jsonSchema, externalSchemas, securityIgnores = []) {
const obj = resolveLocalRef(jsonSchema, externalSchemas)
let toSwaggerProp
switch (container) {
case 'header':
case 'query':
toSwaggerProp = function (propertyName, jsonSchemaElement) {
// complex serialization is not supported by swagger
if (jsonSchemaElement[xConsume]) {
throw new Error('Complex serialization is not supported by Swagger. ' +
'Remove "' + xConsume + '" for "' + propertyName + '" querystring/header schema or ' +
'change specification to OpenAPI')
}
jsonSchemaElement.in = container
jsonSchemaElement.name = propertyName
return jsonSchemaElement
}
break
case 'formData':
toSwaggerProp = function (propertyName, jsonSchemaElement) {
delete jsonSchemaElement.$id
jsonSchemaElement.in = container
jsonSchemaElement.name = propertyName
// https://json-schema.org/understanding-json-schema/reference/non_json_data.html#contentencoding
if (jsonSchemaElement.contentEncoding === 'binary') {
delete jsonSchemaElement.contentEncoding // Must be removed
jsonSchemaElement.type = 'file'
}
return jsonSchemaElement
}
break
case 'path':
toSwaggerProp = function (propertyName, jsonSchemaElement) {
jsonSchemaElement.in = container
jsonSchemaElement.name = propertyName
jsonSchemaElement.required = true
return jsonSchemaElement
}
break
}
return Object.keys(obj)
.filter((propKey) => (!securityIgnores.includes(propKey)))
.map((propKey) => {
return toSwaggerProp(propKey, obj[propKey])
})
}
/*
* Map unsupported JSON schema definitions to Swagger definitions
*/
function replaceUnsupported (jsonSchema) {
if (typeof jsonSchema === 'object' && jsonSchema !== null) {
// Handle patternProperties, that is not part of OpenAPI definitions
if (jsonSchema.patternProperties) {
jsonSchema.additionalProperties = { type: 'string' }
delete jsonSchema.patternProperties
} else if (jsonSchema.const !== undefined) {
// Handle const, that is not part of OpenAPI definitions
jsonSchema.enum = [jsonSchema.const]
delete jsonSchema.const
}
Object.keys(jsonSchema).forEach(function (key) {
jsonSchema[key] = replaceUnsupported(jsonSchema[key])
})
}
return jsonSchema
}
function isConsumesFormOnly (schema) {
const consumes = schema.consumes
return (
consumes &&
consumes.length === 1 &&
(consumes[0] === 'application/x-www-form-urlencoded' ||
consumes[0] === 'multipart/form-data')
)
}
function resolveBodyParams (parameters, schema, ref) {
const resolved = ref.resolve(schema)
replaceUnsupported(resolved)
parameters.push({
name: 'body',
in: 'body',
description: resolved?.description,
schema: resolved
})
}
function resolveCommonParams (container, parameters, schema, ref, sharedSchemas, securityIgnores) {
const resolved = ref.resolve(schema)
const arr = plainJsonObjectToSwagger2(container, resolved, sharedSchemas, securityIgnores)
arr.forEach(swaggerSchema => parameters.push(swaggerSchema))
}
function findReferenceDescription (rawSchema, ref) {
const resolved = resolveSchemaReference(rawSchema, ref)
return resolved?.description
}
// https://swagger.io/docs/specification/2-0/describing-responses/
function resolveResponse (fastifyResponseJson, ref) {
// if the user does not provided an out schema
if (!fastifyResponseJson) {
return { 200: { description: 'Default Response' } }
}
const responsesContainer = {}
const statusCodes = Object.keys(fastifyResponseJson)
statusCodes.forEach(statusCode => {
const rawJsonSchema = fastifyResponseJson[statusCode]
const resolved = ref.resolve(rawJsonSchema)
delete resolved.$schema
// 2xx is not supported by swagger
const deXXStatusCode = statusCode.toUpperCase().replace('XX', '00')
// conflict when we have both 2xx and 200
if (statusCode.toUpperCase().includes('XX') && statusCodes.includes(deXXStatusCode)) {
return
}
// converts statusCode to upper case only when it is not "default"
if (statusCode !== 'default') {
statusCode = deXXStatusCode
}
const response = {
description: rawJsonSchema[xResponseDescription] ||
rawJsonSchema.description ||
findReferenceDescription(rawJsonSchema, ref) ||
'Default Response'
}
// add headers when there are any.
if (rawJsonSchema.headers) {
response.headers = rawJsonSchema.headers
// remove invalid field
delete resolved.headers
}
// add schema when type is not 'null'
if (rawJsonSchema.type !== 'null') {
const schema = { ...resolved }
replaceUnsupported(schema)
delete schema[xResponseDescription]
response.schema = schema
}
responsesContainer[statusCode] = response
})
return responsesContainer
}
function prepareSwaggerMethod (schema, ref, swaggerObject, url) {
const swaggerMethod = {}
const parameters = []
// Parse out the security prop keys to ignore
const securityIgnores = [
...(swaggerObject?.security || []),
...(schema?.security || [])
]
.reduce((acc, securitySchemeGroup) => {
Object.keys(securitySchemeGroup).forEach((securitySchemeLabel) => {
const { name, in: category } = swaggerObject.securityDefinitions[securitySchemeLabel]
if (!acc[category]) {
acc[category] = []
}
acc[category].push(name)
})
return acc
}, {})
// All the data the user can give us, is via the schema object
if (schema) {
if (schema.operationId) swaggerMethod.operationId = schema.operationId
if (schema.summary) swaggerMethod.summary = schema.summary
if (schema.description) swaggerMethod.description = schema.description
if (schema.externalDocs) swaggerMethod.externalDocs = schema.externalDocs
if (schema.tags) swaggerMethod.tags = schema.tags
if (schema.produces) swaggerMethod.produces = schema.produces
if (schema.consumes) swaggerMethod.consumes = schema.consumes
if (schema.querystring) resolveCommonParams('query', parameters, schema.querystring, ref, swaggerObject.definitions, securityIgnores.query)
if (schema.body) {
const isConsumesAllFormOnly = isConsumesFormOnly(schema) || isConsumesFormOnly(swaggerObject)
isConsumesAllFormOnly
? resolveCommonParams('formData', parameters, schema.body, ref, swaggerObject.definitions)
: resolveBodyParams(parameters, schema.body, ref)
}
if (schema.params) resolveCommonParams('path', parameters, schema.params, ref, swaggerObject.definitions)
if (schema.headers) resolveCommonParams('header', parameters, schema.headers, ref, swaggerObject.definitions, securityIgnores.header)
if (parameters.length > 0) swaggerMethod.parameters = parameters
if (schema.deprecated) swaggerMethod.deprecated = schema.deprecated
if (schema.security) swaggerMethod.security = schema.security
for (const key of Object.keys(schema)) {
if (key.startsWith('x-')) {
swaggerMethod[key] = schema[key]
}
}
}
// If there is no schema or schema.params, we need to generate them
if ((!schema || !schema.params) && hasParams(url)) {
const schemaGenerated = generateParamsSchema(url)
resolveCommonParams('path', parameters, schemaGenerated.params, ref, swaggerObject.definitions)
swaggerMethod.parameters = parameters
}
swaggerMethod.responses = resolveResponse(schema ? schema.response : null, ref)
return swaggerMethod
}
function prepareSwaggerDefinitions (definitions, ref) {
return Object.entries(definitions)
.reduce((res, [name, definition]) => {
const _ = { ...definition }
const resolved = ref.resolve(_, { externalSchemas: [definitions] })
// Swagger doesn't accept $id on /definitions schemas.
// The $ids are needed by Ref() to check the URI so we need
// to remove them at the end of the process
delete resolved.$id
delete resolved.definitions
res[name] = resolved
return res
}, {})
}
module.exports = {
prepareDefaultOptions,
prepareSwaggerObject,
prepareSwaggerMethod,
normalizeUrl,
prepareSwaggerDefinitions
}

7
backend/node_modules/@fastify/swagger/lib/symbols.js generated vendored Normal file
View File

@@ -0,0 +1,7 @@
'use strict'
const rawRequired = Symbol('@fastify/swagger.rawRequired')
module.exports = {
rawRequired
}

View File

@@ -0,0 +1,82 @@
'use strict'
const Ref = require('json-schema-resolver')
const cloner = require('rfdc')({ proto: true, circles: false })
function addHook (fastify, pluginOptions) {
const routes = []
const sharedSchemasMap = new Map()
let hookRun = false
fastify.addHook('onRoute', (routeOptions) => {
const routeConfig = routeOptions.config || {}
const swaggerConfig = routeConfig.swagger || {}
if (routeOptions.method === 'HEAD' && pluginOptions.exposeHeadRoutes !== true && swaggerConfig.exposeHeadRoute !== true) {
return
}
if (
routeOptions.method === 'HEAD' &&
routeOptions.schema !== undefined &&
routeOptions.schema.operationId !== undefined
) {
routes.push(
// If two routes with operationId are added to the swagger
// object, it is no longer valid.
// therefore we suffix the operationId with `-head`.
Object.assign({}, routeOptions, {
schema: Object.assign({}, routeOptions.schema, {
operationId: `${routeOptions.schema.operationId}-head`
})
})
)
return
}
routes.push(routeOptions)
})
fastify.addHook('onRegister', async (instance) => {
// we need to wait the ready event to get all the .getSchemas()
// otherwise it will be empty
// TODO: better handle for schemaId
// when schemaId is the same in difference instance
// the latter will lost
instance.addHook('onReady', (done) => {
const allSchemas = instance.getSchemas()
for (const schemaId of Object.keys(allSchemas)) {
sharedSchemasMap.set(schemaId, allSchemas[schemaId])
}
done()
})
})
fastify.addHook('onReady', (done) => {
hookRun = true
const allSchemas = fastify.getSchemas()
for (const schemaId of Object.keys(allSchemas)) {
// it is the top-level, we do not expect to have duplicate id
sharedSchemasMap.set(schemaId, allSchemas[schemaId])
}
done()
})
return {
routes,
Ref () {
if (hookRun === false) {
throw new Error('.swagger() must be called after .ready()')
}
const externalSchemas = cloner(Array.from(sharedSchemasMap.values()))
return Ref(Object.assign(
{ applicationUri: 'todo.com' },
pluginOptions.refResolver,
{ clone: true, externalSchemas })
)
}
}
}
module.exports = {
addHook
}

View File

@@ -0,0 +1,86 @@
'use strict'
// The swagger standard does not accept the url param with ':'
// so '/user/:id' is not valid.
// This function converts the url in a swagger compliant url string
// => '/user/{id}'
// custom verbs at the end of a url are okay => /user::watch but should be rendered as /user:watch in swagger
const COLON = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
function formatParamUrl (str) {
let i, char
let state = 'skip'
let path = ''
let param = ''
let level = 0
// count for regex if no param exist
let regexp = 0
for (i = 0; i < str.length; i++) {
char = str[i]
switch (state) {
case 'colon': {
// we only accept a-zA-Z0-9_ in param
if (COLON.indexOf(char) !== -1) {
param += char
} else if (char === '(') {
state = 'regexp'
level++
} else {
// end
state = 'skip'
path += '{' + param + '}'
path += char
param = ''
}
break
}
case 'regexp': {
if (char === '(') {
level++
} else if (char === ')') {
level--
}
// we end if the level reach zero
if (level === 0) {
state = 'skip'
if (param === '') {
regexp++
param = 'regexp' + String(regexp)
}
path += '{' + param + '}'
param = ''
}
break
}
default: {
// we check if we need to change state
if (char === ':' && str[i + 1] === ':') {
// double colon -> single colon
path += char
// skip one more
i++
} else if (char === ':') {
// single colon -> state colon
state = 'colon'
} else if (char === '(') {
state = 'regexp'
level++
} else if (char === '*') {
// * -> {*}
// should be exist once only
path += '{*}'
} else {
path += char
}
}
}
}
// clean up
if (state === 'colon' && param !== '') {
path += '{' + param + '}'
}
return path
}
module.exports = {
formatParamUrl
}

View File

@@ -0,0 +1,35 @@
'use strict'
const { matchParams } = require('./match-params')
const namePattern = /\{([^{}]+)\}/u
function paramName (param) {
return param.replace(namePattern, (_, captured) => captured)
}
// Generates default parameters schema from the given URL. (ex: /example/{userId})
function generateParamsSchema (url) {
const params = matchParams(url)
const schema = {
params: {
type: 'object',
properties: {}
}
}
schema.params.properties = params.reduce((acc, param) => {
const name = paramName(param)
acc[name] = {
type: 'string'
}
return acc
}, {})
return schema
}
module.exports = {
generateParamsSchema,
paramName
}

View File

@@ -0,0 +1,18 @@
'use strict'
const paramPattern = /\{[^{}]+\}/gu
function hasParams (url) {
if (!url) return false
return paramPattern.test(url)
}
function matchParams (url) {
if (!url) return []
return url.match(paramPattern) || []
}
module.exports = {
hasParams,
matchParams
}

View File

@@ -0,0 +1,16 @@
'use strict'
const fs = require('node:fs')
const path = require('node:path')
function readPackageJson () {
try {
return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json')))
} catch {
return {}
}
}
module.exports = {
readPackageJson
}

View File

@@ -0,0 +1,42 @@
'use strict'
const { rawRequired } = require('../symbols')
const { xConsume } = require('../constants')
function resolveLocalRef (jsonSchema, externalSchemas) {
if (jsonSchema.type !== undefined && jsonSchema.properties !== undefined) {
// for the shorthand querystring/params/headers declaration
const propertiesMap = Object.keys(jsonSchema.properties).reduce((acc, headers) => {
const rewriteProps = {}
rewriteProps.required = (Array.isArray(jsonSchema.required) && jsonSchema.required.indexOf(headers) >= 0) || false
// save raw required for next restore in the content/<media-type>
if (jsonSchema.properties[headers][xConsume]) {
rewriteProps[rawRequired] = jsonSchema.properties[headers].required
}
const newProps = Object.assign({}, jsonSchema.properties[headers], rewriteProps)
return Object.assign({}, acc, { [headers]: newProps })
}, {})
return propertiesMap
}
// for oneOf, anyOf, allOf support in querystring/params/headers
if (jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf) {
const schemas = jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf
return schemas.reduce((acc, schema) => Object.assign(acc, resolveLocalRef(schema, externalSchemas)), {})
}
// $ref is in the format: #/definitions/<resolved definition>/<optional fragment>
if (jsonSchema.$ref) {
const localRef = jsonSchema.$ref.split('/', 3)[2]
if (externalSchemas[localRef]) return resolveLocalRef(externalSchemas[localRef], externalSchemas)
// $ref is in the format: #/components/schemas/<resolved definition>
return resolveLocalRef(externalSchemas[jsonSchema.$ref.split('/', 4)[3]], externalSchemas)
}
return jsonSchema
}
module.exports = {
resolveLocalRef
}

View File

@@ -0,0 +1,18 @@
'use strict'
function resolveSchemaReference (rawSchema, ref) {
const resolvedReference = ref.resolve(rawSchema, { externalSchemas: [ref.definitions().definitions] })
// Ref has format `#/definitions/id`
const schemaId = resolvedReference?.$ref?.split('/', 3)[2]
if (schemaId === undefined) {
return undefined
}
return resolvedReference.definitions?.[schemaId]
}
module.exports = {
resolveSchemaReference
}

View File

@@ -0,0 +1,13 @@
'use strict'
function resolveSwaggerFunction (opts, cache, routes, Ref) {
if (opts.openapi === undefined || opts.openapi === null) {
return require('../spec/swagger')(opts, cache, routes, Ref)
} else {
return require('../spec/openapi')(opts, cache, routes, Ref)
}
}
module.exports = {
resolveSwaggerFunction
}

View File

@@ -0,0 +1,25 @@
'use strict'
function shouldRouteHide (schema, opts) {
const { hiddenTag, hideUntagged } = opts
if (schema?.hide) {
return true
}
const tags = schema?.tags || []
if (tags.length === 0 && hideUntagged) {
return true
}
if (tags.includes(hiddenTag)) {
return true
}
return false
}
module.exports = {
shouldRouteHide
}