From 148b51d554973b221160aa084e959fc0ba648f1f Mon Sep 17 00:00:00 2001
From: chris48s <chris48s@users.noreply.github.com>
Date: Tue, 11 Apr 2023 18:37:16 +0100
Subject: [PATCH] Export OpenAPI definitions from service examples; affects
 [dynamic endpoint static] (#8966)

* WIP export OpenAPI definitions from service examples

* allow services to optionally define an OpenApi Paths Object instead of examples

* make use of param descriptions and required query params

* convert other 'core' services to declare openApi..

..instead of examples

* tweak descriptions for standard query params

* move stuff around, add a high-level integration test for category2openapi

* update simple-icons text refs #9054

* remove legacy param names
---
 .gitignore                                    |   1 +
 core/base-service/base.js                     |  13 +-
 core/base-service/examples.js                 |   5 +-
 core/base-service/openapi.js                  | 335 ++++++++++++++++
 core/base-service/openapi.spec.js             | 379 ++++++++++++++++++
 core/base-service/service-definitions.js      |  22 +
 frontend/categories/.gitkeep                  |   0
 scripts/export-openapi-cli.js                 |  49 +++
 scripts/export-service-definitions-cli.js     |  13 +
 services/dynamic/dynamic-json.service.js      |  47 +++
 services/dynamic/dynamic-xml.service.js       |  47 +++
 services/dynamic/dynamic-yaml.service.js      |  47 +++
 services/endpoint/endpoint.service.js         | 130 ++++++
 services/static-badge/static-badge.service.js |  60 ++-
 14 files changed, 1142 insertions(+), 6 deletions(-)
 create mode 100644 core/base-service/openapi.js
 create mode 100644 core/base-service/openapi.spec.js
 create mode 100644 frontend/categories/.gitkeep
 create mode 100644 scripts/export-openapi-cli.js

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: `<img alt="${altText}" src="$url">`,
+    },
+  ]
+}
+
+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(/<br>/g, '<br />') // react does not like <br>
+            .replace(/{/g, '&#123;')
+            .replace(/}/g, '&#125;')
+            .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)) {
+    paths[key] = endpoints[key]
+    paths[key].get.parameters = [
+      ...paths[key].get.parameters,
+      ...globalParamRefs,
+    ]
+    paths[key].get['x-code-samples'] = getCodeSamples(paths[key].get.summary)
+  }
+  return paths
+}
+
+function services2openapi(services) {
+  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) {
+          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
+      }
+    }
+  }
+  return paths
+}
+
+function category2openapi(category, services) {
+  const spec = {
+    openapi: '3.0.0',
+    info: {
+      version: '1.0.0',
+      title: category.name,
+      license: {
+        name: 'CC0',
+      },
+    },
+    servers: [{ url: baseUrl }],
+    components: {
+      parameters: {
+        style: {
+          name: 'style',
+          in: 'query',
+          required: false,
+          description:
+            'One of: flat (default), flat-square, plastic, for-the-badge, social',
+          schema: {
+            type: 'string',
+          },
+          example: 'flat',
+        },
+        logo: {
+          name: 'logo',
+          in: 'query',
+          required: false,
+          description:
+            'One of the named logos (bitcoin, dependabot, gitlab, npm, paypal, serverfault, stackexchange, superuser, telegram, travis) or simple-icons. All simple-icons are referenced using icon slugs. You can click the icon title on <a href="https://simpleicons.org/" rel="noopener noreferrer" target="_blank">simple-icons</a> to copy the slug or they can be found in the <a href="https://github.com/simple-icons/simple-icons/blob/master/slugs.md">slugs.md file</a> in the simple-icons repository.',
+          schema: {
+            type: 'string',
+          },
+          example: 'appveyor',
+        },
+        logoColor: {
+          name: 'logoColor',
+          in: 'query',
+          required: false,
+          description:
+            'The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for named logos and Shields logos but not for custom logos. For multicolor Shields logos, the corresponding named logo will be used and colored.',
+          schema: {
+            type: 'string',
+          },
+          example: 'violet',
+        },
+        label: {
+          name: 'label',
+          in: 'query',
+          required: false,
+          description:
+            'Override the default left-hand-side text (<a href="https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding">URL-Encoding</a> needed for spaces or special characters!)',
+          schema: {
+            type: 'string',
+          },
+          example: 'healthiness',
+        },
+        labelColor: {
+          name: 'labelColor',
+          in: 'query',
+          required: false,
+          description:
+            'Background color of the left part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+          schema: {
+            type: 'string',
+          },
+          example: 'abcdef',
+        },
+        color: {
+          name: 'color',
+          in: 'query',
+          required: false,
+          description:
+            'Background color of the right part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+          schema: {
+            type: 'string',
+          },
+          example: 'fedcba',
+        },
+        cacheSeconds: {
+          name: 'cacheSeconds',
+          in: 'query',
+          required: false,
+          description:
+            'HTTP cache lifetime (rules are applied to infer a default value on a per-badge basis, any values specified below the default will be ignored).',
+          schema: {
+            type: 'string',
+          },
+          example: '3600',
+        },
+        link: {
+          name: 'link',
+          in: 'query',
+          required: false,
+          description:
+            'Specify what clicking on the left/right of a badge should do. Note that this only works when integrating your badge in an `<object>` HTML tag, but not an `<img>` tag or a markup language.',
+          style: 'form',
+          explode: true,
+          schema: {
+            type: 'array',
+            maxItems: 2,
+            items: {
+              type: 'string',
+            },
+          },
+        },
+      },
+    },
+    paths: services2openapi(services),
+  }
+
+  return spec
+}
+
+export { category2openapi }
diff --git a/core/base-service/openapi.spec.js b/core/base-service/openapi.spec.js
new file mode 100644
index 0000000000..8d6b3f09e4
--- /dev/null
+++ b/core/base-service/openapi.spec.js
@@ -0,0 +1,379 @@
+import chai from 'chai'
+import { category2openapi } from './openapi.js'
+import BaseJsonService from './base-json.js'
+const { expect } = chai
+
+class OpenApiService extends BaseJsonService {
+  static category = 'build'
+  static route = { base: 'openapi/service', pattern: ':packageName/:distTag*' }
+
+  // this service defines its own API Paths Object
+  static openApi = {
+    '/openapi/service/{packageName}': {
+      get: {
+        summary: 'OpenApiService Summary',
+        description: 'OpenApiService Description',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+        ],
+      },
+    },
+    '/openapi/service/{packageName}/{distTag}': {
+      get: {
+        summary: 'OpenApiService Summary (with Tag)',
+        description: 'OpenApiService Description (with Tag)',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          {
+            name: 'distTag',
+            description: 'distTag description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'latest',
+          },
+        ],
+      },
+    },
+  }
+}
+
+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' } },
+  servers: [{ url: 'https://img.shields.io' }],
+  components: {
+    parameters: {
+      style: {
+        name: 'style',
+        in: 'query',
+        required: false,
+        description:
+          'One of: flat (default), flat-square, plastic, for-the-badge, social',
+        schema: { type: 'string' },
+        example: 'flat',
+      },
+      logo: {
+        name: 'logo',
+        in: 'query',
+        required: false,
+        description:
+          'One of the named logos (bitcoin, dependabot, gitlab, npm, paypal, serverfault, stackexchange, superuser, telegram, travis) or simple-icons. All simple-icons are referenced using icon slugs. You can click the icon title on <a href="https://simpleicons.org/" rel="noopener noreferrer" target="_blank">simple-icons</a> to copy the slug or they can be found in the <a href="https://github.com/simple-icons/simple-icons/blob/master/slugs.md">slugs.md file</a> in the simple-icons repository.',
+        schema: { type: 'string' },
+        example: 'appveyor',
+      },
+      logoColor: {
+        name: 'logoColor',
+        in: 'query',
+        required: false,
+        description:
+          'The color of the logo (hex, rgb, rgba, hsl, hsla and css named colors supported). Supported for named logos and Shields logos but not for custom logos. For multicolor Shields logos, the corresponding named logo will be used and colored.',
+        schema: { type: 'string' },
+        example: 'violet',
+      },
+      label: {
+        name: 'label',
+        in: 'query',
+        required: false,
+        description:
+          'Override the default left-hand-side text (<a href="https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding">URL-Encoding</a> needed for spaces or special characters!)',
+        schema: { type: 'string' },
+        example: 'healthiness',
+      },
+      labelColor: {
+        name: 'labelColor',
+        in: 'query',
+        required: false,
+        description:
+          'Background color of the left part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+        schema: { type: 'string' },
+        example: 'abcdef',
+      },
+      color: {
+        name: 'color',
+        in: 'query',
+        required: false,
+        description:
+          'Background color of the right part (hex, rgb, rgba, hsl, hsla and css named colors supported).',
+        schema: { type: 'string' },
+        example: 'fedcba',
+      },
+      cacheSeconds: {
+        name: 'cacheSeconds',
+        in: 'query',
+        required: false,
+        description:
+          'HTTP cache lifetime (rules are applied to infer a default value on a per-badge basis, any values specified below the default will be ignored).',
+        schema: { type: 'string' },
+        example: '3600',
+      },
+      link: {
+        name: 'link',
+        in: 'query',
+        required: false,
+        description:
+          'Specify what clicking on the left/right of a badge should do. Note that this only works when integrating your badge in an `<object>` HTML tag, but not an `<img>` tag or a markup language.',
+        style: 'form',
+        explode: true,
+        schema: { type: 'array', maxItems: 2, items: { type: 'string' } },
+      },
+    },
+  },
+  paths: {
+    '/openapi/service/{packageName}': {
+      get: {
+        summary: 'OpenApiService Summary',
+        description: 'OpenApiService Description',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            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: '![OpenApiService Summary]($url)',
+          },
+          {
+            lang: 'reStructuredText',
+            label: 'rSt',
+            source: '.. image:: $url\n:   alt: OpenApiService Summary',
+          },
+          {
+            lang: 'AsciiDoc',
+            label: 'AsciiDoc',
+            source: 'image:$url[OpenApiService Summary]',
+          },
+          {
+            lang: 'HTML',
+            label: 'HTML',
+            source: '<img alt="OpenApiService Summary" src="$url">',
+          },
+        ],
+      },
+    },
+    '/openapi/service/{packageName}/{distTag}': {
+      get: {
+        summary: 'OpenApiService Summary (with Tag)',
+        description: 'OpenApiService Description (with Tag)',
+        parameters: [
+          {
+            name: 'packageName',
+            description: 'packageName description',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'badge-maker',
+          },
+          {
+            name: 'distTag',
+            description: 'distTag description',
+            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: '![OpenApiService Summary (with Tag)]($url)',
+          },
+          {
+            lang: 'reStructuredText',
+            label: 'rSt',
+            source:
+              '.. image:: $url\n:   alt: OpenApiService Summary (with Tag)',
+          },
+          {
+            lang: 'AsciiDoc',
+            label: 'AsciiDoc',
+            source: 'image:$url[OpenApiService Summary (with Tag)]',
+          },
+          {
+            lang: 'HTML',
+            label: 'HTML',
+            source: '<img alt="OpenApiService Summary (with Tag)" src="$url">',
+          },
+        ],
+      },
+    },
+    '/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: '![LegacyService Title (with Tag)]($url)',
+          },
+          {
+            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: '![LegacyService Title (with Tag)]($url)',
+          },
+          {
+            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">',
+          },
+        ],
+      },
+    },
+  },
+}
+
+function clean(obj) {
+  // remove any undefined values in the object
+  return JSON.parse(JSON.stringify(obj))
+}
+
+describe('category2openapi', function () {
+  it('generates an Open API spec', function () {
+    expect(
+      clean(
+        category2openapi({ name: 'build' }, [
+          OpenApiService.getDefinition(),
+          LegacyService.getDefinition(),
+        ])
+      )
+    ).to.deep.equal(expected)
+  })
+})
diff --git a/core/base-service/service-definitions.js b/core/base-service/service-definitions.js
index 1d66f4e914..8718b58cbd 100644
--- a/core/base-service/service-definitions.js
+++ b/core/base-service/service-definitions.js
@@ -46,6 +46,28 @@ const serviceDefinition = Joi.object({
       })
     )
     .default([]),
