From 6d517d2bf6e3898e4b80e9e66e9cfd19adea698e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 14:06:04 +0100 Subject: [PATCH 01/17] Change of schema --- src/lib/openapi/spec/me-schema.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/openapi/spec/me-schema.ts b/src/lib/openapi/spec/me-schema.ts index 0f811b056b..92b974d058 100644 --- a/src/lib/openapi/spec/me-schema.ts +++ b/src/lib/openapi/spec/me-schema.ts @@ -34,6 +34,10 @@ export const meSchema = { type: 'boolean', }, }, + something: { + description: 'Something something', + type: 'string', + }, }, components: { schemas: { From 9bcad092a59a6f9edf162088b51e4be10ff117e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 14:13:22 +0100 Subject: [PATCH 02/17] Revert "Change of schema" This reverts commit 84b127725eecc8bedf31387c28350f3c392a5ad5. --- src/lib/openapi/spec/me-schema.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib/openapi/spec/me-schema.ts b/src/lib/openapi/spec/me-schema.ts index 92b974d058..0f811b056b 100644 --- a/src/lib/openapi/spec/me-schema.ts +++ b/src/lib/openapi/spec/me-schema.ts @@ -34,10 +34,6 @@ export const meSchema = { type: 'boolean', }, }, - something: { - description: 'Something something', - type: 'string', - }, }, components: { schemas: { From 51db6239968362e2e6a7b08a4301cd5d9447c6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 16:29:06 +0100 Subject: [PATCH 03/17] feat: add an opinionated stability and release version to APIs --- src/lib/openapi/util/api-operation.ts | 50 +++++++++ src/lib/services/openapi-service.ts | 81 ++++++++++++-- website/docs/contributing/ADRs/ADRs.md | 1 + .../ADRs/back-end/api-version-tracking.md | 102 ++++++++++++++++++ 4 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 website/docs/contributing/ADRs/back-end/api-version-tracking.md diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index bb6f47ddea..764a1c6372 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -1,5 +1,42 @@ import type { OpenAPIV3 } from 'openapi-types'; import type { OpenApiTag } from './openapi-tags.js'; +import semver from 'semver'; + +/** + * Calculate stability level based on comparing release and current versions. + * - Alpha: release version is ahead of current (not yet released) + * - Beta: current is 1-2 minor versions ahead of release version + * - Stable: current is more than 2 minor versions ahead of release version + */ +export function calculateStability( + releaseVersion: string, + currentVersion: string, +): 'alpha' | 'beta' | 'stable' { + const release = semver.coerce(releaseVersion); + const current = semver.coerce(currentVersion); + + if (!release || !current) { + return 'stable'; // Default to stable if versions can't be parsed + } + + // If release is ahead of current, it's alpha (not yet released) + if (semver.gt(release, current)) { + return 'alpha'; + } + + // Calculate minor version difference + // For same major: just subtract minors + // For different major: consider major difference as many minors + const majorDiff = current.major - release.major; + const minorDiff = current.minor - release.minor; + const totalMinorDiff = majorDiff * 1000 + minorDiff; // Major version jump = 1000 minors (effectively always stable) + + if (totalMinorDiff <= 2) { + return 'beta'; + } + + return 'stable'; +} type DeprecatedOpenAPITag = // Deprecated tag names. Please use a tag from the OpenAPITag type instead. @@ -14,6 +51,19 @@ export interface ApiOperation extends Omit { operationId: string; tags: [Tag]; + /** @deprecated use releaseVersion instead */ beta?: boolean; + /** + * The version when this API was introduced or last significantly changed. + * Used to automatically calculate stability: + * - Alpha: release version is ahead of current version (not yet released) + * - Beta: current version is 1-2 minor versions ahead of release version + * - Stable: current version is more than 2 minor versions ahead of release version + * + * When developing a new API, set this to your best estimate of when it will be released. + * All APIs naturally progress through the beta -> stable lifecycle as versions advance. + * @default '7.0.0' + */ + releaseVersion?: string; enterpriseOnly?: boolean; } diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index d813a6fb61..c6943a7bf4 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -1,4 +1,5 @@ import openapi, { type IExpressOpenApi } from '@wesleytodd/openapi'; +import generateDocument from '@wesleytodd/openapi/lib/generate-doc.js'; import type { Express, RequestHandler, Response } from 'express'; import type { IUnleashConfig } from '../types/option.js'; import { @@ -7,11 +8,16 @@ import { removeJsonSchemaProps, type SchemaId, } from '../openapi/index.js'; -import type { ApiOperation } from '../openapi/util/api-operation.js'; +import { + type ApiOperation, + calculateStability, +} from '../openapi/util/api-operation.js'; import type { Logger } from '../logger.js'; import { validateSchema } from '../openapi/validate.js'; import type { IFlagResolver } from '../types/index.js'; +import version from '../util/version.js'; +const defaultReleaseVersion = '7.0.0'; export class OpenApiService { private readonly config: IUnleashConfig; @@ -19,6 +25,8 @@ export class OpenApiService { private readonly api: IExpressOpenApi; + private readonly isDevelopment = process.env.NODE_ENV === 'development'; + private flagResolver: IFlagResolver; constructor(config: IUnleashConfig) { @@ -38,22 +46,31 @@ export class OpenApiService { } validPath(op: ApiOperation): RequestHandler { - const { beta, enterpriseOnly, ...rest } = op; + const { + releaseVersion = defaultReleaseVersion, + enterpriseOnly, + ...rest + } = op; const { baseUriPath = '' } = this.config.server ?? {}; const openapiStaticAssets = `${baseUriPath}/openapi-static`; - const betaBadge = beta - ? `![Beta](${openapiStaticAssets}/Beta.svg) This is a beta endpoint and it may change or be removed in the future. + const stability = calculateStability(releaseVersion, version); + const summaryWithStability = + stability !== 'stable' && rest.summary + ? `[${stability.toUpperCase()}] ${rest.summary}` + : rest.summary; + const stabilityBadge = + stability !== 'stable' + ? `**[${stability.toUpperCase()}]** This is a ${stability} endpoint and it may change or be removed in the future. ` - : ''; + : ''; const enterpriseBadge = enterpriseOnly ? `![Unleash Enterprise](${openapiStaticAssets}/Enterprise.svg) **Enterprise feature** ` : ''; - const failDeprecated = - (op.deprecated ?? false) && process.env.NODE_ENV === 'development'; + const failDeprecated = (op.deprecated ?? false) && this.isDevelopment; if (failDeprecated) { return (req, res, _next) => { @@ -67,8 +84,13 @@ export class OpenApiService { } return this.api.validPath({ ...rest, + summary: summaryWithStability, + 'x-stability-level': stability, + ...(releaseVersion !== defaultReleaseVersion + ? { 'x-release-version': releaseVersion } + : {}), description: - `${enterpriseBadge}${betaBadge}${op.description}`.replaceAll( + `${enterpriseBadge}${stabilityBadge}${op.description}`.replaceAll( /\n\s*/g, '\n\n', ), @@ -76,10 +98,53 @@ export class OpenApiService { } useDocs(app: Express): void { + // Serve a filtered OpenAPI document that hides alpha endpoints from Swagger UI. + app.get(`${this.docsPath()}.json`, (req, res, next) => { + try { + const doc = generateDocument( + (this.api as any).document, + req.app._router || req.app.router, + this.config.server.baseUriPath, + ); + res.json( + this.isDevelopment ? doc : this.removeAlphaOperations(doc), + ); + } catch (error) { + next(error); + } + }); + app.use(this.api); app.use(this.docsPath(), this.api.swaggerui()); } + // Remove operations explicitly marked as alpha to keep them out of the rendered docs. + // Paths with no remaining operations are dropped as well. + private removeAlphaOperations(doc: any): any { + if (!doc?.paths) { + return doc; + } + + const filteredPaths = Object.fromEntries( + Object.entries(doc.paths) + .map(([path, methods]) => { + const nonAlphaMethods = Object.fromEntries( + Object.entries( + methods as Record, + ).filter( + ([, operation]) => + (operation as any)?.['x-stability-level'] !== + 'alpha', + ), + ); + return [path, nonAlphaMethods]; + }) + .filter(([, methods]) => Object.keys(methods).length > 0), + ); + + return { ...doc, paths: filteredPaths }; + } + docsPath(): string { const { baseUriPath = '' } = this.config.server ?? {}; return `${baseUriPath}/docs/openapi`; diff --git a/website/docs/contributing/ADRs/ADRs.md b/website/docs/contributing/ADRs/ADRs.md index 2471b2eea3..7aaeeb3205 100644 --- a/website/docs/contributing/ADRs/ADRs.md +++ b/website/docs/contributing/ADRs/ADRs.md @@ -30,6 +30,7 @@ We are in the process of defining ADRs for the back end. At the time of writing * [Write model vs Read models](/contributing/ADRs/back-end/write-model-vs-read-models) * [Frontend API Design](/contributing/ADRs/back-end/frontend-api-design) * [Correct type dependencies](/contributing/ADRs/back-end/correct-type-dependencies) +* [API Version Tracking and Stability Lifecycle](/contributing/ADRs/back-end/api-version-tracking) ## Front-end ADRs diff --git a/website/docs/contributing/ADRs/back-end/api-version-tracking.md b/website/docs/contributing/ADRs/back-end/api-version-tracking.md new file mode 100644 index 0000000000..e43f04ec40 --- /dev/null +++ b/website/docs/contributing/ADRs/back-end/api-version-tracking.md @@ -0,0 +1,102 @@ +--- +title: "ADR: API Version Tracking and Stability Lifecycle" +--- + +## Background + +Our OpenAPI documentation lacked a systematic way to communicate API maturity and stability to users. We had no automated mechanism to: +- Track when APIs were introduced +- Communicate stability levels (alpha, beta, stable) to API consumers +- Filter unreleased APIs from public documentation +- Provide historical context about API evolution + +Manually maintaining stability levels was error-prone and often forgotten. APIs would remain marked as "beta" indefinitely, or lack any stability indicator altogether. This created confusion for both internal developers and external API consumers about which endpoints were production-ready. + +Additionally, we wanted to ship and test new APIs with select customers before formally documenting them, but had no way to hide alpha/unreleased features from public docs while keeping them functionally available. + +## Decision + +We've implemented an automated API stability tracking system based on semantic versioning. Each endpoint declares a `releaseVersion` field (the version when it was introduced or last significantly changed). The system automatically calculates stability levels based on version comparison: + +### Stability Calculation Heuristic + +- **Alpha** πŸ”΄: Release version is ahead of current version (not yet released) +- **Beta** 🟑: Current version is 1-2 minor versions ahead of release version +- **Stable** 🟒: Current version is 3+ minor versions ahead of release version + +**Example:** +```typescript +// Current Unleash version: 7.4.0 + +openApiService.validPath({ + tags: ['Features'], + summary: 'Create feature flag', + releaseVersion: '7.5.0', // β†’ Alpha (not yet released) + operationId: 'createFeature', + // ... +}) + +openApiService.validPath({ + tags: ['Projects'], + summary: 'List projects', + releaseVersion: '7.3.0', // β†’ Beta (1 minor behind) + operationId: 'getProjects', + // ... +}) + +openApiService.validPath({ + tags: ['Users'], + summary: 'Get user info', + releaseVersion: '7.1.0', // β†’ Stable (3+ minors behind) + operationId: 'getUserInfo', + // ... +}) +``` + +### Implementation + +1. **ApiOperation Interface**: Added `releaseVersion?: string` field (defaults to `'7.0.0'` so most/all APIs are stable now) +2. **Stability Calculation**: `calculateStability()` function compares release version with current Unleash version +3. **OpenAPI Extensions**: Automatically adds `x-stability-level` and `x-release-version` (only if defined) to OpenAPI spec +4. **Swagger UI Integration**: + - Alpha endpoints are hidden from public docs in production + - Visible in development mode (`NODE_ENV=development`) + - Stability prefix added to endpoint summaries (e.g., `[BETA] List projects`) + +### Alpha API Behavior + +Alpha endpoints are **automatically hidden** from the public OpenAPI docs (`/docs/openapi`) in production. This means: + +- βœ… **APIs are still fully functional** - clients can use them if they know the endpoint +- πŸ“– **Not advertised publicly** - they don't appear in the documentation portal +- πŸ” **Visible in development** - when `NODE_ENV=development`, all alpha endpoints show up for internal testing + +This gives us the best of both worlds: we can ship and test alpha APIs internally or with select customers without formally documenting them, reducing support burden and managing expectations for unstable features. + +## Consequences + +### Positive + +**Zero maintenance**: As Unleash versions progress, APIs automatically transition from alpha β†’ beta β†’ stable without manual intervention. + +**Built-in documentation**: The `releaseVersion` field serves as historical documentation. Anyone can see when an API was introduced and assess its maturity. + +**Flexible during development**: Developers estimate which version a new API will ship in. If priorities change or development takes longer, they simply update the version - it's okay to be wrong initially. + +**Selective disclosure**: Ship alpha features to production for testing with select customers without exposing them in public documentation. + +**Consistency**: Every API follows the same maturity progression, eliminating confusion about stability levels. + +### Migration Path + +1. **Immediate**: New endpoints should include `releaseVersion` +2. **Gradual**: Add `releaseVersion` to existing endpoints as they're modified +3. **Future**: AI-assisted bulk backfill from git history to document all existing APIs + +### Trade-offs + +**Version guessing required**: Developers must estimate release versions during development. This is an acceptable trade-off given the version can be updated and the benefits of automation. + +**Defaults to 7.0.0**: Endpoints without `releaseVersion` default to `'7.0.0'`, which may not be historically accurate but provides a reasonable baseline for the migration period. + +**Heuristic limitations**: The 2-minor-version threshold for betaβ†’stable is somewhat arbitrary but provides a reasonable balance between caution and API maturity progression. From e0e31a11a4102006920faa5da0cf57753a175e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 16:33:52 +0100 Subject: [PATCH 04/17] Keep using deprecated beta for a while --- src/lib/services/openapi-service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index c6943a7bf4..dd74c46fec 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -47,6 +47,7 @@ export class OpenApiService { validPath(op: ApiOperation): RequestHandler { const { + beta, releaseVersion = defaultReleaseVersion, enterpriseOnly, ...rest @@ -54,7 +55,9 @@ export class OpenApiService { const { baseUriPath = '' } = this.config.server ?? {}; const openapiStaticAssets = `${baseUriPath}/openapi-static`; - const stability = calculateStability(releaseVersion, version); + const stability = beta + ? 'beta' + : calculateStability(releaseVersion, version); const summaryWithStability = stability !== 'stable' && rest.summary ? `[${stability.toUpperCase()}] ${rest.summary}` From 2c7f1c01ae5826209f18f42eb87e8d1c5e456650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 16:37:41 +0100 Subject: [PATCH 05/17] Get version from openapi json --- src/lib/services/openapi-service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index dd74c46fec..67ab54f413 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -15,7 +15,6 @@ import { import type { Logger } from '../logger.js'; import { validateSchema } from '../openapi/validate.js'; import type { IFlagResolver } from '../types/index.js'; -import version from '../util/version.js'; const defaultReleaseVersion = '7.0.0'; export class OpenApiService { @@ -55,9 +54,11 @@ export class OpenApiService { const { baseUriPath = '' } = this.config.server ?? {}; const openapiStaticAssets = `${baseUriPath}/openapi-static`; + const currentVersion = + (this.api as any).document?.info?.version || '7.0.0'; const stability = beta ? 'beta' - : calculateStability(releaseVersion, version); + : calculateStability(releaseVersion, currentVersion); const summaryWithStability = stability !== 'stable' && rest.summary ? `[${stability.toUpperCase()}] ${rest.summary}` From b72971c9d82a9ba9887658b3106f8685213143ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 18:19:30 +0100 Subject: [PATCH 06/17] Address comments --- src/lib/openapi/util/api-operation.test.ts | 26 +++++++ src/lib/openapi/util/api-operation.ts | 4 +- src/lib/services/openapi-service.test.ts | 55 +++++++++++++++ src/lib/services/openapi-service.ts | 69 ++++++++++++------- .../ADRs/back-end/api-version-tracking.md | 4 +- 5 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 src/lib/openapi/util/api-operation.test.ts create mode 100644 src/lib/services/openapi-service.test.ts diff --git a/src/lib/openapi/util/api-operation.test.ts b/src/lib/openapi/util/api-operation.test.ts new file mode 100644 index 0000000000..c9e6e93a2c --- /dev/null +++ b/src/lib/openapi/util/api-operation.test.ts @@ -0,0 +1,26 @@ +import { calculateStability } from './api-operation.js'; + +test('calculateStability returns alpha when release is ahead of current', () => { + expect(calculateStability('7.5.0', '7.4.0')).toBe('alpha'); +}); + +test.each([ + ['7.4.0', '7.4.0'], + ['7.3.0', '7.4.0'], + ['7.2.0', '7.4.0'], +])('calculateStability returns beta for 0-2 minor versions ahead (release %s, current %s)', (releaseVersion, currentVersion) => { + expect(calculateStability(releaseVersion, currentVersion)).toBe('beta'); +}); + +test('calculateStability returns stable for 3+ minor versions ahead', () => { + expect(calculateStability('7.1.0', '7.4.0')).toBe('stable'); +}); + +test('calculateStability returns stable across major version differences', () => { + expect(calculateStability('6.5.0', '7.0.0')).toBe('stable'); +}); + +test('calculateStability defaults to stable when versions are invalid', () => { + expect(calculateStability('not-a-version', '7.4.0')).toBe('stable'); + expect(calculateStability('7.4.0', 'nope')).toBe('stable'); +}); diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index 764a1c6372..cd83d80247 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -5,7 +5,7 @@ import semver from 'semver'; /** * Calculate stability level based on comparing release and current versions. * - Alpha: release version is ahead of current (not yet released) - * - Beta: current is 1-2 minor versions ahead of release version + * - Beta: current is 0-2 minor versions ahead of release version * - Stable: current is more than 2 minor versions ahead of release version */ export function calculateStability( @@ -57,7 +57,7 @@ export interface ApiOperation * The version when this API was introduced or last significantly changed. * Used to automatically calculate stability: * - Alpha: release version is ahead of current version (not yet released) - * - Beta: current version is 1-2 minor versions ahead of release version + * - Beta: current version is 0-2 minor versions ahead of release version * - Stable: current version is more than 2 minor versions ahead of release version * * When developing a new API, set this to your best estimate of when it will be released. diff --git a/src/lib/services/openapi-service.test.ts b/src/lib/services/openapi-service.test.ts new file mode 100644 index 0000000000..29f2a75a97 --- /dev/null +++ b/src/lib/services/openapi-service.test.ts @@ -0,0 +1,55 @@ +import type { OpenAPIV3 } from 'openapi-types'; +import { OpenApiService } from './openapi-service.js'; +import { createTestConfig } from '../../test/config/test-config.js'; + +const okResponse = { '200': { description: 'ok' } }; +type OperationWithStability = OpenAPIV3.OperationObject & { + 'x-stability-level'?: string; +}; + +const buildDocument = (): OpenAPIV3.Document => ({ + openapi: '3.0.0', + info: { title: 'Test API', version: '7.4.0' }, + paths: { + '/alpha-only': { + get: { + responses: okResponse, + 'x-stability-level': 'alpha', + } as OperationWithStability, + }, + '/mixed': { + get: { + responses: okResponse, + 'x-stability-level': 'alpha', + } as OperationWithStability, + post: { + responses: okResponse, + 'x-stability-level': 'beta', + } as OperationWithStability, + }, + '/stable': { + put: { + responses: okResponse, + }, + }, + }, +}); + +test('removeAlphaOperations removes alpha operations and empty paths', () => { + const openApiService = new OpenApiService(createTestConfig()); + const removeAlphaOperations = ( + openApiService as unknown as { + removeAlphaOperations: ( + doc: OpenAPIV3.Document, + ) => OpenAPIV3.Document; + } + ).removeAlphaOperations.bind(openApiService); + + const doc = buildDocument(); + const filtered = removeAlphaOperations(doc); + + expect(filtered.paths?.['/alpha-only']).toBeUndefined(); + expect(filtered.paths?.['/mixed']?.get).toBeUndefined(); + expect(filtered.paths?.['/mixed']?.post).toBeDefined(); + expect(filtered.paths?.['/stable']).toBeDefined(); +}); diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index 67ab54f413..82f1c1d5a9 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -1,6 +1,6 @@ import openapi, { type IExpressOpenApi } from '@wesleytodd/openapi'; -import generateDocument from '@wesleytodd/openapi/lib/generate-doc.js'; import type { Express, RequestHandler, Response } from 'express'; +import type { OpenAPIV3 } from 'openapi-types'; import type { IUnleashConfig } from '../types/option.js'; import { createOpenApiSchema, @@ -17,12 +17,33 @@ import { validateSchema } from '../openapi/validate.js'; import type { IFlagResolver } from '../types/index.js'; const defaultReleaseVersion = '7.0.0'; +const getStabilityLevel = (operation: unknown): string | undefined => { + if (!operation || typeof operation !== 'object') { + return undefined; + } + + return ( + operation as OpenAPIV3.OperationObject & { + 'x-stability-level'?: string; + } + )['x-stability-level']; +}; +type OpenApiDocument = OpenAPIV3.Document; +type OpenApiMiddleware = IExpressOpenApi & { + document: OpenApiDocument; + generateDocument: ( + baseDocument: OpenApiDocument, + router?: unknown, + basePath?: string, + ) => OpenApiDocument; +}; + export class OpenApiService { private readonly config: IUnleashConfig; private readonly logger: Logger; - private readonly api: IExpressOpenApi; + private readonly api: OpenApiMiddleware; private readonly isDevelopment = process.env.NODE_ENV === 'development'; @@ -41,7 +62,7 @@ export class OpenApiService { extendRefs: true, basePath: config.server.baseUriPath, }, - ); + ) as OpenApiMiddleware; } validPath(op: ApiOperation): RequestHandler { @@ -54,8 +75,7 @@ export class OpenApiService { const { baseUriPath = '' } = this.config.server ?? {}; const openapiStaticAssets = `${baseUriPath}/openapi-static`; - const currentVersion = - (this.api as any).document?.info?.version || '7.0.0'; + const currentVersion = this.api.document?.info?.version || '7.0.0'; const stability = beta ? 'beta' : calculateStability(releaseVersion, currentVersion); @@ -105,8 +125,8 @@ export class OpenApiService { // Serve a filtered OpenAPI document that hides alpha endpoints from Swagger UI. app.get(`${this.docsPath()}.json`, (req, res, next) => { try { - const doc = generateDocument( - (this.api as any).document, + const doc = this.api.generateDocument( + this.api.document, req.app._router || req.app.router, this.config.server.baseUriPath, ); @@ -123,28 +143,27 @@ export class OpenApiService { } // Remove operations explicitly marked as alpha to keep them out of the rendered docs. - // Paths with no remaining operations are dropped as well. - private removeAlphaOperations(doc: any): any { + private removeAlphaOperations(doc: OpenApiDocument): OpenApiDocument { if (!doc?.paths) { return doc; } - const filteredPaths = Object.fromEntries( - Object.entries(doc.paths) - .map(([path, methods]) => { - const nonAlphaMethods = Object.fromEntries( - Object.entries( - methods as Record, - ).filter( - ([, operation]) => - (operation as any)?.['x-stability-level'] !== - 'alpha', - ), - ); - return [path, nonAlphaMethods]; - }) - .filter(([, methods]) => Object.keys(methods).length > 0), - ); + const filteredPaths: OpenAPIV3.PathsObject = {}; + for (const [path, methods] of Object.entries(doc.paths)) { + if (!methods) { + continue; + } + + const entries = Object.entries(methods).filter( + ([, operation]) => getStabilityLevel(operation) !== 'alpha', + ); + + if (entries.length > 0) { + filteredPaths[path] = Object.fromEntries( + entries, + ) as OpenAPIV3.PathItemObject; + } + } return { ...doc, paths: filteredPaths }; } diff --git a/website/docs/contributing/ADRs/back-end/api-version-tracking.md b/website/docs/contributing/ADRs/back-end/api-version-tracking.md index e43f04ec40..d0bb138df5 100644 --- a/website/docs/contributing/ADRs/back-end/api-version-tracking.md +++ b/website/docs/contributing/ADRs/back-end/api-version-tracking.md @@ -21,7 +21,7 @@ We've implemented an automated API stability tracking system based on semantic v ### Stability Calculation Heuristic - **Alpha** πŸ”΄: Release version is ahead of current version (not yet released) -- **Beta** 🟑: Current version is 1-2 minor versions ahead of release version +- **Beta** 🟑: Current version is 0-2 minor versions ahead of release version - **Stable** 🟒: Current version is 3+ minor versions ahead of release version **Example:** @@ -99,4 +99,4 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall **Defaults to 7.0.0**: Endpoints without `releaseVersion` default to `'7.0.0'`, which may not be historically accurate but provides a reasonable baseline for the migration period. -**Heuristic limitations**: The 2-minor-version threshold for betaβ†’stable is somewhat arbitrary but provides a reasonable balance between caution and API maturity progression. +**Heuristic limitations**: The more-than-2-minor-versions (3+ minor versions) threshold for betaβ†’stable is somewhat arbitrary but provides a reasonable balance between caution and API maturity progression. From f92f971bbd04e2b7addfbc2f424aed7cbb6df425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 9 Jan 2026 17:38:28 +0000 Subject: [PATCH 07/17] Update src/lib/openapi/util/api-operation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/openapi/util/api-operation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index cd83d80247..2352a7d123 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -58,7 +58,7 @@ export interface ApiOperation * Used to automatically calculate stability: * - Alpha: release version is ahead of current version (not yet released) * - Beta: current version is 0-2 minor versions ahead of release version - * - Stable: current version is more than 2 minor versions ahead of release version + * - Stable: current version is 3 or more minor versions ahead of release version * * When developing a new API, set this to your best estimate of when it will be released. * All APIs naturally progress through the beta -> stable lifecycle as versions advance. From 4f59bf424293921d0e81cfb5af33d4afe4f080ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 12 Jan 2026 10:46:49 +0000 Subject: [PATCH 08/17] Apply suggestions from code review --- .../docs/contributing/ADRs/back-end/api-version-tracking.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/contributing/ADRs/back-end/api-version-tracking.md b/website/docs/contributing/ADRs/back-end/api-version-tracking.md index d0bb138df5..1f3ce114ea 100644 --- a/website/docs/contributing/ADRs/back-end/api-version-tracking.md +++ b/website/docs/contributing/ADRs/back-end/api-version-tracking.md @@ -71,7 +71,7 @@ Alpha endpoints are **automatically hidden** from the public OpenAPI docs (`/doc - πŸ“– **Not advertised publicly** - they don't appear in the documentation portal - πŸ” **Visible in development** - when `NODE_ENV=development`, all alpha endpoints show up for internal testing -This gives us the best of both worlds: we can ship and test alpha APIs internally or with select customers without formally documenting them, reducing support burden and managing expectations for unstable features. +This gives us the best of both worlds: we can ship and test alpha APIs internally without formally documenting them, reducing support burden and managing expectations for unstable features. ## Consequences @@ -83,7 +83,7 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall **Flexible during development**: Developers estimate which version a new API will ship in. If priorities change or development takes longer, they simply update the version - it's okay to be wrong initially. -**Selective disclosure**: Ship alpha features to production for testing with select customers without exposing them in public documentation. +**Selective disclosure**: Ship alpha features to production for testing without exposing them to customers, until the API is ready to be moved to beta. **Consistency**: Every API follows the same maturity progression, eliminating confusion about stability levels. From 36ec1e3f2c497ee66d8f331b7c587f448d0bc43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 12 Jan 2026 15:35:30 +0100 Subject: [PATCH 09/17] doc: document edge cases with build releases and pre-releases --- src/lib/openapi/util/api-operation.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/lib/openapi/util/api-operation.test.ts b/src/lib/openapi/util/api-operation.test.ts index c9e6e93a2c..f650027d59 100644 --- a/src/lib/openapi/util/api-operation.test.ts +++ b/src/lib/openapi/util/api-operation.test.ts @@ -24,3 +24,19 @@ test('calculateStability defaults to stable when versions are invalid', () => { expect(calculateStability('not-a-version', '7.4.0')).toBe('stable'); expect(calculateStability('7.4.0', 'nope')).toBe('stable'); }); + +test.each([ + ['7.5.0', '7.4.0-beta.1', 'alpha'], + ['7.5.0', '7.4.0+build.123', 'alpha'], + ['7.5.0', '7.4.0-beta.2+exp.sha.5114f85', 'alpha'], + // pre-releases are always alpha + ['7.5.0', '7.5.0-alpha.1', 'beta'], + ['7.5.0', '7.5.0+build.123', 'beta'], + ['7.5.0', '7.5.0-beta.2+exp.sha.5114f85', 'beta'], + // on next patch release it moves to beta + ['7.5.0', '7.5.1-beta.1', 'beta'], + ['7.5.0', '7.5.1+build.123', 'beta'], + ['7.5.0', '7.5.1-beta.2+exp.sha.5114f85', 'beta'], +])('calculateStability returns beta for 0-2 minor versions ahead (release %s, current %s)', (releaseVersion, currentVersion, expected) => { + expect(calculateStability(releaseVersion, currentVersion)).toBe(expected); +}); From a1505342e04a696af33109610db0f922bed14bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 16:25:33 +0100 Subject: [PATCH 10/17] chore: switch from heuristic to explicit versioning --- src/lib/openapi/util/api-operation.test.ts | 98 +++++++++++++------ src/lib/openapi/util/api-operation.ts | 65 ++++++------ src/lib/services/openapi-service.ts | 14 +-- .../ADRs/back-end/api-version-tracking.md | 38 +++---- 4 files changed, 128 insertions(+), 87 deletions(-) diff --git a/src/lib/openapi/util/api-operation.test.ts b/src/lib/openapi/util/api-operation.test.ts index f650027d59..f5952dc7cb 100644 --- a/src/lib/openapi/util/api-operation.test.ts +++ b/src/lib/openapi/util/api-operation.test.ts @@ -1,42 +1,80 @@ import { calculateStability } from './api-operation.js'; -test('calculateStability returns alpha when release is ahead of current', () => { - expect(calculateStability('7.5.0', '7.4.0')).toBe('alpha'); +test('calculateStability returns alpha when current is before beta and stable', () => { + expect( + calculateStability({ + betaReleaseVersion: '7.5.0', + stableReleaseVersion: '7.7.0', + currentVersion: '7.4.0', + }), + ).toBe('alpha'); }); -test.each([ - ['7.4.0', '7.4.0'], - ['7.3.0', '7.4.0'], - ['7.2.0', '7.4.0'], -])('calculateStability returns beta for 0-2 minor versions ahead (release %s, current %s)', (releaseVersion, currentVersion) => { - expect(calculateStability(releaseVersion, currentVersion)).toBe('beta'); +test('calculateStability returns beta when current is between beta and stable', () => { + expect( + calculateStability({ + betaReleaseVersion: '7.5.0', + stableReleaseVersion: '7.7.0', + currentVersion: '7.6.0', + }), + ).toBe('beta'); }); -test('calculateStability returns stable for 3+ minor versions ahead', () => { - expect(calculateStability('7.1.0', '7.4.0')).toBe('stable'); +test('calculateStability returns stable when current is at or after stable', () => { + expect( + calculateStability({ + betaReleaseVersion: '7.5.0', + stableReleaseVersion: '7.7.0', + currentVersion: '7.7.0', + }), + ).toBe('stable'); + expect( + calculateStability({ + betaReleaseVersion: '7.5.0', + stableReleaseVersion: '7.7.0', + currentVersion: '7.8.0', + }), + ).toBe('stable'); }); -test('calculateStability returns stable across major version differences', () => { - expect(calculateStability('6.5.0', '7.0.0')).toBe('stable'); +test('calculateStability returns alpha when beta is omitted and current is before stable', () => { + expect( + calculateStability({ + stableReleaseVersion: '7.7.0', + currentVersion: '7.6.0', + }), + ).toBe('alpha'); +}); + +test('calculateStability returns stable when beta is omitted and current is after stable', () => { + expect( + calculateStability({ + stableReleaseVersion: '7.7.0', + currentVersion: '7.8.0', + }), + ).toBe('stable'); }); test('calculateStability defaults to stable when versions are invalid', () => { - expect(calculateStability('not-a-version', '7.4.0')).toBe('stable'); - expect(calculateStability('7.4.0', 'nope')).toBe('stable'); -}); - -test.each([ - ['7.5.0', '7.4.0-beta.1', 'alpha'], - ['7.5.0', '7.4.0+build.123', 'alpha'], - ['7.5.0', '7.4.0-beta.2+exp.sha.5114f85', 'alpha'], - // pre-releases are always alpha - ['7.5.0', '7.5.0-alpha.1', 'beta'], - ['7.5.0', '7.5.0+build.123', 'beta'], - ['7.5.0', '7.5.0-beta.2+exp.sha.5114f85', 'beta'], - // on next patch release it moves to beta - ['7.5.0', '7.5.1-beta.1', 'beta'], - ['7.5.0', '7.5.1+build.123', 'beta'], - ['7.5.0', '7.5.1-beta.2+exp.sha.5114f85', 'beta'], -])('calculateStability returns beta for 0-2 minor versions ahead (release %s, current %s)', (releaseVersion, currentVersion, expected) => { - expect(calculateStability(releaseVersion, currentVersion)).toBe(expected); + expect( + calculateStability({ + betaReleaseVersion: 'not-a-version', + stableReleaseVersion: '7.7.0', + currentVersion: '7.4.0', + }), + ).toBe('stable'); + expect( + calculateStability({ + betaReleaseVersion: '7.5.0', + stableReleaseVersion: 'nope', + currentVersion: '7.4.0', + }), + ).toBe('stable'); + expect( + calculateStability({ + betaReleaseVersion: '7.5.0', + stableReleaseVersion: '7.7.0', + currentVersion: 'nope', + }), + ).toBe('stable'); }); diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index 2352a7d123..81f69e8c0e 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -3,39 +3,40 @@ import type { OpenApiTag } from './openapi-tags.js'; import semver from 'semver'; /** - * Calculate stability level based on comparing release and current versions. - * - Alpha: release version is ahead of current (not yet released) - * - Beta: current is 0-2 minor versions ahead of release version - * - Stable: current is more than 2 minor versions ahead of release version + * Calculate stability level based on comparing beta/stable milestones + * against the current version. + * - Alpha: current version is before beta and before stable + * - Beta: current version is >= beta and < stable + * - Stable: current version is >= stable */ -export function calculateStability( - releaseVersion: string, - currentVersion: string, -): 'alpha' | 'beta' | 'stable' { - const release = semver.coerce(releaseVersion); - const current = semver.coerce(currentVersion); +type StabilityVersions = { + betaReleaseVersion?: string; + stableReleaseVersion: string; + currentVersion: string; +}; - if (!release || !current) { +export function calculateStability({ + betaReleaseVersion, + stableReleaseVersion, + currentVersion, +}: StabilityVersions): 'alpha' | 'beta' | 'stable' { + const current = semver.coerce(currentVersion); + const beta = betaReleaseVersion ? semver.coerce(betaReleaseVersion) : null; + const stable = semver.coerce(stableReleaseVersion); + + if (!current || !stable) { return 'stable'; // Default to stable if versions can't be parsed } - // If release is ahead of current, it's alpha (not yet released) - if (semver.gt(release, current)) { - return 'alpha'; + if (semver.gte(current, stable)) { + return 'stable'; } - // Calculate minor version difference - // For same major: just subtract minors - // For different major: consider major difference as many minors - const majorDiff = current.major - release.major; - const minorDiff = current.minor - release.minor; - const totalMinorDiff = majorDiff * 1000 + minorDiff; // Major version jump = 1000 minors (effectively always stable) - - if (totalMinorDiff <= 2) { + if (beta && semver.gte(current, beta)) { return 'beta'; } - return 'stable'; + return 'alpha'; } type DeprecatedOpenAPITag = @@ -51,19 +52,17 @@ export interface ApiOperation extends Omit { operationId: string; tags: [Tag]; - /** @deprecated use releaseVersion instead */ + /** @deprecated use betaReleaseVersion/stableReleaseVersion instead */ beta?: boolean; /** - * The version when this API was introduced or last significantly changed. - * Used to automatically calculate stability: - * - Alpha: release version is ahead of current version (not yet released) - * - Beta: current version is 0-2 minor versions ahead of release version - * - Stable: current version is 3 or more minor versions ahead of release version - * - * When developing a new API, set this to your best estimate of when it will be released. - * All APIs naturally progress through the beta -> stable lifecycle as versions advance. + * The first version where this API is expected to be beta. + * If omitted, the API stays alpha until it reaches stable. + */ + betaReleaseVersion?: string; + /** + * The first version where this API is expected to be stable. * @default '7.0.0' */ - releaseVersion?: string; + stableReleaseVersion?: string; enterpriseOnly?: boolean; } diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index 82f1c1d5a9..ecbaa493da 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -16,7 +16,7 @@ import type { Logger } from '../logger.js'; import { validateSchema } from '../openapi/validate.js'; import type { IFlagResolver } from '../types/index.js'; -const defaultReleaseVersion = '7.0.0'; +const defaultStableReleaseVersion = '7.0.0'; const getStabilityLevel = (operation: unknown): string | undefined => { if (!operation || typeof operation !== 'object') { return undefined; @@ -68,7 +68,8 @@ export class OpenApiService { validPath(op: ApiOperation): RequestHandler { const { beta, - releaseVersion = defaultReleaseVersion, + betaReleaseVersion, + stableReleaseVersion = defaultStableReleaseVersion, enterpriseOnly, ...rest } = op; @@ -78,7 +79,11 @@ export class OpenApiService { const currentVersion = this.api.document?.info?.version || '7.0.0'; const stability = beta ? 'beta' - : calculateStability(releaseVersion, currentVersion); + : calculateStability({ + betaReleaseVersion, + stableReleaseVersion, + currentVersion, + }); const summaryWithStability = stability !== 'stable' && rest.summary ? `[${stability.toUpperCase()}] ${rest.summary}` @@ -110,9 +115,6 @@ export class OpenApiService { ...rest, summary: summaryWithStability, 'x-stability-level': stability, - ...(releaseVersion !== defaultReleaseVersion - ? { 'x-release-version': releaseVersion } - : {}), description: `${enterpriseBadge}${stabilityBadge}${op.description}`.replaceAll( /\n\s*/g, diff --git a/website/docs/contributing/ADRs/back-end/api-version-tracking.md b/website/docs/contributing/ADRs/back-end/api-version-tracking.md index 1f3ce114ea..a91a0fc038 100644 --- a/website/docs/contributing/ADRs/back-end/api-version-tracking.md +++ b/website/docs/contributing/ADRs/back-end/api-version-tracking.md @@ -16,13 +16,13 @@ Additionally, we wanted to ship and test new APIs with select customers before f ## Decision -We've implemented an automated API stability tracking system based on semantic versioning. Each endpoint declares a `releaseVersion` field (the version when it was introduced or last significantly changed). The system automatically calculates stability levels based on version comparison: +We've implemented an automated API stability tracking system based on semantic versioning. Each endpoint declares a `betaReleaseVersion` (optional) and a `stableReleaseVersion` (required). The system calculates stability levels by comparing those milestones against the current version: ### Stability Calculation Heuristic -- **Alpha** πŸ”΄: Release version is ahead of current version (not yet released) -- **Beta** 🟑: Current version is 0-2 minor versions ahead of release version -- **Stable** 🟒: Current version is 3+ minor versions ahead of release version +- **Alpha** πŸ”΄: Current version is before both beta and stable (if beta is omitted, stable is the only check) +- **Beta** 🟑: Current version is at or after beta, but before stable +- **Stable** 🟒: Current version is at or after stable **Example:** ```typescript @@ -31,7 +31,8 @@ We've implemented an automated API stability tracking system based on semantic v openApiService.validPath({ tags: ['Features'], summary: 'Create feature flag', - releaseVersion: '7.5.0', // β†’ Alpha (not yet released) + betaReleaseVersion: '7.5.0', + stableReleaseVersion: '7.7.0', // β†’ Alpha (not yet released) operationId: 'createFeature', // ... }) @@ -39,7 +40,8 @@ openApiService.validPath({ openApiService.validPath({ tags: ['Projects'], summary: 'List projects', - releaseVersion: '7.3.0', // β†’ Beta (1 minor behind) + betaReleaseVersion: '7.3.0', + stableReleaseVersion: '7.5.0', // β†’ Beta (between beta and stable) operationId: 'getProjects', // ... }) @@ -47,7 +49,7 @@ openApiService.validPath({ openApiService.validPath({ tags: ['Users'], summary: 'Get user info', - releaseVersion: '7.1.0', // β†’ Stable (3+ minors behind) + stableReleaseVersion: '7.1.0', // β†’ Stable (already at/after stable) operationId: 'getUserInfo', // ... }) @@ -55,9 +57,9 @@ openApiService.validPath({ ### Implementation -1. **ApiOperation Interface**: Added `releaseVersion?: string` field (defaults to `'7.0.0'` so most/all APIs are stable now) -2. **Stability Calculation**: `calculateStability()` function compares release version with current Unleash version -3. **OpenAPI Extensions**: Automatically adds `x-stability-level` and `x-release-version` (only if defined) to OpenAPI spec +1. **ApiOperation Interface**: Added `betaReleaseVersion?: string` and `stableReleaseVersion?: string` (defaults to `'7.0.0'` so most/all APIs are stable now) +2. **Stability Calculation**: `calculateStability()` compares beta/stable milestones with the current Unleash version +3. **OpenAPI Extensions**: Adds only `x-stability-level` to the OpenAPI spec (milestone versions stay in code as documentation) 4. **Swagger UI Integration**: - Alpha endpoints are hidden from public docs in production - Visible in development mode (`NODE_ENV=development`) @@ -77,11 +79,11 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall ### Positive -**Zero maintenance**: As Unleash versions progress, APIs automatically transition from alpha β†’ beta β†’ stable without manual intervention. +**Zero maintenance**: As Unleash versions progress, APIs automatically transition from alpha β†’ beta β†’ stable based on the declared milestones. -**Built-in documentation**: The `releaseVersion` field serves as historical documentation. Anyone can see when an API was introduced and assess its maturity. +**Built-in documentation**: The `betaReleaseVersion` and `stableReleaseVersion` fields serve as historical documentation. Anyone can see the intended lifecycle and assess maturity. -**Flexible during development**: Developers estimate which version a new API will ship in. If priorities change or development takes longer, they simply update the version - it's okay to be wrong initially. +**Flexible during development**: Developers estimate which versions a new API will reach beta and stable. If priorities change or development takes longer, they update the milestones. **Selective disclosure**: Ship alpha features to production for testing without exposing them to customers, until the API is ready to be moved to beta. @@ -89,14 +91,14 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall ### Migration Path -1. **Immediate**: New endpoints should include `releaseVersion` -2. **Gradual**: Add `releaseVersion` to existing endpoints as they're modified +1. **Immediate**: New endpoints should include `stableReleaseVersion` and optionally `betaReleaseVersion` +2. **Gradual**: Add `stableReleaseVersion` (and optional `betaReleaseVersion`) to existing endpoints as they're modified 3. **Future**: AI-assisted bulk backfill from git history to document all existing APIs ### Trade-offs -**Version guessing required**: Developers must estimate release versions during development. This is an acceptable trade-off given the version can be updated and the benefits of automation. +**Milestone guessing required**: Developers must estimate beta/stable milestones during development. This is acceptable given the milestones can be updated. -**Defaults to 7.0.0**: Endpoints without `releaseVersion` default to `'7.0.0'`, which may not be historically accurate but provides a reasonable baseline for the migration period. +**Defaults to 7.0.0**: Endpoints without `stableReleaseVersion` default to `'7.0.0'`, which may not be historically accurate but provides a reasonable baseline for the migration period. -**Heuristic limitations**: The more-than-2-minor-versions (3+ minor versions) threshold for betaβ†’stable is somewhat arbitrary but provides a reasonable balance between caution and API maturity progression. +**Explicit lifecycle**: Having two milestones is explicit, but it requires setting (and occasionally updating) both values. From 70427b917b5283f3086ede3f4a15fc22a535a4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 16:37:03 +0100 Subject: [PATCH 11/17] Remove the need of beta by using current version as beta --- src/lib/features/context/context.ts | 18 +++++++++--------- src/lib/openapi/util/api-operation.ts | 2 -- src/lib/services/openapi-service.ts | 13 +++++-------- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/lib/features/context/context.ts b/src/lib/features/context/context.ts index 294196cfbc..65891a85d4 100644 --- a/src/lib/features/context/context.ts +++ b/src/lib/features/context/context.ts @@ -87,7 +87,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Gets configured context fields', description: 'Returns all configured [Context fields](https://docs.getunleash.io/concepts/unleash-context) that have been created.', @@ -107,7 +107,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Gets context field', description: 'Returns specific [context field](https://docs.getunleash.io/concepts/unleash-context) identified by the name in the path', @@ -127,7 +127,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Strategies'], - beta, + betaReleaseVersion: '7.4.0', // current version operationId: resolveOperationId( 'getStrategiesByContextField', mode, @@ -153,7 +153,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version operationId: resolveOperationId('createContextField', mode), summary: 'Create a context field', description: @@ -178,7 +178,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Update an existing context field', description: `Endpoint that allows updating a custom context field. Used to toggle stickiness and add/remove legal values for this context field`, operationId: resolveOperationId('updateContextField', mode), @@ -200,7 +200,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Add or update legal value for the context field', description: `Endpoint that allows adding or updating a single custom context field legal value. If the legal value already exists, it will be updated with the new description`, operationId: resolveOperationId( @@ -224,7 +224,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Delete legal value for the context field', description: `Removes the specified custom context field legal value. Does not validate that the legal value is not in use and does not remove usage from constraints that use it.`, operationId: resolveOperationId( @@ -247,7 +247,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Delete an existing context field', description: 'Endpoint that allows deletion of a custom context field. Does not validate that context field is not in use, but since context field configuration is stored in a json blob for the strategy, existing strategies are safe.', @@ -267,7 +267,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - beta, + betaReleaseVersion: '7.4.0', // current version summary: 'Validate a context field', description: 'Check whether the provided data can be used to create a context field. If the data is not valid, returns a 400 status code with the reason why it is not valid.', diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index 81f69e8c0e..d722c70462 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -52,8 +52,6 @@ export interface ApiOperation extends Omit { operationId: string; tags: [Tag]; - /** @deprecated use betaReleaseVersion/stableReleaseVersion instead */ - beta?: boolean; /** * The first version where this API is expected to be beta. * If omitted, the API stays alpha until it reaches stable. diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index ecbaa493da..33c1c1a775 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -67,7 +67,6 @@ export class OpenApiService { validPath(op: ApiOperation): RequestHandler { const { - beta, betaReleaseVersion, stableReleaseVersion = defaultStableReleaseVersion, enterpriseOnly, @@ -77,13 +76,11 @@ export class OpenApiService { const openapiStaticAssets = `${baseUriPath}/openapi-static`; const currentVersion = this.api.document?.info?.version || '7.0.0'; - const stability = beta - ? 'beta' - : calculateStability({ - betaReleaseVersion, - stableReleaseVersion, - currentVersion, - }); + const stability = calculateStability({ + betaReleaseVersion, + stableReleaseVersion, + currentVersion, + }); const summaryWithStability = stability !== 'stable' && rest.summary ? `[${stability.toUpperCase()}] ${rest.summary}` From 979100a2958c67ac07c112c130dda80464937d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 17:08:27 +0100 Subject: [PATCH 12/17] Fix test expectations --- src/lib/openapi/util/api-operation.test.ts | 6 +++--- src/lib/openapi/util/api-operation.ts | 10 +++++++--- src/lib/services/openapi-service.ts | 4 +++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/lib/openapi/util/api-operation.test.ts b/src/lib/openapi/util/api-operation.test.ts index f5952dc7cb..2ff27c16fa 100644 --- a/src/lib/openapi/util/api-operation.test.ts +++ b/src/lib/openapi/util/api-operation.test.ts @@ -37,13 +37,13 @@ test('calculateStability returns stable when current is at or after stable', () ).toBe('stable'); }); -test('calculateStability returns alpha when beta is omitted and current is before stable', () => { +test('calculateStability returns beta when beta is omitted and current is before stable', () => { expect( calculateStability({ stableReleaseVersion: '7.7.0', currentVersion: '7.6.0', }), - ).toBe('alpha'); + ).toBe('beta'); }); test('calculateStability returns stable when beta is omitted and current is after stable', () => { @@ -59,7 +59,7 @@ test('calculateStability defaults to stable when versions are invalid', () => { expect( calculateStability({ betaReleaseVersion: 'not-a-version', - stableReleaseVersion: '7.7.0', + stableReleaseVersion: 'x.y.z', currentVersion: '7.4.0', }), ).toBe('stable'); diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index d722c70462..b9e1fa5502 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -32,11 +32,15 @@ export function calculateStability({ return 'stable'; } - if (beta && semver.gte(current, beta)) { - return 'beta'; + if (beta) { + if (semver.lt(current, beta)) { + return 'alpha'; + } else { + return 'beta'; + } } - return 'alpha'; + return semver.lt(current, stable) ? 'beta' : 'stable'; } type DeprecatedOpenAPITag = diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index 33c1c1a775..2f99b5e298 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -15,6 +15,7 @@ import { import type { Logger } from '../logger.js'; import { validateSchema } from '../openapi/validate.js'; import type { IFlagResolver } from '../types/index.js'; +import packageVersion from '../util/version.js'; const defaultStableReleaseVersion = '7.0.0'; const getStabilityLevel = (operation: unknown): string | undefined => { @@ -75,7 +76,8 @@ export class OpenApiService { const { baseUriPath = '' } = this.config.server ?? {}; const openapiStaticAssets = `${baseUriPath}/openapi-static`; - const currentVersion = this.api.document?.info?.version || '7.0.0'; + const currentVersion = + this.api.document?.info?.version || packageVersion || '7.0.0'; const stability = calculateStability({ betaReleaseVersion, stableReleaseVersion, From 399ef9b4bd249729efc30534313db3646fe88ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 17:39:40 +0100 Subject: [PATCH 13/17] Make context beta until 7.6.0 --- src/lib/features/context/context.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/features/context/context.ts b/src/lib/features/context/context.ts index 65891a85d4..06d4d042ca 100644 --- a/src/lib/features/context/context.ts +++ b/src/lib/features/context/context.ts @@ -87,7 +87,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Gets configured context fields', description: 'Returns all configured [Context fields](https://docs.getunleash.io/concepts/unleash-context) that have been created.', @@ -107,7 +107,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Gets context field', description: 'Returns specific [context field](https://docs.getunleash.io/concepts/unleash-context) identified by the name in the path', @@ -127,7 +127,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Strategies'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current operationId: resolveOperationId( 'getStrategiesByContextField', mode, @@ -153,7 +153,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current operationId: resolveOperationId('createContextField', mode), summary: 'Create a context field', description: @@ -178,7 +178,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Update an existing context field', description: `Endpoint that allows updating a custom context field. Used to toggle stickiness and add/remove legal values for this context field`, operationId: resolveOperationId('updateContextField', mode), @@ -200,7 +200,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Add or update legal value for the context field', description: `Endpoint that allows adding or updating a single custom context field legal value. If the legal value already exists, it will be updated with the new description`, operationId: resolveOperationId( @@ -224,7 +224,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Delete legal value for the context field', description: `Removes the specified custom context field legal value. Does not validate that the legal value is not in use and does not remove usage from constraints that use it.`, operationId: resolveOperationId( @@ -247,7 +247,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Delete an existing context field', description: 'Endpoint that allows deletion of a custom context field. Does not validate that context field is not in use, but since context field configuration is stored in a json blob for the strategy, existing strategies are safe.', @@ -267,7 +267,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - betaReleaseVersion: '7.4.0', // current version + stableReleaseVersion: '7.6.0', // two versions ahead of current summary: 'Validate a context field', description: 'Check whether the provided data can be used to create a context field. If the data is not valid, returns a 400 status code with the reason why it is not valid.', From ceb5df70596b946d96895e4caf0313c50b86486f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 17:49:17 +0100 Subject: [PATCH 14/17] Updated docs --- src/lib/openapi/util/api-operation.ts | 4 ++-- .../docs/contributing/ADRs/back-end/api-version-tracking.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index b9e1fa5502..94a5d124f9 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -5,7 +5,7 @@ import semver from 'semver'; /** * Calculate stability level based on comparing beta/stable milestones * against the current version. - * - Alpha: current version is before beta and before stable + * - Alpha: current version is before beta (when beta is defined) * - Beta: current version is >= beta and < stable * - Stable: current version is >= stable */ @@ -58,7 +58,7 @@ export interface ApiOperation tags: [Tag]; /** * The first version where this API is expected to be beta. - * If omitted, the API stays alpha until it reaches stable. + * If omitted, the API is treated as beta until it reaches stable. */ betaReleaseVersion?: string; /** diff --git a/website/docs/contributing/ADRs/back-end/api-version-tracking.md b/website/docs/contributing/ADRs/back-end/api-version-tracking.md index a91a0fc038..814bfac5e2 100644 --- a/website/docs/contributing/ADRs/back-end/api-version-tracking.md +++ b/website/docs/contributing/ADRs/back-end/api-version-tracking.md @@ -20,7 +20,7 @@ We've implemented an automated API stability tracking system based on semantic v ### Stability Calculation Heuristic -- **Alpha** πŸ”΄: Current version is before both beta and stable (if beta is omitted, stable is the only check) +- **Alpha** πŸ”΄: Current version is before beta (when beta is defined) - **Beta** 🟑: Current version is at or after beta, but before stable - **Stable** 🟒: Current version is at or after stable @@ -91,7 +91,7 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall ### Migration Path -1. **Immediate**: New endpoints should include `stableReleaseVersion` and optionally `betaReleaseVersion` +1. **Immediate**: New endpoints should include `stableReleaseVersion` and optionally `betaReleaseVersion` (omit beta if you want beta until stable) 2. **Gradual**: Add `stableReleaseVersion` (and optional `betaReleaseVersion`) to existing endpoints as they're modified 3. **Future**: AI-assisted bulk backfill from git history to document all existing APIs From 8d27596e8703cd11822c21945cedf513570c9b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 17:52:23 +0100 Subject: [PATCH 15/17] Move new ADR to new folder --- .../ADRs/back-end/api-version-tracking.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {website/docs/contributing => contributing}/ADRs/back-end/api-version-tracking.md (100%) diff --git a/website/docs/contributing/ADRs/back-end/api-version-tracking.md b/contributing/ADRs/back-end/api-version-tracking.md similarity index 100% rename from website/docs/contributing/ADRs/back-end/api-version-tracking.md rename to contributing/ADRs/back-end/api-version-tracking.md From 0cbd16145a2b66548bfca91fdfb638538fd60903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 19 Jan 2026 17:53:31 +0100 Subject: [PATCH 16/17] Move change in ADRs --- contributing/ADRs/ADRs.md | 1 + website/docs/contributing/ADRs/ADRs.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing/ADRs/ADRs.md b/contributing/ADRs/ADRs.md index 2471b2eea3..7aaeeb3205 100644 --- a/contributing/ADRs/ADRs.md +++ b/contributing/ADRs/ADRs.md @@ -30,6 +30,7 @@ We are in the process of defining ADRs for the back end. At the time of writing * [Write model vs Read models](/contributing/ADRs/back-end/write-model-vs-read-models) * [Frontend API Design](/contributing/ADRs/back-end/frontend-api-design) * [Correct type dependencies](/contributing/ADRs/back-end/correct-type-dependencies) +* [API Version Tracking and Stability Lifecycle](/contributing/ADRs/back-end/api-version-tracking) ## Front-end ADRs diff --git a/website/docs/contributing/ADRs/ADRs.md b/website/docs/contributing/ADRs/ADRs.md index 7aaeeb3205..2471b2eea3 100644 --- a/website/docs/contributing/ADRs/ADRs.md +++ b/website/docs/contributing/ADRs/ADRs.md @@ -30,7 +30,6 @@ We are in the process of defining ADRs for the back end. At the time of writing * [Write model vs Read models](/contributing/ADRs/back-end/write-model-vs-read-models) * [Frontend API Design](/contributing/ADRs/back-end/frontend-api-design) * [Correct type dependencies](/contributing/ADRs/back-end/correct-type-dependencies) -* [API Version Tracking and Stability Lifecycle](/contributing/ADRs/back-end/api-version-tracking) ## Front-end ADRs From 305708013e2245ba63807bc6964a7534e7720dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Tue, 20 Jan 2026 11:29:47 +0100 Subject: [PATCH 17/17] chore: changed to alpha/beta until version which is easier to adopt --- .../ADRs/back-end/api-version-tracking.md | 34 +++++----- src/lib/features/context/context.ts | 18 ++--- src/lib/openapi/util/api-operation.test.ts | 55 +++++++-------- src/lib/openapi/util/api-operation.ts | 68 ++++++++++--------- src/lib/services/openapi-service.ts | 14 ++-- 5 files changed, 94 insertions(+), 95 deletions(-) diff --git a/contributing/ADRs/back-end/api-version-tracking.md b/contributing/ADRs/back-end/api-version-tracking.md index 814bfac5e2..fc08e404f4 100644 --- a/contributing/ADRs/back-end/api-version-tracking.md +++ b/contributing/ADRs/back-end/api-version-tracking.md @@ -16,13 +16,13 @@ Additionally, we wanted to ship and test new APIs with select customers before f ## Decision -We've implemented an automated API stability tracking system based on semantic versioning. Each endpoint declares a `betaReleaseVersion` (optional) and a `stableReleaseVersion` (required). The system calculates stability levels by comparing those milestones against the current version: +We've implemented an automated API stability tracking system based on semantic versioning. Each endpoint can declare an `alphaUntilVersion` and/or a `betaUntilVersion` to define cutoffs. The system calculates stability levels by comparing those cutoffs against the current version: ### Stability Calculation Heuristic -- **Alpha** πŸ”΄: Current version is before beta (when beta is defined) -- **Beta** 🟑: Current version is at or after beta, but before stable -- **Stable** 🟒: Current version is at or after stable +- **Alpha** πŸ”΄: Current version is before `alphaUntilVersion` (when defined) +- **Beta** 🟑: Current version is at or after `alphaUntilVersion` but before `betaUntilVersion` (when defined) +- **Stable** 🟒: Current version is at or after `betaUntilVersion` (when defined); if `betaUntilVersion` is omitted, stability is reached once alpha is no longer in effect (or immediately if `alphaUntilVersion` is also omitted) **Example:** ```typescript @@ -31,8 +31,8 @@ We've implemented an automated API stability tracking system based on semantic v openApiService.validPath({ tags: ['Features'], summary: 'Create feature flag', - betaReleaseVersion: '7.5.0', - stableReleaseVersion: '7.7.0', // β†’ Alpha (not yet released) + alphaUntilVersion: '7.5.0', + betaUntilVersion: '7.7.0', // β†’ Alpha (not yet released) operationId: 'createFeature', // ... }) @@ -40,8 +40,8 @@ openApiService.validPath({ openApiService.validPath({ tags: ['Projects'], summary: 'List projects', - betaReleaseVersion: '7.3.0', - stableReleaseVersion: '7.5.0', // β†’ Beta (between beta and stable) + alphaUntilVersion: '7.3.0', + betaUntilVersion: '7.5.0', // β†’ Beta (between alpha and beta cutoffs) operationId: 'getProjects', // ... }) @@ -49,7 +49,7 @@ openApiService.validPath({ openApiService.validPath({ tags: ['Users'], summary: 'Get user info', - stableReleaseVersion: '7.1.0', // β†’ Stable (already at/after stable) + betaUntilVersion: '7.1.0', // β†’ Stable (already at/after beta cutoff) operationId: 'getUserInfo', // ... }) @@ -57,9 +57,9 @@ openApiService.validPath({ ### Implementation -1. **ApiOperation Interface**: Added `betaReleaseVersion?: string` and `stableReleaseVersion?: string` (defaults to `'7.0.0'` so most/all APIs are stable now) -2. **Stability Calculation**: `calculateStability()` compares beta/stable milestones with the current Unleash version -3. **OpenAPI Extensions**: Adds only `x-stability-level` to the OpenAPI spec (milestone versions stay in code as documentation) +1. **ApiOperation Interface**: Added `alphaUntilVersion?: string` and `betaUntilVersion?: string` (omit both to mark stable). `releaseVersion?: string` is available for documentation only. +2. **Stability Calculation**: `calculateStability()` compares alpha/beta cutoffs with the current Unleash version +3. **OpenAPI Extensions**: Adds only `x-stability-level` to the OpenAPI spec (cutoff versions stay in code as documentation) 4. **Swagger UI Integration**: - Alpha endpoints are hidden from public docs in production - Visible in development mode (`NODE_ENV=development`) @@ -81,7 +81,7 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall **Zero maintenance**: As Unleash versions progress, APIs automatically transition from alpha β†’ beta β†’ stable based on the declared milestones. -**Built-in documentation**: The `betaReleaseVersion` and `stableReleaseVersion` fields serve as historical documentation. Anyone can see the intended lifecycle and assess maturity. +**Built-in documentation**: The cutoff versions (and optional `releaseVersion`) serve as lifecycle documentation. Anyone can see the intended lifecycle and assess maturity. **Flexible during development**: Developers estimate which versions a new API will reach beta and stable. If priorities change or development takes longer, they update the milestones. @@ -91,14 +91,12 @@ This gives us the best of both worlds: we can ship and test alpha APIs internall ### Migration Path -1. **Immediate**: New endpoints should include `stableReleaseVersion` and optionally `betaReleaseVersion` (omit beta if you want beta until stable) -2. **Gradual**: Add `stableReleaseVersion` (and optional `betaReleaseVersion`) to existing endpoints as they're modified +1. **Immediate**: New endpoints can include `alphaUntilVersion` and/or `betaUntilVersion` as needed +2. **Gradual**: Add cutoffs to existing endpoints as they're modified 3. **Future**: AI-assisted bulk backfill from git history to document all existing APIs ### Trade-offs **Milestone guessing required**: Developers must estimate beta/stable milestones during development. This is acceptable given the milestones can be updated. -**Defaults to 7.0.0**: Endpoints without `stableReleaseVersion` default to `'7.0.0'`, which may not be historically accurate but provides a reasonable baseline for the migration period. - -**Explicit lifecycle**: Having two milestones is explicit, but it requires setting (and occasionally updating) both values. +**Explicit lifecycle**: Cutoffs are explicit, but they require setting (and occasionally updating) the versions. diff --git a/src/lib/features/context/context.ts b/src/lib/features/context/context.ts index 06d4d042ca..9cad2b0747 100644 --- a/src/lib/features/context/context.ts +++ b/src/lib/features/context/context.ts @@ -87,7 +87,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Gets configured context fields', description: 'Returns all configured [Context fields](https://docs.getunleash.io/concepts/unleash-context) that have been created.', @@ -107,7 +107,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Gets context field', description: 'Returns specific [context field](https://docs.getunleash.io/concepts/unleash-context) identified by the name in the path', @@ -127,7 +127,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Strategies'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current operationId: resolveOperationId( 'getStrategiesByContextField', mode, @@ -153,7 +153,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current operationId: resolveOperationId('createContextField', mode), summary: 'Create a context field', description: @@ -178,7 +178,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Update an existing context field', description: `Endpoint that allows updating a custom context field. Used to toggle stickiness and add/remove legal values for this context field`, operationId: resolveOperationId('updateContextField', mode), @@ -200,7 +200,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Add or update legal value for the context field', description: `Endpoint that allows adding or updating a single custom context field legal value. If the legal value already exists, it will be updated with the new description`, operationId: resolveOperationId( @@ -224,7 +224,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Delete legal value for the context field', description: `Removes the specified custom context field legal value. Does not validate that the legal value is not in use and does not remove usage from constraints that use it.`, operationId: resolveOperationId( @@ -247,7 +247,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Delete an existing context field', description: 'Endpoint that allows deletion of a custom context field. Does not validate that context field is not in use, but since context field configuration is stored in a json blob for the strategy, existing strategies are safe.', @@ -267,7 +267,7 @@ export class ContextController extends Controller { middleware: [ openApiService.validPath({ tags: ['Context'], - stableReleaseVersion: '7.6.0', // two versions ahead of current + betaUntilVersion: '7.6.0', // two versions ahead of current summary: 'Validate a context field', description: 'Check whether the provided data can be used to create a context field. If the data is not valid, returns a 400 status code with the reason why it is not valid.', diff --git a/src/lib/openapi/util/api-operation.test.ts b/src/lib/openapi/util/api-operation.test.ts index 2ff27c16fa..8e53aa6857 100644 --- a/src/lib/openapi/util/api-operation.test.ts +++ b/src/lib/openapi/util/api-operation.test.ts @@ -1,55 +1,63 @@ import { calculateStability } from './api-operation.js'; -test('calculateStability returns alpha when current is before beta and stable', () => { +test('calculateStability returns alpha when current is before alpha cutoff', () => { expect( calculateStability({ - betaReleaseVersion: '7.5.0', - stableReleaseVersion: '7.7.0', + alphaUntilVersion: '7.5.0', + betaUntilVersion: '7.7.0', currentVersion: '7.4.0', }), ).toBe('alpha'); }); -test('calculateStability returns beta when current is between beta and stable', () => { +test('calculateStability returns beta when current is between alpha and beta cutoffs', () => { expect( calculateStability({ - betaReleaseVersion: '7.5.0', - stableReleaseVersion: '7.7.0', + alphaUntilVersion: '7.5.0', + betaUntilVersion: '7.7.0', currentVersion: '7.6.0', }), ).toBe('beta'); }); -test('calculateStability returns stable when current is at or after stable', () => { +test('calculateStability returns stable when current is at or after beta cutoff', () => { expect( calculateStability({ - betaReleaseVersion: '7.5.0', - stableReleaseVersion: '7.7.0', + alphaUntilVersion: '7.5.0', + betaUntilVersion: '7.7.0', currentVersion: '7.7.0', }), ).toBe('stable'); expect( calculateStability({ - betaReleaseVersion: '7.5.0', - stableReleaseVersion: '7.7.0', + alphaUntilVersion: '7.5.0', + betaUntilVersion: '7.7.0', currentVersion: '7.8.0', }), ).toBe('stable'); }); -test('calculateStability returns beta when beta is omitted and current is before stable', () => { +test('calculateStability returns beta when beta cutoff is set without alpha', () => { expect( calculateStability({ - stableReleaseVersion: '7.7.0', - currentVersion: '7.6.0', + betaUntilVersion: '7.6.0', + currentVersion: '7.4.0', }), ).toBe('beta'); }); -test('calculateStability returns stable when beta is omitted and current is after stable', () => { +test('calculateStability returns stable when beta cutoff is omitted and current is after alpha', () => { + expect( + calculateStability({ + alphaUntilVersion: '7.5.0', + currentVersion: '7.6.0', + }), + ).toBe('stable'); +}); + +test('calculateStability returns stable when alpha and beta cutoffs are omitted', () => { expect( calculateStability({ - stableReleaseVersion: '7.7.0', currentVersion: '7.8.0', }), ).toBe('stable'); @@ -58,22 +66,15 @@ test('calculateStability returns stable when beta is omitted and current is afte test('calculateStability defaults to stable when versions are invalid', () => { expect( calculateStability({ - betaReleaseVersion: 'not-a-version', - stableReleaseVersion: 'x.y.z', + alphaUntilVersion: 'not-a-version', + betaUntilVersion: 'x.y.z', currentVersion: '7.4.0', }), ).toBe('stable'); expect( calculateStability({ - betaReleaseVersion: '7.5.0', - stableReleaseVersion: 'nope', - currentVersion: '7.4.0', - }), - ).toBe('stable'); - expect( - calculateStability({ - betaReleaseVersion: '7.5.0', - stableReleaseVersion: '7.7.0', + alphaUntilVersion: '7.5.0', + betaUntilVersion: '7.7.0', currentVersion: 'nope', }), ).toBe('stable'); diff --git a/src/lib/openapi/util/api-operation.ts b/src/lib/openapi/util/api-operation.ts index 94a5d124f9..8fcd60d7e4 100644 --- a/src/lib/openapi/util/api-operation.ts +++ b/src/lib/openapi/util/api-operation.ts @@ -3,44 +3,45 @@ import type { OpenApiTag } from './openapi-tags.js'; import semver from 'semver'; /** - * Calculate stability level based on comparing beta/stable milestones + * Calculate stability level based on comparing alpha/beta cutoffs * against the current version. - * - Alpha: current version is before beta (when beta is defined) - * - Beta: current version is >= beta and < stable - * - Stable: current version is >= stable + * - Alpha: current version is before alpha cutoff (when defined) + * - Beta: current version is at/after alpha cutoff and before beta cutoff (when defined) + * - Stable: current version is at/after beta cutoff (when defined), or when no cutoffs apply */ type StabilityVersions = { - betaReleaseVersion?: string; - stableReleaseVersion: string; + alphaUntilVersion?: string; + betaUntilVersion?: string; currentVersion: string; }; export function calculateStability({ - betaReleaseVersion, - stableReleaseVersion, + alphaUntilVersion, + betaUntilVersion, currentVersion, }: StabilityVersions): 'alpha' | 'beta' | 'stable' { - const current = semver.coerce(currentVersion); - const beta = betaReleaseVersion ? semver.coerce(betaReleaseVersion) : null; - const stable = semver.coerce(stableReleaseVersion); - - if (!current || !stable) { - return 'stable'; // Default to stable if versions can't be parsed - } - - if (semver.gte(current, stable)) { + if (!alphaUntilVersion && !betaUntilVersion) { return 'stable'; } - - if (beta) { - if (semver.lt(current, beta)) { - return 'alpha'; - } else { - return 'beta'; - } + const current = semver.coerce(currentVersion); + if (!current) { + return 'stable'; // Default to stable if current can't be parsed } - return semver.lt(current, stable) ? 'beta' : 'stable'; + const alphaUntil = alphaUntilVersion + ? semver.coerce(alphaUntilVersion) + : null; + const betaUntil = betaUntilVersion ? semver.coerce(betaUntilVersion) : null; + + if (alphaUntil && semver.lt(current, alphaUntil)) { + return 'alpha'; + } + + if (betaUntil && semver.lt(current, betaUntil)) { + return 'beta'; + } + + return 'stable'; } type DeprecatedOpenAPITag = @@ -57,14 +58,19 @@ export interface ApiOperation operationId: string; tags: [Tag]; /** - * The first version where this API is expected to be beta. - * If omitted, the API is treated as beta until it reaches stable. + * The version up to (but not including) which this API is alpha. + * If omitted, the API is never alpha. */ - betaReleaseVersion?: string; + alphaUntilVersion?: string; /** - * The first version where this API is expected to be stable. - * @default '7.0.0' + * The version up to (but not including) which this API is beta. + * If omitted, the API is stable once it is no longer alpha. */ - stableReleaseVersion?: string; + betaUntilVersion?: string; + /** + * The version when this API was introduced or last significantly changed. + * Documentation only; does not affect stability calculation. + */ + releaseVersion?: string; enterpriseOnly?: boolean; } diff --git a/src/lib/services/openapi-service.ts b/src/lib/services/openapi-service.ts index 2f99b5e298..fde6e361ac 100644 --- a/src/lib/services/openapi-service.ts +++ b/src/lib/services/openapi-service.ts @@ -16,8 +16,6 @@ import type { Logger } from '../logger.js'; import { validateSchema } from '../openapi/validate.js'; import type { IFlagResolver } from '../types/index.js'; import packageVersion from '../util/version.js'; - -const defaultStableReleaseVersion = '7.0.0'; const getStabilityLevel = (operation: unknown): string | undefined => { if (!operation || typeof operation !== 'object') { return undefined; @@ -67,20 +65,16 @@ export class OpenApiService { } validPath(op: ApiOperation): RequestHandler { - const { - betaReleaseVersion, - stableReleaseVersion = defaultStableReleaseVersion, - enterpriseOnly, - ...rest - } = op; + const { alphaUntilVersion, betaUntilVersion, enterpriseOnly, ...rest } = + op; const { baseUriPath = '' } = this.config.server ?? {}; const openapiStaticAssets = `${baseUriPath}/openapi-static`; const currentVersion = this.api.document?.info?.version || packageVersion || '7.0.0'; const stability = calculateStability({ - betaReleaseVersion, - stableReleaseVersion, + alphaUntilVersion, + betaUntilVersion, currentVersion, }); const summaryWithStability =