Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1c77127
add base service routeEnum
jNullj Apr 11, 2026
76d1ab3
enhance error handling in BaseService for invalid parameters
jNullj Apr 11, 2026
9c66f73
check for pattern for routeEnum as well to be used
jNullj Apr 11, 2026
fc74bc1
validate routeEnum in BaseService to ensure it is a non-empty array o…
jNullj Apr 11, 2026
c310122
add routeEnum validation and handling in BaseService tests
jNullj Apr 11, 2026
de01715
refactor route pattern in GemDownloads to use routeEnum
jNullj Apr 11, 2026
b837447
add routeEnum to redirector
jNullj Apr 11, 2026
e9ec499
refactor route pattern in TeamCityBuild use routeEnum
jNullj Apr 11, 2026
d071103
refactor TwitterUrlRedirect route pattern to use routeEnum
jNullj Apr 11, 2026
e4f4816
refactor PackagistDownloads route pattern to use routeEnum
jNullj Apr 11, 2026
e33f252
refactor MozillaObservatory route pattern to use routeEnum
jNullj Apr 11, 2026
142872f
refactor HexPmDownloads route pattern to use routeEnum
jNullj Apr 11, 2026
6a0b728
refactor GreasyForkInstalls route pattern to use routeEnum
jNullj Apr 11, 2026
93befa3
refactor documentation to include routeEnum support and examples
jNullj Apr 11, 2026
3a59191
remove @type tag description for routeEnum
jNullj Apr 21, 2026
316217a
refactor getBadgeExampleCall to support routeEnum for path parameters
jNullj Apr 21, 2026
3ab6453
refactor BaseService to support routeEnum for dynamic route registration
jNullj Apr 22, 2026
b752718
Merge branch 'master' into core/route-enum-refactor-p1
jNullj Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 109 additions & 36 deletions core/base-service/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,24 @@ class BaseService {
}

/**
* Extract an array of allowed values from this service's route pattern
* for a given route parameter
* If the route pattern includes an enum, this property should be set to the
* array of allowed values. This is used to validate the route pattern and
* generate the OpenAPI spec.
* enum applies to the first param in the route pattern.
*
* @abstract
* @type {string[]}
*/
static routeEnum = undefined

