From e8157100b8acfb3d9ac5cb0edb20a21de70a978a Mon Sep 17 00:00:00 2001 From: chris48s <chris48s@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:14:18 +0100 Subject: [PATCH] migrate `examples` to `openApi` part 11: enums; affects [codefactor conda depfu homebrew jsdelivr reddit sourceforge testspace vaadin github] (#9437) * WIP enums * WIP moar enums * add a helper function for extracting enum from route pattern * add enum schemas to services * review and improve service names * convert some more services with enums * review and improve service names * fix issue/pull request detail --- core/base-service/base.js | 21 +++++++ core/base-service/base.spec.js | 41 +++++++++++++ core/base-service/openapi.js | 9 ++- .../codefactor/codefactor-grade.service.js | 56 ++++++++++++++---- services/conda/conda-downloads.service.js | 29 ++++++--- services/conda/conda-platform.service.js | 32 ++++++---- services/depfu/depfu.service.js | 33 +++++++---- .../github/github-contributors.service.js | 32 ++++++---- .../github/github-issue-detail.service.js | 59 ++++++++++--------- .../github/github-milestone-detail.service.js | 41 ++++++++----- services/github/github-milestone.service.js | 36 ++++++----- .../homebrew/homebrew-downloads.service.js | 25 +++++--- .../jsdelivr/jsdelivr-hits-github.service.js | 30 +++++++--- services/reddit/user-karma.service.js | 28 +++++---- .../sourceforge-open-tickets.service.js | 26 +++++--- .../testspace/testspace-test-count.service.js | 38 ++++++++---- .../vaadin-directory-rating.service.js | 26 +++++--- 17 files changed, 399 insertions(+), 163 deletions(-) diff --git a/core/base-service/base.js b/core/base-service/base.js index 96efddbf92..4f6d3da09e 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -21,6 +21,7 @@ import { } from './errors.js' import { validateExample, transformExample } from './examples.js' import { fetch } from './got.js' +import { getEnum } from './openapi.js' import { makeFullUrl, assertValidRoute, @@ -102,6 +103,26 @@ class BaseService { throw new Error(`Route not defined for ${this.name}`) } + /** + * Extract an array of allowed values from this service's route pattern + * for a given route parameter + * + * @param {string} param The name of a param in this service's route pattern + * @returns {string[]} Array of allowed values for this param + */ + static getEnum(param) { + if (!('pattern' in this.route)) { + throw new Error('getEnum() requires route to have a .pattern property') + } + const enumeration = getEnum(this.route.pattern, param) + if (!Array.isArray(enumeration)) { + throw new Error( + `Could not extract enum for param ${param} from pattern ${this.route.pattern}`, + ) + } + return enumeration + } + /** * Configuration for the authentication helper that prepares credentials * for upstream requests. diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index 8f63654679..dbd096e600 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -539,6 +539,7 @@ describe('BaseService', function () { ).to.not.contain('service_response_bytes_bucket') }) }) + describe('auth', function () { class AuthService extends DummyService { static auth = { @@ -592,4 +593,44 @@ describe('BaseService', function () { }) }) }) + + describe('getEnum', function () { + class EnumService extends DummyService { + static route = { + base: 'foo', + pattern: ':namedParamA/:namedParamB(this|that)', + queryParamSchema, + } + } + + it('returns an array of allowed values', async function () { + expect(EnumService.getEnum('namedParamB')).to.deep.equal(['this', 'that']) + }) + + it('throws if param name is invalid', async function () { + expect(() => EnumService.getEnum('notAValidParam')).to.throw( + 'Could not extract enum for param notAValidParam from pattern :namedParamA/:namedParamB(this|that)', + ) + }) + + it('throws if param name is not an enum', async function () { + expect(() => EnumService.getEnum('namedParamA')).to.throw( + 'Could not extract enum for param namedParamA from pattern :namedParamA/:namedParamB(this|that)', + ) + }) + + it('throws if route does not have a pattern', async function () { + class FormatService extends DummyService { + static route = { + base: 'foo', + format: '([^/]+?)', + queryParamSchema, + } + } + + expect(() => FormatService.getEnum('notAValidParam')).to.throw( + 'getEnum() requires route to have a .pattern property', + ) + }) + }) }) diff --git a/core/base-service/openapi.js b/core/base-service/openapi.js index 92847b71f5..a5399a328c 100644 --- a/core/base-service/openapi.js +++ b/core/base-service/openapi.js @@ -466,4 +466,11 @@ function queryParams(...params) { * @property {boolean} allowEmptyValue If true, allows the ability to pass an empty value to this parameter */ -export { category2openapi, pathParam, pathParams, queryParam, queryParams } +export { + category2openapi, + getEnum, + pathParam, + pathParams, + queryParam, + queryParams, +} diff --git a/services/codefactor/codefactor-grade.service.js b/services/codefactor/codefactor-grade.service.js index 0752654565..26ed28208b 100644 --- a/services/codefactor/codefactor-grade.service.js +++ b/services/codefactor/codefactor-grade.service.js @@ -1,5 +1,5 @@ import Joi from 'joi' -import { BaseSvgScrapingService } from '../index.js' +import { BaseSvgScrapingService, pathParams } from '../index.js' import { isValidGrade, gradeColor } from './codefactor-helpers.js' const schema = Joi.object({ @@ -13,18 +13,52 @@ export default class CodeFactorGrade extends BaseSvgScrapingService { pattern: ':vcsType(github|bitbucket)/:user/:repo/:branch*', } - static examples = [ - { - title: 'CodeFactor Grade', - namedParams: { - vcsType: 'github', - user: 'microsoft', - repo: 'powertoys', - branch: 'main', + static openApi = { + '/codefactor/grade/{vcsType}/{user}/{repo}/{branch}': { + get: { + summary: 'CodeFactor Grade (with branch)', + parameters: pathParams( + { + name: 'vcsType', + example: 'github', + schema: { type: 'string', enum: this.getEnum('vcsType') }, + }, + { + name: 'user', + example: 'microsoft', + }, + { + name: 'repo', + example: 'powertoys', + }, + { + name: 'branch', + example: 'main', + }, + ), }, - staticPreview: this.render({ grade: 'B+' }), }, - ] + '/codefactor/grade/{vcsType}/{user}/{repo}': { + get: { + summary: 'CodeFactor Grade', + parameters: pathParams( + { + name: 'vcsType', + example: 'github', + schema: { type: 'string', enum: this.getEnum('vcsType') }, + }, + { + name: 'user', + example: 'microsoft', + }, + { + name: 'repo', + example: 'powertoys', + }, + ), + }, + }, + } static defaultBadgeData = { label: 'code quality' } diff --git a/services/conda/conda-downloads.service.js b/services/conda/conda-downloads.service.js index a08152ed42..85b6e6b897 100644 --- a/services/conda/conda-downloads.service.js +++ b/services/conda/conda-downloads.service.js @@ -1,3 +1,4 @@ +import { pathParams } from '../index.js' import { renderDownloadsBadge } from '../downloads.js' import BaseCondaService from './conda-base.js' @@ -5,14 +6,28 @@ export default class CondaDownloads extends BaseCondaService { static category = 'downloads' static route = { base: 'conda', pattern: ':variant(d|dn)/:channel/:pkg' } - static examples = [ - { - title: 'Conda', - namedParams: { channel: 'conda-forge', package: 'python' }, - pattern: 'dn/:channel/:package', - staticPreview: this.render({ variant: 'dn', downloads: 5000000 }), + static openApi = { + '/conda/{variant}/{channel}/{package}': { + get: { + summary: 'Conda Downloads', + parameters: pathParams( + { + name: 'variant', + example: 'dn', + schema: { type: 'string', enum: this.getEnum('variant') }, + }, + { + name: 'channel', + example: 'conda-forge', + }, + { + name: 'package', + example: 'python', + }, + ), + }, }, - ] + } static render({ variant, downloads }) { const labelOverride = variant === 'dn' ? 'downloads' : 'conda|downloads' diff --git a/services/conda/conda-platform.service.js b/services/conda/conda-platform.service.js index fdf7cc13aa..e16a8399a4 100644 --- a/services/conda/conda-platform.service.js +++ b/services/conda/conda-platform.service.js @@ -1,20 +1,32 @@ +import { pathParams } from '../index.js' import BaseCondaService from './conda-base.js' export default class CondaPlatform extends BaseCondaService { static category = 'platform-support' static route = { base: 'conda', pattern: ':variant(p|pn)/:channel/:pkg' } - static examples = [ - { - title: 'Conda', - namedParams: { channel: 'conda-forge', package: 'python' }, - pattern: 'pn/:channel/:package', - staticPreview: this.render({ - variant: 'pn', - platforms: ['linux-64', 'win-32', 'osx-64', 'win-64'], - }), + static openApi = { + '/conda/{variant}/{channel}/{package}': { + get: { + summary: 'Conda Platform', + parameters: pathParams( + { + name: 'variant', + example: 'pn', + schema: { type: 'string', enum: this.getEnum('variant') }, + }, + { + name: 'channel', + example: 'conda-forge', + }, + { + name: 'package', + example: 'python', + }, + ), + }, }, - ] + } static render({ variant, platforms }) { return { diff --git a/services/depfu/depfu.service.js b/services/depfu/depfu.service.js index fd26a9e429..a1c0dbc2ee 100644 --- a/services/depfu/depfu.service.js +++ b/services/depfu/depfu.service.js @@ -1,5 +1,10 @@ import Joi from 'joi' -import { BaseJsonService, InvalidParameter, redirector } from '../index.js' +import { + BaseJsonService, + InvalidParameter, + redirector, + pathParams, +} from '../index.js' const depfuSchema = Joi.object({ text: Joi.string().required(), @@ -13,16 +18,24 @@ class Depfu extends BaseJsonService { pattern: ':vcsType(github|gitlab)/:project+', } - static examples = [ - { - title: 'Depfu', - namedParams: { vcsType: 'github', project: 'depfu/example-ruby' }, - staticPreview: this.render({ - text: 'recent', - colorscheme: 'brightgreen', - }), + static openApi = { + '/depfu/dependencies/{vcsType}/{project}': { + get: { + summary: 'Depfu', + parameters: pathParams( + { + name: 'vcsType', + example: 'github', + schema: { type: 'string', enum: this.getEnum('vcsType') }, + }, + { + name: 'project', + example: 'depfu/example-ruby', + }, + ), + }, }, - ] + } static defaultBadgeData = { label: 'dependencies' } diff --git a/services/github/github-contributors.service.js b/services/github/github-contributors.service.js index 72162af27a..23d07dfe48 100644 --- a/services/github/github-contributors.service.js +++ b/services/github/github-contributors.service.js @@ -1,5 +1,6 @@ import Joi from 'joi' import parseLinkHeader from 'parse-link-header' +import { pathParams } from '../index.js' import { renderContributorBadge } from '../contributor-count.js' import { GithubAuthV3Service } from './github-auth-service.js' import { documentation, httpErrorsFor } from './github-helpers.js' @@ -14,18 +15,29 @@ export default class GithubContributors extends GithubAuthV3Service { pattern: ':variant(contributors|contributors-anon)/:user/:repo', } - static examples = [ - { - title: 'GitHub contributors', - namedParams: { - variant: 'contributors', - user: 'cdnjs', - repo: 'cdnjs', + static openApi = { + '/github/{variant}/{user}/{repo}': { + get: { + summary: 'GitHub contributors', + description: documentation, + parameters: pathParams( + { + name: 'variant', + example: 'contributors', + schema: { type: 'string', enum: this.getEnum('variant') }, + }, + { + name: 'user', + example: 'cdnjs', + }, + { + name: 'repo', + example: 'cdnjs', + }, + ), }, - staticPreview: this.render({ contributorCount: 397 }), - documentation, }, - ] + } static defaultBadgeData = { label: 'contributors' } diff --git a/services/github/github-issue-detail.service.js b/services/github/github-issue-detail.service.js index c391c15bb8..f6fb5039ef 100644 --- a/services/github/github-issue-detail.service.js +++ b/services/github/github-issue-detail.service.js @@ -2,7 +2,7 @@ import Joi from 'joi' import { nonNegativeInteger } from '../validators.js' import { formatDate, metric } from '../text-formatters.js' import { age } from '../color-formatters.js' -import { InvalidResponse } from '../index.js' +import { InvalidResponse, pathParams } from '../index.js' import { GithubAuthV3Service } from './github-auth-service.js' import { documentation, @@ -179,35 +179,38 @@ export default class GithubIssueDetail extends GithubAuthV3Service { ':issueKind(issues|pulls)/detail/:property(state|title|author|label|comments|age|last-update|milestone)/:user/:repo/:number([0-9]+)', } - static examples = [ - { - title: 'GitHub issue/pull request detail', - namedParams: { - issueKind: 'issues', - property: 'state', - user: 'badges', - repo: 'shields', - number: '979', + static openApi = { + '/github/{issueKind}/detail/{property}/{user}/{repo}/{number}': { + get: { + summary: 'GitHub issue/pull request detail', + description: documentation, + parameters: pathParams( + { + name: 'issueKind', + example: 'issues', + schema: { type: 'string', enum: this.getEnum('issueKind') }, + }, + { + name: 'property', + example: 'state', + schema: { type: 'string', enum: this.getEnum('property') }, + }, + { + name: 'user', + example: 'badges', + }, + { + name: 'repo', + example: 'shields', + }, + { + name: 'number', + example: '979', + }, + ), }, - staticPreview: this.render({ - property: 'state', - value: { state: 'closed' }, - isPR: false, - number: '979', - }), - keywords: [ - 'state', - 'title', - 'author', - 'label', - 'comments', - 'age', - 'last update', - 'milestone', - ], - documentation, }, - ] + } static defaultBadgeData = { label: 'issue/pull request', diff --git a/services/github/github-milestone-detail.service.js b/services/github/github-milestone-detail.service.js index fe37ffb1f8..98be914dad 100644 --- a/services/github/github-milestone-detail.service.js +++ b/services/github/github-milestone-detail.service.js @@ -1,4 +1,5 @@ import Joi from 'joi' +import { pathParams } from '../index.js' import { metric } from '../text-formatters.js' import { nonNegativeInteger } from '../validators.js' import { GithubAuthV3Service } from './github-auth-service.js' @@ -18,23 +19,33 @@ export default class GithubMilestoneDetail extends GithubAuthV3Service { ':variant(issues-closed|issues-open|issues-total|progress|progress-percent)/:user/:repo/:number([0-9]+)', } - static examples = [ - { - title: 'GitHub milestone', - namedParams: { - variant: 'issues-open', - user: 'badges', - repo: 'shields', - number: '1', + static openApi = { + '/github/milestones/{variant}/{user}/{repo}/{number}': { + get: { + summary: 'GitHub milestone details', + description: documentation, + parameters: pathParams( + { + name: 'variant', + example: 'issues-open', + schema: { type: 'string', enum: this.getEnum('variant') }, + }, + { + name: 'user', + example: 'badges', + }, + { + name: 'repo', + example: 'shields', + }, + { + name: 'number', + example: '1', + }, + ), }, - staticPreview: { - label: 'milestone issues', - message: '17/22', - color: 'blue', - }, - documentation, }, - ] + } static defaultBadgeData = { label: 'milestones', color: 'informational' } diff --git a/services/github/github-milestone.service.js b/services/github/github-milestone.service.js index 973bb6649e..458071dcf0 100644 --- a/services/github/github-milestone.service.js +++ b/services/github/github-milestone.service.js @@ -1,4 +1,5 @@ import Joi from 'joi' +import { pathParams } from '../index.js' import { metric } from '../text-formatters.js' import { GithubAuthV3Service } from './github-auth-service.js' import { documentation, httpErrorsFor } from './github-helpers.js' @@ -18,22 +19,29 @@ export default class GithubMilestone extends GithubAuthV3Service { pattern: ':variant(open|closed|all)/:user/:repo', } - static examples = [ - { - title: 'GitHub milestones', - namedParams: { - user: 'badges', - repo: 'shields', - variant: 'open', + static openApi = { + '/github/milestones/{variant}/{user}/{repo}': { + get: { + summary: 'GitHub number of milestones', + description: documentation, + parameters: pathParams( + { + name: 'variant', + example: 'open', + schema: { type: 'string', enum: this.getEnum('variant') }, + }, + { + name: 'user', + example: 'badges', + }, + { + name: 'repo', + example: 'shields', + }, + ), }, - staticPreview: { - label: 'milestones', - message: '2', - color: 'red', - }, - documentation, }, - ] + } static defaultBadgeData = { label: 'milestones', diff --git a/services/homebrew/homebrew-downloads.service.js b/services/homebrew/homebrew-downloads.service.js index e7989e48b3..6dfa10d1a3 100644 --- a/services/homebrew/homebrew-downloads.service.js +++ b/services/homebrew/homebrew-downloads.service.js @@ -1,6 +1,6 @@ import Joi from 'joi' import { renderDownloadsBadge } from '../downloads.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' import { nonNegativeInteger } from '../validators.js' function getSchema({ formula }) { @@ -38,13 +38,24 @@ export default class HomebrewDownloads extends BaseJsonService { pattern: 'installs/:interval(dm|dq|dy)/:formula', } - static examples = [ - { - title: 'homebrew downloads', - namedParams: { interval: 'dm', formula: 'cake' }, - staticPreview: renderDownloadsBadge({ interval: 'month', downloads: 93 }), + static openApi = { + '/homebrew/installs/{interval}/{formula}': { + get: { + summary: 'homebrew downloads', + parameters: pathParams( + { + name: 'interval', + example: 'dm', + schema: { type: 'string', enum: this.getEnum('interval') }, + }, + { + name: 'formula', + example: 'cake', + }, + ), + }, }, - ] + } static defaultBadgeData = { label: 'downloads' } diff --git a/services/jsdelivr/jsdelivr-hits-github.service.js b/services/jsdelivr/jsdelivr-hits-github.service.js index 8d760b8992..96ddc830f3 100644 --- a/services/jsdelivr/jsdelivr-hits-github.service.js +++ b/services/jsdelivr/jsdelivr-hits-github.service.js @@ -1,3 +1,4 @@ +import { pathParams } from '../index.js' import { schema, periodMap, BaseJsDelivrService } from './jsdelivr-base.js' export default class JsDelivrHitsGitHub extends BaseJsDelivrService { @@ -6,17 +7,28 @@ export default class JsDelivrHitsGitHub extends BaseJsDelivrService { pattern: ':period(hd|hw|hm|hy)/:user/:repo', } - static examples = [ - { - title: 'jsDelivr hits (GitHub)', - namedParams: { - period: 'hm', - user: 'jquery', - repo: 'jquery', + static openApi = { + '/jsdelivr/gh/{period}/{user}/{repo}': { + get: { + summary: 'jsDelivr hits (GitHub)', + parameters: pathParams( + { + name: 'period', + example: 'hm', + schema: { type: 'string', enum: this.getEnum('period') }, + }, + { + name: 'user', + example: 'jquery', + }, + { + name: 'repo', + example: 'jquery', + }, + ), }, - staticPreview: this.render({ period: 'hm', hits: 9809876 }), }, - ] + } async fetch({ period, user, repo }) { return this._requestJson({ diff --git a/services/reddit/user-karma.service.js b/services/reddit/user-karma.service.js index 0ec007edc3..ca8b489d34 100644 --- a/services/reddit/user-karma.service.js +++ b/services/reddit/user-karma.service.js @@ -1,7 +1,7 @@ import Joi from 'joi' import { anyInteger } from '../validators.js' import { metric } from '../text-formatters.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' const schema = Joi.object({ data: Joi.object({ @@ -18,18 +18,24 @@ export default class RedditUserKarma extends BaseJsonService { pattern: ':variant(link|comment|combined)/:user', } - static examples = [ - { - title: 'Reddit User Karma', - namedParams: { variant: 'combined', user: 'example' }, - staticPreview: { - label: 'combined karma', - message: 56, - color: 'brightgreen', - style: 'social', + static openApi = { + '/reddit/user-karma/{variant}/{user}': { + get: { + summary: 'Reddit User Karma', + parameters: pathParams( + { + name: 'variant', + example: 'combined', + schema: { type: 'string', enum: this.getEnum('variant') }, + }, + { + name: 'user', + example: 'example', + }, + ), }, }, - ] + } static _cacheLength = 7200 diff --git a/services/sourceforge/sourceforge-open-tickets.service.js b/services/sourceforge/sourceforge-open-tickets.service.js index d6fa6b2c26..7c1cf76ae3 100644 --- a/services/sourceforge/sourceforge-open-tickets.service.js +++ b/services/sourceforge/sourceforge-open-tickets.service.js @@ -1,7 +1,7 @@ import Joi from 'joi' import { metric } from '../text-formatters.js' import { nonNegativeInteger } from '../validators.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' const schema = Joi.object({ count: nonNegativeInteger.required(), @@ -15,16 +15,24 @@ export default class SourceforgeOpenTickets extends BaseJsonService { pattern: ':project/:type(bugs|feature-requests)', } - static examples = [ - { - title: 'Sourceforge Open Tickets', - namedParams: { - type: 'bugs', - project: 'sevenzip', + static openApi = { + '/sourceforge/open-tickets/{project}/{type}': { + get: { + summary: 'Sourceforge Open Tickets', + parameters: pathParams( + { + name: 'project', + example: 'sevenzip', + }, + { + name: 'type', + example: 'bugs', + schema: { type: 'string', enum: this.getEnum('type') }, + }, + ), }, - staticPreview: this.render({ count: 1338 }), }, - ] + } static defaultBadgeData = { label: 'open tickets', diff --git a/services/testspace/testspace-test-count.service.js b/services/testspace/testspace-test-count.service.js index 09ec0e6814..aa670fc3be 100644 --- a/services/testspace/testspace-test-count.service.js +++ b/services/testspace/testspace-test-count.service.js @@ -1,3 +1,4 @@ +import { pathParams } from '../index.js' import { metric as metricCount } from '../text-formatters.js' import TestspaceBase from './testspace-base.js' @@ -8,21 +9,32 @@ export default class TestspaceTestCount extends TestspaceBase { ':metric(total|passed|failed|skipped|errored|untested)/:org/:project/:space+', } - static examples = [ - { - title: 'Testspace tests', - namedParams: { - metric: 'passed', - org: 'swellaby', - project: 'swellaby:testspace-sample', - space: 'main', + static openApi = { + '/testspace/{metric}/{org}/{project}/{space}': { + get: { + summary: 'Testspace tests', + parameters: pathParams( + { + name: 'metric', + example: 'passed', + schema: { type: 'string', enum: this.getEnum('metric') }, + }, + { + name: 'org', + example: 'swellaby', + }, + { + name: 'project', + example: 'swellaby:testspace-sample', + }, + { + name: 'space', + example: 'main', + }, + ), }, - staticPreview: this.render({ - metric: 'passed', - value: 31, - }), }, - ] + } static render({ value, metric }) { let color = 'informational' diff --git a/services/vaadin-directory/vaadin-directory-rating.service.js b/services/vaadin-directory/vaadin-directory-rating.service.js index c07ddf091b..846e741655 100644 --- a/services/vaadin-directory/vaadin-directory-rating.service.js +++ b/services/vaadin-directory/vaadin-directory-rating.service.js @@ -1,3 +1,4 @@ +import { pathParams } from '../index.js' import { starRating } from '../text-formatters.js' import { floorCount as floorCountColor } from '../color-formatters.js' import { BaseVaadinDirectoryService } from './vaadin-directory-base.js' @@ -10,15 +11,24 @@ export default class VaadinDirectoryRating extends BaseVaadinDirectoryService { pattern: ':format(star|stars|rating)/:packageName', } - static examples = [ - { - title: 'Vaadin Directory', - pattern: ':format(stars|rating)/:packageName', - namedParams: { format: 'rating', packageName: 'vaadinvaadin-grid' }, - staticPreview: this.render({ format: 'rating', score: 4.75 }), - keywords: ['vaadin-directory'], + static openApi = { + '/vaadin-directory/{format}/{packageName}': { + get: { + summary: 'Vaadin Directory Rating', + parameters: pathParams( + { + name: 'format', + example: 'rating', + schema: { type: 'string', enum: this.getEnum('format') }, + }, + { + name: 'packageName', + example: 'vaadinvaadin-grid', + }, + ), + }, }, - ] + } static defaultBadgeData = { label: 'rating', -- GitLab