diff --git a/core/base-service/base.js b/core/base-service/base.js index 02eefc59b1..4b7e460955 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -19,7 +19,6 @@ import { InvalidParameter, Deprecated, } from './errors.js' -import { validateExample, transformExample } from './examples.js' import { fetch } from './got.js' import { getEnum } from './openapi.js' import { @@ -144,31 +143,14 @@ class BaseService { static auth = undefined /** - * Array of Example objects describing example URLs for this service. - * These should use the format specified in `route`, - * and can be used to demonstrate how to use badges for this service. - * - * The preferred way to specify an example is with `namedParams` which are - * substituted into the service's compiled route pattern. The rendered badge - * is specified with `staticPreview`. - * - * For services which use a route `format`, the `pattern` can be specified as - * part of the example. - * - * @see {@link module:core/base-service/base~Example} - * @abstract - * @type {module:core/base-service/base~Example[]} - */ - static examples = [] - - /** - * Optional: an OpenAPI Paths Object describing this service's + * An OpenAPI Paths Object describing this service's * route or routes in OpenAPI format. * - * @see https://swagger.io/specification/#paths-object * @abstract + * @see https://swagger.io/specification/#paths-object + * @type {module:core/base-service/service-definitions~openApiSchema} */ - static openApi = undefined + static openApi = {} static get _cacheLength() { const cacheLengths = { @@ -207,23 +189,17 @@ class BaseService { `Default badge data for ${this.name}`, ) - this.examples.forEach((example, index) => - validateExample(example, index, this), - ) - // ensure openApi spec matches route - if (this.openApi) { - const preparedRoute = prepareRoute(this.route) - for (const [key, value] of Object.entries(this.openApi)) { - let example = key - for (const param of value.get.parameters) { - example = example.replace(`{${param.name}}`, param.example) - } - if (!example.match(preparedRoute.regex)) { - throw new Error( - `Inconsistent Open Api spec and Route found for service ${this.name}`, - ) - } + const preparedRoute = prepareRoute(this.route) + for (const [key, value] of Object.entries(this.openApi)) { + let example = key + for (const param of value.get.parameters) { + example = example.replace(`{${param.name}}`, param.example) + } + if (!example.match(preparedRoute.regex)) { + throw new Error( + `Inconsistent Open Api spec and Route found for service ${this.name}`, + ) } } } @@ -233,10 +209,6 @@ class BaseService { const { base, format, pattern } = this.route const queryParams = getQueryParamNames(this.route) - const examples = this.examples.map((example, index) => - transformExample(example, index, this), - ) - let route if (pattern) { route = { pattern: makeFullUrl(base, pattern), queryParams } @@ -246,7 +218,7 @@ class BaseService { route = undefined } - const result = { category, name, isDeprecated, route, examples, openApi } + const result = { category, name, isDeprecated, route, openApi } assertValidServiceDefinition(result, `getDefinition() for ${this.name}`) @@ -597,9 +569,11 @@ class BaseService { * receives numeric can use `Joi.string()`. A boolean * parameter should use `Joi.equal('')` and will receive an * empty string on e.g. `?compact_message` and undefined - * when the parameter is absent. (Note that in, - * `examples.queryParams` boolean query params should be given - * `null` values.) + * when the parameter is absent. In the OpenApi definitions, + * this type of param should be documented as + * queryParam({ + * name: 'compact_message', schema: { type: 'boolean' }, example: null + * }) */ /** @@ -614,30 +588,4 @@ class BaseService { * configured credentials are present. */ -/** - * @typedef {object} Example - * @property {string} title - * Descriptive text that will be shown next to the badge. The default - * is to use the service class name, which probably is not what you want. - * @property {object} namedParams - * An object containing the values of named parameters to - * substitute into the compiled route pattern. - * @property {object} queryParams - * An object containing query parameters to include in the - * example URLs. For alphanumeric query parameters, specify a string value. - * For boolean query parameters, specify `null`. - * @property {string} pattern - * The route pattern to compile. Defaults to `this.route.pattern`. - * @property {object} staticPreview - * A rendered badge of the sort returned by `handle()` or - * `render()`: an object containing `message` and optional `label` and - * `color`. This is usually generated by invoking `this.render()` with some - * explicit props. - * @property {string[]} keywords - * Additional keywords, other than words in the title. This helps - * users locate relevant badges. - * @property {string} documentation - * An HTML string that is included in the badge popup. - */ - export default BaseService diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index 0fda208a70..575f0386cc 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -4,6 +4,7 @@ import sinon from 'sinon' import prometheus from 'prom-client' import chaiAsPromised from 'chai-as-promised' import PrometheusMetrics from '../server/prometheus-metrics.js' +import { pathParam, queryParam } from './openapi.js' import trace from './trace.js' import { NotFound, @@ -31,14 +32,17 @@ class DummyService extends BaseService { static category = 'other' static route = { base: 'foo', pattern: ':namedParamA', queryParamSchema } - static examples = [ - { - pattern: ':world', - namedParams: { world: 'World' }, - staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }), - keywords: ['hello'], + static openApi = { + '/foo/{namedParamA}': { + get: { + summary: 'Dummy Service', + parameters: [ + pathParam({ name: 'namedParamA', example: 'foo' }), + queryParam({ name: 'queryParamA', example: 'bar' }), + ], + }, }, - ] + } static defaultBadgeData = { label: 'cat', namedLogo: 'appveyor' } @@ -383,7 +387,7 @@ describe('BaseService', function () { describe('getDefinition', function () { it('returns the expected result', function () { - const { category, name, isDeprecated, route, examples } = + const { category, name, isDeprecated, route, openApi } = DummyService.getDefinition() expect({ category, @@ -400,7 +404,7 @@ describe('BaseService', function () { }, }) // The in-depth tests for examples reside in examples.spec.js - expect(examples).to.have.lengthOf(1) + expect(Object.keys(openApi)).to.have.lengthOf(1) }) }) diff --git a/core/base-service/deprecated-service.js b/core/base-service/deprecated-service.js index 341c4843ec..8a43bb014f 100644 --- a/core/base-service/deprecated-service.js +++ b/core/base-service/deprecated-service.js @@ -10,14 +10,12 @@ const attrSchema = Joi.object({ name: Joi.string(), label: Joi.string(), category: isValidCategory, - // The content of examples is validated later, via `transformExamples()`. - examples: Joi.array().default([]), message: Joi.string(), dateAdded: Joi.date().required(), }).required() function deprecatedService(attrs) { - const { route, name, label, category, examples, message } = Joi.attempt( + const { route, name, label, category, message } = Joi.attempt( attrs, attrSchema, `Deprecated service for ${attrs.route.base}`, @@ -33,7 +31,6 @@ function deprecatedService(attrs) { static category = category static isDeprecated = true static route = route - static examples = examples static defaultBadgeData = { label } async handle() { diff --git a/core/base-service/deprecated-service.spec.js b/core/base-service/deprecated-service.spec.js index 58ca62b6d9..870e9622f9 100644 --- a/core/base-service/deprecated-service.spec.js +++ b/core/base-service/deprecated-service.spec.js @@ -36,16 +36,6 @@ describe('DeprecatedService', function () { expect(service.category).to.equal(category) }) - it('sets specified examples', function () { - const examples = [ - { - title: 'Not sure we would have examples', - }, - ] - const service = deprecatedService({ ...commonAttrs, examples }) - expect(service.examples).to.deep.equal(examples) - }) - it('uses default deprecation message when no message specified', async function () { const service = deprecatedService({ ...commonAttrs }) expect(await service.invoke()).to.deep.equal({ diff --git a/core/base-service/examples.js b/core/base-service/examples.js deleted file mode 100644 index cf1b1c021d..0000000000 --- a/core/base-service/examples.js +++ /dev/null @@ -1,155 +0,0 @@ -import Joi from 'joi' -import { pathToRegexp, compile } from 'path-to-regexp' -import categories from '../../services/categories.js' -import coalesceBadge from './coalesce-badge.js' -import { makeFullUrl } from './route.js' - -const optionalObjectOfKeyValues = Joi.object().pattern( - /./, - Joi.string().allow(null), -) - -const schema = Joi.object({ - // This should be: - // title: Joi.string().required(), - title: Joi.string(), - namedParams: optionalObjectOfKeyValues.required(), - queryParams: optionalObjectOfKeyValues.default({}), - pattern: Joi.string(), - staticPreview: Joi.object({ - label: Joi.string(), - message: Joi.alternatives() - .try(Joi.string().allow('').required(), Joi.number()) - .required(), - color: Joi.string(), - style: Joi.string(), - }).required(), - keywords: Joi.array().items(Joi.string()).default([]), - documentation: Joi.string(), // Valid HTML. -}).required() - -function validateExample(example, index, ServiceClass) { - const result = Joi.attempt( - example, - schema, - `Example for ${ServiceClass.name} at index ${index}`, - ) - - const { pattern, namedParams } = result - - if (!pattern && !ServiceClass.route.pattern) { - throw new Error( - `Example for ${ServiceClass.name} at index ${index} does not declare a pattern`, - ) - } - if (pattern === ServiceClass.route.pattern) { - throw new Error( - `Example for ${ServiceClass.name} at index ${index} declares a redundant pattern which should be removed`, - ) - } - - // Make sure we can build the full URL using these patterns. - try { - compile(pattern || ServiceClass.route.pattern, { - encode: encodeURIComponent, - })(namedParams) - } catch (e) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, ${e.message.toLowerCase()}`, - ) - } - // Make sure there are no extra keys. - let keys = [] - pathToRegexp(pattern || ServiceClass.route.pattern, keys, { - strict: true, - sensitive: true, - }) - keys = keys.map(({ name }) => name) - const extraKeys = Object.keys(namedParams).filter(k => !keys.includes(k)) - if (extraKeys.length) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, namedParams contains unknown keys: ${extraKeys.join( - ', ', - )}`, - ) - } - - if (example.keywords) { - // Make sure the keywords are at least two characters long. - const tinyKeywords = example.keywords.filter(k => k.length < 2) - if (tinyKeywords.length) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, keywords contains words that are less than two characters long: ${tinyKeywords.join( - ', ', - )}`, - ) - } - // Make sure none of the keywords are already included in the title. - const title = (example.title || ServiceClass.name).toLowerCase() - const redundantKeywords = example.keywords.filter(k => - title.includes(k.toLowerCase()), - ) - if (redundantKeywords.length) { - throw Error( - `In example for ${ - ServiceClass.name - } at index ${index}, keywords contains words that are already in the title: ${redundantKeywords.join( - ', ', - )}`, - ) - } - } - - return result -} - -function transformExample(inExample, index, ServiceClass) { - const { - // We should get rid of this transform, since the class name is never what - // we want to see. - title = ServiceClass.name, - namedParams, - queryParams, - pattern, - staticPreview, - keywords, - documentation, - } = validateExample(inExample, index, ServiceClass) - - const { label, message, color, style, namedLogo } = coalesceBadge( - {}, - staticPreview, - ServiceClass.defaultBadgeData, - ServiceClass, - ) - - const category = categories.find(c => c.id === ServiceClass.category) - return { - title, - example: { - pattern: makeFullUrl( - ServiceClass.route.base, - pattern || ServiceClass.route.pattern, - ), - namedParams, - queryParams, - }, - preview: { - label, - message: `${message}`, - color, - style: style === 'flat' ? undefined : style, - namedLogo, - }, - keywords: category ? keywords.concat(category.keywords) : keywords, - documentation: documentation ? { __html: documentation } : undefined, - } -} - -export { validateExample, transformExample } diff --git a/core/base-service/examples.spec.js b/core/base-service/examples.spec.js deleted file mode 100644 index 7b64b204d4..0000000000 --- a/core/base-service/examples.spec.js +++ /dev/null @@ -1,167 +0,0 @@ -import { expect } from 'chai' -import { test, given } from 'sazerac' -import { validateExample, transformExample } from './examples.js' - -describe('validateExample function', function () { - it('passes valid examples', function () { - const validExamples = [ - { - title: 'Package manager versioning badge', - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['semver', 'management'], - }, - ] - - validExamples.forEach(example => { - expect(() => - validateExample(example, 0, { route: {}, name: 'mockService' }), - ).not.to.throw(Error) - }) - }) - - it('rejects invalid examples', function () { - const invalidExamples = [ - {}, - { staticPreview: { message: '123' } }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - exampleUrl: 'dt/mypackage', - }, - { staticPreview: { message: '123' }, pattern: 'dt/:package' }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - previewUrl: 'dt/mypackage', - }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - exampleUrl: 'dt/mypackage', - }, - { previewUrl: 'dt/mypackage' }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['a'], // Keyword too short. - }, - { - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['mockService'], // No title and keyword matching the class name. - }, - { - title: 'Package manager versioning badge', - staticPreview: { message: '123' }, - pattern: 'dt/:package', - namedParams: { package: 'mypackage' }, - keywords: ['version'], // Keyword included in title. - }, - ] - - invalidExamples.forEach(example => { - expect(() => - validateExample(example, 0, { route: {}, name: 'mockService' }), - ).to.throw(Error) - }) - }) -}) - -test(transformExample, function () { - const ExampleService = { - name: 'ExampleService', - route: { - base: 'some-service', - pattern: ':interval/:packageName', - }, - defaultBadgeData: { - label: 'downloads', - }, - category: 'platform-support', - } - - given( - { - pattern: 'dt/:packageName', - namedParams: { packageName: 'express' }, - staticPreview: { message: '50k' }, - keywords: ['hello'], - }, - 0, - ExampleService, - ).expect({ - title: 'ExampleService', - example: { - pattern: '/some-service/dt/:packageName', - namedParams: { packageName: 'express' }, - queryParams: {}, - }, - preview: { - label: 'downloads', - message: '50k', - color: 'lightgrey', - namedLogo: undefined, - style: undefined, - }, - keywords: ['hello', 'platform'], - documentation: undefined, - }) - - given( - { - namedParams: { interval: 'dt', packageName: 'express' }, - staticPreview: { message: '50k' }, - keywords: ['hello'], - }, - 0, - ExampleService, - ).expect({ - title: 'ExampleService', - example: { - pattern: '/some-service/:interval/:packageName', - namedParams: { interval: 'dt', packageName: 'express' }, - queryParams: {}, - }, - preview: { - label: 'downloads', - message: '50k', - color: 'lightgrey', - namedLogo: undefined, - style: undefined, - }, - keywords: ['hello', 'platform'], - documentation: undefined, - }) - - given( - { - namedParams: { interval: 'dt', packageName: 'express' }, - queryParams: { registry_url: 'http://example.com/' }, - staticPreview: { message: '50k' }, - keywords: ['hello'], - }, - 0, - ExampleService, - ).expect({ - title: 'ExampleService', - example: { - pattern: '/some-service/:interval/:packageName', - namedParams: { interval: 'dt', packageName: 'express' }, - queryParams: { registry_url: 'http://example.com/' }, - }, - preview: { - label: 'downloads', - message: '50k', - color: 'lightgrey', - namedLogo: undefined, - style: undefined, - }, - keywords: ['hello', 'platform'], - documentation: undefined, - }) -}) diff --git a/core/base-service/openapi.js b/core/base-service/openapi.js index eb6671107e..3eabf9cb99 100644 --- a/core/base-service/openapi.js +++ b/core/base-service/openapi.js @@ -46,13 +46,6 @@ function getCodeSamples(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) @@ -65,126 +58,6 @@ function getEnum(pattern, paramName) { 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(/