/**
* If old route enum is used (legacy):
* Extract an array of allowed values from this service's route pattern
* for a given route parameter
*
* If new routeEnum is used:
* Return the array of allowed values for a given route parameter from the
* routeEnum property
*
* @param {string} param The name of a param in this service's route pattern
* @returns {string[]} Array of allowed values for this param
Expand All @@ -109,6 +125,23 @@ class BaseService {
if (!('pattern' in this.route)) {
throw new Error('getEnum() requires route to have a .pattern property')
}

if (this.routeEnum) {
if (
!Array.isArray(this.routeEnum) ||
this.routeEnum.length === 0 ||
!this.routeEnum.every(item => typeof item === 'string')
) {
throw new Error(
`getEnum() requires routeEnum for ${this.name} to be a non-empty array of strings`,
)
}

return this.routeEnum
}

// TODO Remove after #11371 is merged the old route extraction.
// replace with error if routeEnum and this function is called.
const enumeration = getEnum(this.route.pattern, param)
if (!Array.isArray(enumeration)) {
throw new Error(
Expand Down Expand Up @@ -408,6 +441,17 @@ class BaseService {
serviceError = new ImproperlyConfigured({ prettyMessage })
}

if (!serviceError && this.routeEnum) {
const firstParamName = Object.keys(namedParams || {})[0]
if (firstParamName) {
if (!this.routeEnum.includes(namedParams[firstParamName])) {
serviceError = new InvalidParameter({
prettyMessage: `invalid parameter ${firstParamName}: ${namedParams[firstParamName]}`,
})
}
}
}

const { queryParamSchema } = this.route
let transformedQueryParams
if (!serviceError && queryParamSchema) {
Expand Down Expand Up @@ -469,49 +513,78 @@ class BaseService {
serviceConfig,
) {
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
const { regex, captureNames } = prepareRoute(this.route)
const queryParams = getQueryParamNames(this.route)

const metricHelper = MetricHelper.create({
metricInstance,
ServiceClass: this,
})

camp.route(
regex,
handleRequest(cacheHeaderConfig, {
queryParams,
handler: async (queryParams, match, sendBadge) => {
const metricHandle = metricHelper.startRequest()

const namedParams = namedParamsForMatch(captureNames, match, this)
const serviceData = await this.invoke(
{
requestFetcher: fetch,
githubApiProvider,
librariesIoApiProvider,
metricHelper,
},
serviceConfig,
namedParams,
queryParams,
const routesToRegister = []
if (
this.routeEnum &&
Array.isArray(this.routeEnum) &&
this.routeEnum.length > 0
) {
const { captureNames } = prepareRoute(this.route)
const firstParamName =
captureNames && captureNames.length > 0 ? captureNames[0] : undefined
if (firstParamName) {
for (const enumValue of this.routeEnum) {
const pattern = this.route.pattern.replace(
firstParamName,
`firstParamName(${enumValue})`, // keep capture for backwards compatibility
)
routesToRegister.push({
route: { ...this.route, pattern },
})
}
} else {
routesToRegister.push({ route: this.route })
}
} else {
routesToRegister.push({ route: this.route })
}

const badgeData = coalesceBadge(
queryParams,
serviceData,
this.defaultBadgeData,
this,
)
// The final capture group is the extension.
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
sendBadge(format, badgeData)

metricHandle.noteResponseSent()
},
cacheLength: this._cacheLength,
}),
)
for (const { route: routeToRegister } of routesToRegister) {
const { regex, captureNames } = prepareRoute(routeToRegister)

camp.route(
regex,
handleRequest(cacheHeaderConfig, {
queryParams,
handler: async (queryParams, match, sendBadge) => {
const metricHandle = metricHelper.startRequest()

const namedParams = namedParamsForMatch(captureNames, match, this)
const serviceData = await this.invoke(
{
requestFetcher: fetch,
githubApiProvider,
librariesIoApiProvider,
metricHelper,
},
serviceConfig,
namedParams,
queryParams,
)

const badgeData = coalesceBadge(
queryParams,
serviceData,
this.defaultBadgeData,
this,
)
// The final capture group is the extension.
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
sendBadge(format, badgeData)

metricHandle.noteResponseSent()
},
cacheLength: this._cacheLength,
}),
)
}
}
}

Expand Down
99 changes: 99 additions & 0 deletions core/base-service/base.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -618,5 +618,104 @@ describe('BaseService', function () {
'getEnum() requires route to have a .pattern property',
)
})

it('returns routeEnum when set', function () {
class RouteEnumService extends DummyService {
static route = {
base: 'foo',
pattern: ':namedParamA',
queryParamSchema,
}
static routeEnum = ['alpha', 'beta']
}

expect(RouteEnumService.getEnum('namedParamA')).to.deep.equal([
'alpha',
'beta',
])
})

it('invoke rejects first named param not in routeEnum', async function () {
class RouteEnumService extends DummyService {
static route = {
base: 'foo',
pattern: ':namedParamA',
queryParamSchema,
}
static routeEnum = ['alpha', 'beta']
}

const result = await RouteEnumService.invoke({}, defaultConfig, {
namedParamA: 'gamma',
})
expect(result).to.deep.equal({
isError: true,
color: 'red',
message: 'invalid parameter namedParamA: gamma',
})
})

it('invoke allows first named param present in routeEnum', async function () {
class RouteEnumService extends DummyService {
static route = {
base: 'foo',
pattern: ':namedParamA',
queryParamSchema,
}
static routeEnum = ['alpha', 'beta']
}

const result = await RouteEnumService.invoke({}, defaultConfig, {
namedParamA: 'alpha',
})
expect(result).to.deep.equal({
message: 'Hello namedParamA: alpha with queryParamA: undefined',
})
})

it('throws when routeEnum is not a non-empty array of strings (not an array)', function () {
class BadRouteEnumService extends DummyService {
static route = {
base: 'foo',
pattern: ':namedParamA',
queryParamSchema,
}
static routeEnum = { alpha: true }
}

expect(() => BadRouteEnumService.getEnum('namedParamA')).to.throw(
`getEnum() requires routeEnum for ${BadRouteEnumService.name} to be a non-empty array of strings`,
)
})

it('throws when routeEnum is an empty array', function () {
class BadRouteEnumService extends DummyService {
static route = {
base: 'foo',
pattern: ':namedParamA',
queryParamSchema,
}
static routeEnum = []
}

expect(() => BadRouteEnumService.getEnum('namedParamA')).to.throw(
`getEnum() requires routeEnum for ${BadRouteEnumService.name} to be a non-empty array of strings`,
)
})

it('throws when routeEnum contains non-string values', function () {
class BadRouteEnumService extends DummyService {
static route = {
base: 'foo',
pattern: ':namedParamA',
queryParamSchema,
}
static routeEnum = ['alpha', 42]
}

expect(() => BadRouteEnumService.getEnum('namedParamA')).to.throw(
`getEnum() requires routeEnum for ${BadRouteEnumService.name} to be a non-empty array of strings`,
)
})
})
})
3 changes: 3 additions & 0 deletions core/base-service/redirector.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const attrSchema = Joi.object({
category: isValidCategory,
isRetired: Joi.boolean().default(true),
route: isValidRoute,
routeEnum: Joi.array().items(Joi.string()).optional(),
openApi: openApiSchema,
transformPath: Joi.func()
.maxArity(1)
Expand All @@ -37,6 +38,7 @@ export default function redirector(attrs) {
category,
isRetired,
route,
routeEnum,
openApi,
transformPath,
transformQueryParams,
Expand All @@ -53,6 +55,7 @@ export default function redirector(attrs) {
static category = category
static isRetired = isRetired
static route = route
static routeEnum = routeEnum
static openApi = openApi

static register({ camp, metricInstance }, { rasterUrl }) {
Expand Down
21 changes: 20 additions & 1 deletion doc/TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,26 @@ Description of the code:
4. `route` declares the URL path at which the service operates. It also maps components of the URL path to handler parameters.
- `base` defines the first part of the URL that doesn't change, e.g. `/example/`.
- `pattern` defines the variable part of the route, everything that comes after `/example/`. It can include any number of named parameters. These are converted into regular expressions by [`path-to-regexp`][path-to-regexp]. Because a service instance won't be created until it's time to handle a request, the route and other metadata must be obtained by examining the classes themselves. [That's why they're marked `static`.][static]
- There is additional documentation on conventions for [designing badge URLs](./badge-urls.md)

- `routeEnum` (optional): an array of strings that defines a finite set of allowed values for an enum-like parameter in the route. When `routeEnum` is present, the first named parameter in the route `pattern` is treated as the enum value to validate against `routeEnum`.
- Example: for a route with pattern `:type/:user/:repo` you would set `static routeEnum = ['dt', 'dm', 'dd']` and the incoming `type` value will be validated against that array.
- If the first named parameter is not present in `routeEnum`, the request will be rejected before `handle()` is invoked.

```js
// Example service that uses routeEnum
export default class Downloads extends BaseService {
static route = { base: 'downloads', pattern: ':type/:user/:repo' }
static routeEnum = ['dt', 'dm', 'dd']

async handle({ type, user, repo }) {
// `type` is guaranteed to be one of 'dt', 'dm', or 'dd'
// implement fetch/render logic here
}
}
```

- There is additional documentation on conventions for [designing badge URLs](./badge-urls.md)

5. All badges must implement the `async handle()` function that receives parameters to render the badge. Parameters of `handle()` will match the name defined in `route` Because we're capturing a single variable called `text` our function signature is `async handle({ text })`. `async` is needed to let JavaScript do other things while we are waiting for result from external API. Although in this simple case, we don't make any external calls. Our `handle()` function should return an object with 3 properties:
- `label`: the text on the left side of the badge
- `message`: the text on the right side of the badge - here we are passing through the parameter we captured in the route regex
Expand Down
6 changes: 4 additions & 2 deletions doc/badge-redirectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ export default redirector({
category: 'analysis',
route: {
base: 'scrutinizer',
pattern: ':vcs(g|b)/:user/:repo/:branch*',
pattern: ':vcs/:user/:repo/:branch*',
},
routeEnum: ['g', 'b'],
transformPath: ({ vcs, user, repo, branch }) =>
`/scrutinizer/quality/${vcs}/${user}/${repo}${branch ? `/${branch}` : ''}`,
dateAdded: new Date('2025-02-02'),
Expand All @@ -81,8 +82,9 @@ export default redirector({
category: 'monitoring',
route: {
base: 'website',
pattern: ':protocol(https|http)/:hostAndPath+',
pattern: ':protocol/:hostAndPath+',
},
routeEnum: ['https', 'http'],
transformPath: () => '/website',
transformQueryParams: ({ protocol, hostAndPath }) => ({
url: `${protocol}://${hostAndPath}`,
Expand Down
2 changes: 2 additions & 0 deletions doc/badge-urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- singular if the badge message represents a single entity, such as the current status of a build (e.g: `/build`), or a more abstract or aggregate representation of the thing (e.g.: `/coverage`, `/quality`)
- plural if there are (or may) be many of the thing (e.g: `/dependencies`, `/stars`)
- Parameters should always be part of the route if they are required to display a badge e.g: `:packageName` and should be lower camelCase.
- Parameters should always be part of the route if they are required to display a badge e.g: `:packageName` and should be lower camelCase.
- `routeEnum` support: when a service defines a finite set of allowed values for a parameter, the service can declare `static routeEnum = [...]`. In that case the first named parameter in the route `pattern` is treated as the enum to validate against `routeEnum`.
- Common optional params like, `:branch` or `:tag` should also be passed as part of the route.
- Query string parameters should be used when:
- The parameter is related to formatting. e.g: `/appveyor/tests/:user/:repo?compact_message`.
Expand Down
3 changes: 2 additions & 1 deletion services/gem/gem-downloads.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const versionSchema = Joi.array()

export default class GemDownloads extends BaseJsonService {
static category = 'downloads'
static route = { base: 'gem', pattern: ':variant(dt|dtv|dv)/:gem/:version?' }
static route = { base: 'gem', pattern: ':variant/:gem/:version?' }
static routeEnum = ['dt', 'dtv', 'dv']
static openApi = {
'/gem/dt/{gem}': {
get: {
Expand Down
3 changes: 2 additions & 1 deletion services/greasyfork/greasyfork-downloads.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import BaseGreasyForkService from './greasyfork-base.js'

export default class GreasyForkInstalls extends BaseGreasyForkService {
static category = 'downloads'
static route = { base: 'greasyfork', pattern: ':variant(dt|dd)/:scriptId' }
static route = { base: 'greasyfork', pattern: ':variant/:scriptId' }
static routeEnum = ['dt', 'dd']

static openApi = {
'/greasyfork/{variant}/{scriptId}': {
Expand Down
Loading
Loading