diff --git a/.gitignore b/.gitignore index a645da247b..5c8b82a5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,7 @@ typings/ badge-examples.json supported-features.json service-definitions.yml +frontend/categories/*.yaml # Local runtime configuration. /config/local*.yml diff --git a/core/base-service/base.js b/core/base-service/base.js index 4befd03cc2..f60470fa3b 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -140,6 +140,15 @@ class BaseService { */ static examples = [] + /** + * Optional: an OpenAPI Paths Object describing this service's + * route or routes in OpenAPI format. + * + * @see https://swagger.io/specification/#paths-object + * @abstract + */ + static openApi = undefined + static get _cacheLength() { const cacheLengths = { build: 30, @@ -183,7 +192,7 @@ class BaseService { } static getDefinition() { - const { category, name, isDeprecated } = this + const { category, name, isDeprecated, openApi } = this const { base, format, pattern } = this.route const queryParams = getQueryParamNames(this.route) @@ -200,7 +209,7 @@ class BaseService { route = undefined } - const result = { category, name, isDeprecated, route, examples } + const result = { category, name, isDeprecated, route, examples, openApi } assertValidServiceDefinition(result, `getDefinition() for ${this.name}`) diff --git a/core/base-service/examples.js b/core/base-service/examples.js index 6aa9ff03ed..4f7f2342df 100644 --- a/core/base-service/examples.js +++ b/core/base-service/examples.js @@ -129,6 +129,7 @@ function transformExample(inExample, index, ServiceClass) { ServiceClass ) + const category = categories.find(c => c.id === ServiceClass.category) return { title, example: { @@ -146,9 +147,7 @@ function transformExample(inExample, index, ServiceClass) { style: style === 'flat' ? undefined : style, namedLogo, }, - keywords: keywords.concat( - categories.find(c => c.id === ServiceClass.category).keywords - ), + keywords: category ? keywords.concat(category.keywords) : keywords, documentation: documentation ? { __html: documentation } : undefined, } } diff --git a/core/base-service/openapi.js b/core/base-service/openapi.js new file mode 100644 index 0000000000..1cb463a76c --- /dev/null +++ b/core/base-service/openapi.js @@ -0,0 +1,335 @@ +const baseUrl = process.env.BASE_URL || 'https://img.shields.io' +const globalParamRefs = [ + { $ref: '#/components/parameters/style' }, + { $ref: '#/components/parameters/logo' }, + { $ref: '#/components/parameters/logoColor' }, + { $ref: '#/components/parameters/label' }, + { $ref: '#/components/parameters/labelColor' }, + { $ref: '#/components/parameters/color' }, + { $ref: '#/components/parameters/cacheSeconds' }, + { $ref: '#/components/parameters/link' }, +] + +function getCodeSamples(altText) { + return [ + { + lang: 'URL', + label: 'URL', + source: '$url', + }, + { + lang: 'Markdown', + label: 'Markdown', + source: `![${altText}]($url)`, + }, + { + lang: 'reStructuredText', + label: 'rSt', + source: `.. image:: $url\n: alt: ${altText}`, + }, + { + lang: 'AsciiDoc', + label: 'AsciiDoc', + source: `image:$url[${altText}]`, + }, + { + lang: 'HTML', + label: 'HTML', + source: `${altText}`, + }, + ] +} + +function pattern2openapi(pattern) { + return pattern + .replace(/:([A-Za-z0-9_\-.]+)(?=[/]?)/g, (matches, grp1) => `{${grp1}}`) + .replace(/\([^)]*\)/g, '') + .replace(/\+$/, '') +} + +function getEnum(pattern, paramName) { + const re = new RegExp(`${paramName}\\(([A-Za-z0-9_\\-|]+)\\)`) + const match = pattern.match(re) + if (match === null) { + return undefined + } + if (!match[1].includes('|')) { + return undefined + } + return match[1].split('|') +} + +function param2openapi(pattern, paramName, exampleValue, paramType) { + const outParam = {} + outParam.name = paramName + // We don't have description if we are building the OpenAPI spec from examples[] + outParam.in = paramType + if (paramType === 'path') { + outParam.required = true + } else { + /* Occasionally we do have required query params, but we can't + detect this if we are building the OpenAPI spec from examples[] + so just assume all query params are optional */ + outParam.required = false + } + + if (exampleValue === null && paramType === 'query') { + outParam.schema = { type: 'boolean' } + outParam.allowEmptyValue = true + } else { + outParam.schema = { type: 'string' } + } + + if (paramType === 'path') { + outParam.schema.enum = getEnum(pattern, paramName) + } + + outParam.example = exampleValue + return outParam +} + +function getVariants(pattern) { + /* + given a URL pattern (which may include '/one/or/:more?/:optional/:parameters*') + return an array of all possible permutations: + [ + '/one/or/:more/:optional/:parameters', + '/one/or/:optional/:parameters', + '/one/or/:more/:optional', + '/one/or/:optional', + ] + */ + const patterns = [pattern.split('/')] + while (patterns.flat().find(p => p.endsWith('?') || p.endsWith('*'))) { + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i] + for (let j = 0; j < pattern.length; j++) { + const path = pattern[j] + if (path.endsWith('?') || path.endsWith('*')) { + pattern[j] = path.slice(0, -1) + patterns.push(patterns[i].filter(p => p !== pattern[j])) + } + } + } + } + for (let i = 0; i < patterns.length; i++) { + patterns[i] = patterns[i].join('/') + } + return patterns +} + +function examples2openapi(examples) { + const paths = {} + for (const example of examples) { + const patterns = getVariants(example.example.pattern) + + for (const pattern of patterns) { + const openApiPattern = pattern2openapi(pattern) + if ( + openApiPattern.includes('*') || + openApiPattern.includes('?') || + openApiPattern.includes('+') || + openApiPattern.includes('(') + ) { + throw new Error(`unexpected characters in pattern '${openApiPattern}'`) + } + + /* + There's several things going on in this block: + 1. Filter out any examples for params that don't appear + in this variant of the route + 2. Make sure we add params to the array + in the same order they appear in the route + 3. If there are any params we don't have an example value for, + make sure they still appear in the pathParams array with + exampleValue == undefined anyway + */ + const pathParams = [] + for (const param of openApiPattern + .split('/') + .filter(p => p.startsWith('{') && p.endsWith('}'))) { + const paramName = param.slice(1, -1) + const exampleValue = example.example.namedParams[paramName] + pathParams.push(param2openapi(pattern, paramName, exampleValue, 'path')) + } + + const queryParams = example.example.queryParams || {} + + const parameters = [ + ...pathParams, + ...Object.entries(queryParams).map(([paramName, exampleValue]) => + param2openapi(pattern, paramName, exampleValue, 'query') + ), + ...globalParamRefs, + ] + paths[openApiPattern] = { + get: { + summary: example.title, + description: example?.documentation?.__html + .replace(/
/g, '
') // react does not like
+ .replace(/{/g, '{') + .replace(/}/g, '}') + .replace(/