Complete the examples --> openApi migration; affects [node sonar travis wordpress visualstudio librariesio] (#9977)
* you missed one * remove examples from deprecatedService() I'm not going to replace this with openApi We have zero examples of deprecated services that declare examples * remove examples from redirector() * update test * remove compatibility code for converting examples to openApi * remove all the code for handling examples * remove a few bits of redundant code * improve docs for openApi property * last one, I promise
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
@@ -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(/<br>/g, '<br />') // react does not like <br>
|
||||
.replace(/{/g, '{')
|
||||
.replace(/}/g, '}')
|
||||
.replace(/<style>(.|\n)*?<\/style>/, ''), // workaround for w3c-validation TODO: remove later
|
||||
parameters,
|
||||
'x-code-samples': getCodeSamples(example.title),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function addGlobalProperties(endpoints) {
|
||||
const paths = {}
|
||||
for (const key of Object.keys(endpoints)) {
|
||||
@@ -207,24 +80,13 @@ function sortPaths(obj) {
|
||||
function services2openapi(services, sort) {
|
||||
const paths = {}
|
||||
for (const service of services) {
|
||||
if (service.openApi) {
|
||||
// if the service declares its own OpenAPI definition, use that...
|
||||
for (const [key, value] of Object.entries(
|
||||
addGlobalProperties(service.openApi),
|
||||
)) {
|
||||
if (key in paths && key !== '/github/{variant}/{user}/{repo}') {
|
||||
throw new Error(`Conflicting route: ${key}`)
|
||||
}
|
||||
paths[key] = value
|
||||
}
|
||||
} else {
|
||||
// ...otherwise do our best to build one from examples[]
|
||||
for (const [key, value] of Object.entries(
|
||||
examples2openapi(service.examples),
|
||||
)) {
|
||||
// allow conflicting routes for legacy examples
|
||||
paths[key] = value
|
||||
for (const [key, value] of Object.entries(
|
||||
addGlobalProperties(service.openApi),
|
||||
)) {
|
||||
if (key in paths && key !== '/github/{variant}/{user}/{repo}') {
|
||||
throw new Error(`Conflicting route: ${key}`)
|
||||
}
|
||||
paths[key] = value
|
||||
}
|
||||
}
|
||||
return sort ? sortPaths(paths) : paths
|
||||
|
||||
@@ -58,27 +58,6 @@ class OpenApiService extends BaseJsonService {
|
||||
}
|
||||
}
|
||||
|
||||
class LegacyService extends BaseJsonService {
|
||||
static category = 'build'
|
||||
static route = { base: 'legacy/service', pattern: ':packageName/:distTag*' }
|
||||
|
||||
// this service defines an Examples Array
|
||||
static examples = [
|
||||
{
|
||||
title: 'LegacyService Title',
|
||||
namedParams: { packageName: 'badge-maker' },
|
||||
staticPreview: { label: 'build', message: 'passing' },
|
||||
documentation: 'LegacyService Description',
|
||||
},
|
||||
{
|
||||
title: 'LegacyService Title (with Tag)',
|
||||
namedParams: { packageName: 'badge-maker', distTag: 'latest' },
|
||||
staticPreview: { label: 'build', message: 'passing' },
|
||||
documentation: 'LegacyService Description (with Tag)',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const expected = {
|
||||
openapi: '3.0.0',
|
||||
info: { version: '1.0.0', title: 'build', license: { name: 'CC0' } },
|
||||
@@ -266,105 +245,6 @@ const expected = {
|
||||
],
|
||||
},
|
||||
},
|
||||
'/legacy/service/{packageName}/{distTag}': {
|
||||
get: {
|
||||
summary: 'LegacyService Title (with Tag)',
|
||||
description: 'LegacyService Description (with Tag)',
|
||||
parameters: [
|
||||
{
|
||||
name: 'packageName',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'badge-maker',
|
||||
},
|
||||
{
|
||||
name: 'distTag',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'latest',
|
||||
},
|
||||
{ $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' },
|
||||
],
|
||||
'x-code-samples': [
|
||||
{ lang: 'URL', label: 'URL', source: '$url' },
|
||||
{
|
||||
lang: 'Markdown',
|
||||
label: 'Markdown',
|
||||
source: '',
|
||||
},
|
||||
{
|
||||
lang: 'reStructuredText',
|
||||
label: 'rSt',
|
||||
source: '.. image:: $url\n :alt: LegacyService Title (with Tag)',
|
||||
},
|
||||
{
|
||||
lang: 'AsciiDoc',
|
||||
label: 'AsciiDoc',
|
||||
source: 'image:$url[LegacyService Title (with Tag)]',
|
||||
},
|
||||
{
|
||||
lang: 'HTML',
|
||||
label: 'HTML',
|
||||
source: '<img alt="LegacyService Title (with Tag)" src="$url">',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'/legacy/service/{packageName}': {
|
||||
get: {
|
||||
summary: 'LegacyService Title (with Tag)',
|
||||
description: 'LegacyService Description (with Tag)',
|
||||
parameters: [
|
||||
{
|
||||
name: 'packageName',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'badge-maker',
|
||||
},
|
||||
{ $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' },
|
||||
],
|
||||
'x-code-samples': [
|
||||
{ lang: 'URL', label: 'URL', source: '$url' },
|
||||
{
|
||||
lang: 'Markdown',
|
||||
label: 'Markdown',
|
||||
source: '',
|
||||
},
|
||||
{
|
||||
lang: 'reStructuredText',
|
||||
label: 'rSt',
|
||||
source: '.. image:: $url\n :alt: LegacyService Title (with Tag)',
|
||||
},
|
||||
{
|
||||
lang: 'AsciiDoc',
|
||||
label: 'AsciiDoc',
|
||||
source: 'image:$url[LegacyService Title (with Tag)]',
|
||||
},
|
||||
{
|
||||
lang: 'HTML',
|
||||
label: 'HTML',
|
||||
source: '<img alt="LegacyService Title (with Tag)" src="$url">',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -379,10 +259,7 @@ describe('category2openapi', function () {
|
||||
clean(
|
||||
category2openapi({
|
||||
category: { name: 'build' },
|
||||
services: [
|
||||
OpenApiService.getDefinition(),
|
||||
LegacyService.getDefinition(),
|
||||
],
|
||||
services: [OpenApiService.getDefinition()],
|
||||
}),
|
||||
),
|
||||
).to.deep.equal(expected)
|
||||
|
||||
@@ -18,7 +18,6 @@ const attrSchema = Joi.object({
|
||||
category: isValidCategory,
|
||||
isDeprecated: Joi.boolean().default(true),
|
||||
route: isValidRoute,
|
||||
examples: Joi.array().has(Joi.object()).default([]),
|
||||
openApi: openApiSchema,
|
||||
transformPath: Joi.func()
|
||||
.maxArity(1)
|
||||
@@ -38,7 +37,6 @@ export default function redirector(attrs) {
|
||||
category,
|
||||
isDeprecated,
|
||||
route,
|
||||
examples,
|
||||
openApi,
|
||||
transformPath,
|
||||
transformQueryParams,
|
||||
@@ -55,7 +53,6 @@ export default function redirector(attrs) {
|
||||
static category = category
|
||||
static isDeprecated = isDeprecated
|
||||
static route = route
|
||||
static examples = examples
|
||||
static openApi = openApi
|
||||
|
||||
static register({ camp, metricInstance }, { rasterUrl }) {
|
||||
|
||||
@@ -45,24 +45,6 @@ describe('Redirector', function () {
|
||||
).to.throw('"dateAdded" is required')
|
||||
})
|
||||
|
||||
it('sets specified example', function () {
|
||||
const examples = [
|
||||
{
|
||||
title: 'very old service',
|
||||
pattern: ':namedParamA',
|
||||
namedParams: {
|
||||
namedParamA: 'namedParamAValue',
|
||||
},
|
||||
staticPreview: {
|
||||
label: 'service',
|
||||
message: 'v0.14.0',
|
||||
color: 'blue',
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(redirector({ ...attrs, examples }).examples).to.equal(examples)
|
||||
})
|
||||
|
||||
describe('ScoutCamp integration', function () {
|
||||
let port, baseUrl
|
||||
beforeEach(async function () {
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
/**
|
||||
* @module
|
||||
*/
|
||||
import Joi from 'joi'
|
||||
|
||||
const arrayOfStrings = Joi.array().items(Joi.string()).min(0).required()
|
||||
|
||||
const objectOfKeyValues = Joi.object()
|
||||
.pattern(/./, Joi.string().allow(null))
|
||||
.required()
|
||||
|
||||
const openApiSchema = Joi.object().pattern(
|
||||
/./,
|
||||
Joi.object({
|
||||
get: Joi.object({
|
||||
summary: Joi.string().required(),
|
||||
description: Joi.string(),
|
||||
parameters: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
description: Joi.string(),
|
||||
in: Joi.string().valid('query', 'path').required(),
|
||||
required: Joi.boolean().required(),
|
||||
schema: Joi.object({
|
||||
type: Joi.string().required(),
|
||||
enum: Joi.array(),
|
||||
}).required(),
|
||||
allowEmptyValue: Joi.boolean(),
|
||||
example: Joi.string().allow(null),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.required(),
|
||||
/**
|
||||
* Joi schema describing the subset of OpenAPI paths we use in this application
|
||||
*
|
||||
* @see https://swagger.io/specification/#paths-object
|
||||
*/
|
||||
const openApiSchema = Joi.object()
|
||||
.pattern(
|
||||
/./,
|
||||
Joi.object({
|
||||
get: Joi.object({
|
||||
summary: Joi.string().required(),
|
||||
description: Joi.string(),
|
||||
parameters: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
description: Joi.string(),
|
||||
in: Joi.string().valid('query', 'path').required(),
|
||||
required: Joi.boolean().required(),
|
||||
schema: Joi.object({
|
||||
type: Joi.string().required(),
|
||||
enum: Joi.array(),
|
||||
}).required(),
|
||||
allowEmptyValue: Joi.boolean(),
|
||||
example: Joi.string().allow(null),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.required(),
|
||||
}).required(),
|
||||
}).required(),
|
||||
}).required(),
|
||||
)
|
||||
)
|
||||
.default({})
|
||||
|
||||
const serviceDefinition = Joi.object({
|
||||
category: Joi.string().required(),
|
||||
@@ -47,29 +53,6 @@ const serviceDefinition = Joi.object({
|
||||
queryParams: arrayOfStrings,
|
||||
}),
|
||||
),
|
||||
examples: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
title: Joi.string().required(),
|
||||
example: Joi.object({
|
||||
pattern: Joi.string(),
|
||||
namedParams: objectOfKeyValues,
|
||||
queryParams: objectOfKeyValues,
|
||||
}).required(),
|
||||
preview: Joi.object({
|
||||
label: Joi.string(),
|
||||
message: Joi.string().allow('').required(),
|
||||
color: Joi.string().required(),
|
||||
style: Joi.string(),
|
||||
namedLogo: Joi.string(),
|
||||
}).required(),
|
||||
keywords: arrayOfStrings,
|
||||
documentation: Joi.object({
|
||||
__html: Joi.string().required(), // Valid HTML.
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
openApi: openApiSchema,
|
||||
}).required()
|
||||
|
||||
@@ -84,15 +67,14 @@ const serviceDefinitionExport = Joi.object({
|
||||
Joi.object({
|
||||
id: Joi.string().required(),
|
||||
name: Joi.string().required(),
|
||||
keywords: arrayOfStrings,
|
||||
}),
|
||||
)
|
||||
.required(),
|
||||
services: Joi.array().items(serviceDefinition).required(),
|
||||
}).required()
|
||||
|
||||
function assertValidServiceDefinitionExport(examples, message = undefined) {
|
||||
Joi.assert(examples, serviceDefinitionExport, message)
|
||||
function assertValidServiceDefinitionExport(openApiSpec, message = undefined) {
|
||||
Joi.assert(openApiSpec, serviceDefinitionExport, message)
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user