+  openApi: Joi.object().pattern(
+    /./,
+    Joi.object({
+      get: Joi.object({
+        summary: Joi.string().required(),
+        description: Joi.string().required(),
+        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() }).required(),
+              example: Joi.string(),
+            })
+          )
+          .min(1)
+          .required(),
+      }).required(),
+    }).required()
+  ),
 }).required()
 
 function assertValidServiceDefinition(example, message = undefined) {
diff --git a/frontend/categories/.gitkeep b/frontend/categories/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/scripts/export-openapi-cli.js b/scripts/export-openapi-cli.js
new file mode 100644
index 0000000000..bc0481ba17
--- /dev/null
+++ b/scripts/export-openapi-cli.js
@@ -0,0 +1,49 @@
+import fs from 'fs'
+import path from 'path'
+import yaml from 'js-yaml'
+import { collectDefinitions } from '../core/base-service/loader.js'
+import { category2openapi } from '../core/base-service/openapi.js'
+
+const specsPath = path.join('frontend', 'categories')
+
+function writeSpec(filename, spec) {
+  // Omit undefined
+  // https://github.com/nodeca/js-yaml/issues/356#issuecomment-312430599
+  const cleaned = JSON.parse(JSON.stringify(spec))
+
+  fs.writeFileSync(
+    filename,
+    yaml.dump(cleaned, { flowLevel: 5, forceQuotes: true })
+  )
+}
+
+;(async () => {
+  const definitions = await collectDefinitions()
+
+  for (const category of definitions.categories) {
+    const services = definitions.services.filter(
+      service => service.category === category.id && !service.isDeprecated
+    )
+
+    writeSpec(
+      path.join(specsPath, `${category.id}.yaml`),
+      category2openapi(category, services)
+    )
+  }
+
+  let coreServices = []
+  coreServices = coreServices.concat(
+    definitions.services.filter(
+      service => service.category === 'static' && !service.isDeprecated
+    )
+  )
+  coreServices = coreServices.concat(
+    definitions.services.filter(
+      service => service.category === 'dynamic' && !service.isDeprecated
+    )
+  )
+  writeSpec(
+    path.join(specsPath, '1core.yaml'),
+    category2openapi({ name: 'Core' }, coreServices)
+  )
+})()
diff --git a/scripts/export-service-definitions-cli.js b/scripts/export-service-definitions-cli.js
index e019bbcd17..b953b76d73 100644
--- a/scripts/export-service-definitions-cli.js
+++ b/scripts/export-service-definitions-cli.js
@@ -3,6 +3,19 @@ import { collectDefinitions } from '../core/base-service/loader.js'
 ;(async () => {
   const definitions = await collectDefinitions()
 
+  // filter out static, dynamic and debug badge examples
+  const publicCategories = definitions.categories.map(c => c.id)
+  definitions.services = definitions.services.filter(s =>
+    publicCategories.includes(s.category)
+  )
+
+  // drop the openApi property for the "legacy" frontend
+  for (const service of definitions.services) {
+    if (service.openApi) {
+      service.openApi = undefined
+    }
+  }
+
   // Omit undefined
   // https://github.com/nodeca/js-yaml/issues/356#issuecomment-312430599
   const cleaned = JSON.parse(JSON.stringify(definitions))
diff --git a/services/dynamic/dynamic-json.service.js b/services/dynamic/dynamic-json.service.js
index 5e01de94d1..0dc3fa56a8 100644
--- a/services/dynamic/dynamic-json.service.js
+++ b/services/dynamic/dynamic-json.service.js
@@ -6,6 +6,53 @@ import jsonPath from './json-path.js'
 export default class DynamicJson extends jsonPath(BaseJsonService) {
   static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
   static route = createRoute('json')
+  static openApi = {
+    '/badge/dynamic/json': {
+      get: {
+        summary: 'Dynamic JSON Badge',
+        description: `<p>
+          The Dynamic JSON Badge allows you to extract an arbitrary value from any
+          JSON Document using a JSONPath selector and show it on a badge.
+        </p>`,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to a JSON document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example:
+              'https://github.com/badges/shields/raw/master/package.json',
+          },
+          {
+            name: 'query',
+            description:
+              'A <a href="https://jsonpath.com/">JSONPath</a> expression that will be used to query the document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: '$.name',
+          },
+          {
+            name: 'prefix',
+            description: 'Optional prefix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: '[',
+          },
+          {
+            name: 'suffix',
+            description: 'Optional suffix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: ']',
+          },
+        ],
+      },
+    },
+  }
 
   async fetch({ schema, url, errorMessages }) {
     return this._requestJson({
diff --git a/services/dynamic/dynamic-xml.service.js b/services/dynamic/dynamic-xml.service.js
index 2278f7260a..7696f9e3e0 100644
--- a/services/dynamic/dynamic-xml.service.js
+++ b/services/dynamic/dynamic-xml.service.js
@@ -15,6 +15,53 @@ export default class DynamicXml extends BaseService {
   static category = 'dynamic'
   static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
   static route = createRoute('xml')
+  static openApi = {
+    '/badge/dynamic/xml': {
+      get: {
+        summary: 'Dynamic XML Badge',
+        description: `<p>
+          The Dynamic XML Badge allows you to extract an arbitrary value from any
+          XML Document using an XPath selector and show it on a badge.
+        </p>`,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to a XML document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: 'https://httpbin.org/xml',
+          },
+          {
+            name: 'query',
+            description:
+              'A <a href="http://xpather.com/">XPath</a> expression that will be used to query the document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: '//slideshow/slide[1]/title',
+          },
+          {
+            name: 'prefix',
+            description: 'Optional prefix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: '[',
+          },
+          {
+            name: 'suffix',
+            description: 'Optional suffix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: ']',
+          },
+        ],
+      },
+    },
+  }
+
   static defaultBadgeData = { label: 'custom badge' }
 
   transform({ pathExpression, buffer }) {
diff --git a/services/dynamic/dynamic-yaml.service.js b/services/dynamic/dynamic-yaml.service.js
index 1c606f9033..46f84dd8e0 100644
--- a/services/dynamic/dynamic-yaml.service.js
+++ b/services/dynamic/dynamic-yaml.service.js
@@ -6,6 +6,53 @@ import jsonPath from './json-path.js'
 export default class DynamicYaml extends jsonPath(BaseYamlService) {
   static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
   static route = createRoute('yaml')
+  static openApi = {
+    '/badge/dynamic/yaml': {
+      get: {
+        summary: 'Dynamic YAML Badge',
+        description: `<p>
+          The Dynamic YAML Badge allows you to extract an arbitrary value from any
+          YAML Document using a JSONPath selector and show it on a badge.
+        </p>`,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to a YAML document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example:
+              'https://raw.githubusercontent.com/badges/shields/master/.github/dependabot.yml',
+          },
+          {
+            name: 'query',
+            description:
+              'A <a href="https://jsonpath.com/">JSONPath</a> expression that will be used to query the document',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: '$.version',
+          },
+          {
+            name: 'prefix',
+            description: 'Optional prefix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: '[',
+          },
+          {
+            name: 'suffix',
+            description: 'Optional suffix to append to the value',
+            in: 'query',
+            required: false,
+            schema: { type: 'string' },
+            example: ']',
+          },
+        ],
+      },
+    },
+  }
 
   async fetch({ schema, url, errorMessages }) {
     return this._requestYaml({
diff --git a/services/endpoint/endpoint.service.js b/services/endpoint/endpoint.service.js
index 8a64ccc74c..26be6f8402 100644
--- a/services/endpoint/endpoint.service.js
+++ b/services/endpoint/endpoint.service.js
@@ -11,14 +11,144 @@ const queryParamSchema = Joi.object({
   url: optionalUrl.required(),
 }).required()
 
+const description = `<p>
+  Using the endpoint badge, you can provide content for a badge through
+  a JSON endpoint. The content can be prerendered, or generated on the
+  fly. To strike a balance between responsiveness and bandwidth
+  utilization on one hand, and freshness on the other, cache behavior is
+  configurable, subject to the Shields minimum. The endpoint URL is
+  provided to Shields through the query string. Shields fetches it and
+  formats the badge.
+</p>
+<p>
+  The endpoint badge takes a single required query param: <code>url</code>, which is the URL to your JSON endpoint
+</p>
+<div>
+  <h2>Example JSON Endpoint Response</h2>
+  <code>&#123; "schemaVersion": 1, "label": "hello", "message": "sweet world", "color": "orange" &#125;</code>
+  <h2>Example Shields Response</h2>
+  <img src="https://img.shields.io/badge/hello-sweet_world-orange" />
+</div>
+<div>
+  <h2>Schema</h2>
+  <table>
+    <tbody>
+      <tr>
+        <th>Property</th>
+        <th>Description</th>
+      </tr>
+      <tr>
+        <td><code>schemaVersion</code></td>
+        <td>Required. Always the number <code>1</code>.</td>
+      </tr>
+      <tr>
+        <td><code>label</code></td>
+        <td>
+          Required. The left text, or the empty string to omit the left side of
+          the badge. This can be overridden by the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>message</code></td>
+        <td>Required. Can't be empty. The right text.</td>
+      </tr>
+      <tr>
+        <td><code>color</code></td>
+        <td>
+          Default: <code>lightgrey</code>. The right color. Supports the eight
+          named colors above, as well as hex, rgb, rgba, hsl, hsla and css named
+          colors. This can be overridden by the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>labelColor</code></td>
+        <td>
+          Default: <code>grey</code>. The left color. This can be overridden by
+          the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>isError</code></td>
+        <td>
+          Default: <code>false</code>. <code>true</code> to treat this as an
+          error badge. This prevents the user from overriding the color. In the
+          future, it may affect cache behavior.
+        </td>
+      </tr>
+      <tr>
+        <td><code>namedLogo</code></td>
+        <td>
+          Default: none. One of the named logos supported by Shields or
+          <a href="https://simpleicons.org/">simple-icons</a>. Can be overridden
+          by the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>logoSvg</code></td>
+        <td>Default: none. An SVG string containing a custom logo.</td>
+      </tr>
+      <tr>
+        <td><code>logoColor</code></td>
+        <td>
+          Default: none. Same meaning as the query string. Can be overridden by
+          the query string. Only works for named logos and Shields logos. If you
+          override the color of a multicolor Shield logo, the corresponding
+          named logo will be used and colored.
+        </td>
+      </tr>
+      <tr>
+        <td><code>logoWidth</code></td>
+        <td>
+          Default: none. Same meaning as the query string. Can be overridden by
+          the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>logoPosition</code></td>
+        <td>
+          Default: none. Same meaning as the query string. Can be overridden by
+          the query string.
+        </td>
+      </tr>
+      <tr>
+        <td><code>style</code></td>
+        <td>
+          Default: <code>flat</code>. The default template to use. Can be
+          overridden by the query string.
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>`
+
 export default class Endpoint extends BaseJsonService {
   static category = 'dynamic'
+
   static route = {
     base: 'endpoint',
     pattern: '',
     queryParamSchema,
   }
 
+  static openApi = {
+    '/endpoint': {
+      get: {
+        summary: 'Endpoint Badge',
+        description,
+        parameters: [
+          {
+            name: 'url',
+            description: 'The URL to your JSON endpoint',
+            in: 'query',
+            required: true,
+            schema: { type: 'string' },
+            example: 'https://shields.redsparr0w.com/2473/monday',
+          },
+        ],
+      },
+    },
+  }
+
   static _cacheLength = 300
   static defaultBadgeData = { label: 'custom badge' }
 
diff --git a/services/static-badge/static-badge.service.js b/services/static-badge/static-badge.service.js
index 0bcbe488e3..257cdb6260 100644
--- a/services/static-badge/static-badge.service.js
+++ b/services/static-badge/static-badge.service.js
@@ -1,15 +1,73 @@
 import { escapeFormat } from '../../core/badge-urls/path-helpers.js'
 import { BaseStaticService } from '../index.js'
 
+const description = `<p>
+  The static badge accepts a single required path parameter which encodes either:
+</p>
+<ul>
+  <li>
+    Label, message and color seperated by a dash <code>-</code>. For example:<br />
+    <img alt="any text: you like" src="https://img.shields.io/badge/any_text-you_like-blue" /> -
+    <a href="https://img.shields.io/badge/any_text-you_like-blue">https://img.shields.io/badge/any_text-you_like-blue</a>
+  </li>
+  <li>
+    Message and color only, seperated by a dash <code>-</code>. For example:<br />
+    <img alt="just the message" src="https://img.shields.io/badge/just%20the%20message-8A2BE2" /> -
+    <a href="https://img.shields.io/badge/just%20the%20message-8A2BE2">https://img.shields.io/badge/just%20the%20message-8A2BE2</a>
+  </li>
+</ul>
+<table>
+  <tbody>
+    <tr>
+      <th>URL input</th>
+      <th>Badge output</th>
+    </tr>
+    <tr>
+      <td>Underscore <code>_</code> or <code>%20</code></td>
+      <td>Space <code>&nbsp;</code></td>
+    </tr>
+    <tr>
+      <td>Double underscore <code>__</code></td>
+      <td>Underscore <code>_</code></td>
+    </tr>
+    <tr>
+      <td>Double dash <code>--</code></td>
+      <td>Dash <code>-</code></td>
+    </tr>
+  </tbody>
+</table>
+<p>
+  Hex, rgb, rgba, hsl, hsla and css named colors may be used.
+</p>`
+
 export default class StaticBadge extends BaseStaticService {
   static category = 'static'
-
   static route = {
     base: '',
     format: '(?::|badge/)((?:[^-]|--)*?)-?((?:[^-]|--)*)-((?:[^-.]|--)+)',
     capture: ['label', 'message', 'color'],
   }
 
+  static openApi = {
+    '/badge/{badgeContent}': {
+      get: {
+        summary: 'Static Badge',
+        description,
+        parameters: [
+          {
+            name: 'badgeContent',
+            description:
+              'Label, (optional) message, and color. Seperated by dashes',
+            in: 'path',
+            required: true,
+            schema: { type: 'string' },
+            example: 'build-passing-brightgreen',
+          },
+        ],
+      },
+    },
+  }
+
   handle({ label, message, color }) {
     return { label: escapeFormat(label), message: escapeFormat(message), color }
   }
-- 
GitLab