diff --git a/docs/deprecations/index.mdx b/docs/deprecations/index.mdx new file mode 100644 index 00000000..c985f4b6 --- /dev/null +++ b/docs/deprecations/index.mdx @@ -0,0 +1,15 @@ +--- +title: Deprecation List +description: Track deprecated endpoints, fields, and parameters in Glean's APIs +hide_title: true +--- + +import DeprecationsList from '@site/src/components/Deprecations/DeprecationsList' +import DeprecationsHeader from '@site/src/components/Deprecations/DeprecationsHeader' +import deprecationsData from '@site/src/data/deprecations.json' + + + +This page lists all deprecations related to Glean's public APIs. Each deprecation includes the date it was introduced, the removal date after which the property will no longer be available, and guidance on what to use instead. See [API Evolution & Deprecations](/deprecations/overview) for details on how our deprecation process works. + + diff --git a/docs/deprecations/overview.mdx b/docs/deprecations/overview.mdx new file mode 100644 index 00000000..e3d08dab --- /dev/null +++ b/docs/deprecations/overview.mdx @@ -0,0 +1,135 @@ +--- +title: API Evolution & Deprecations +description: How Glean evolves its REST API through additive changes and predictable deprecations +--- + +import CardGroup from '@site/src/components/CardGroup'; +import Card from '@site/src/components/Card'; +import { Steps, Step } from '@site/src/components/Steps'; + +# API Evolution & Deprecations + +We evolve our API through additive changes and predictable deprecations — not versioned URLs. This ensures your integrations remain stable while allowing us to improve the API. + +--- + +## What's a Breaking Change? + + + + Require your code to be updated: + + - Removing or renaming endpoints, fields, or parameters + - Changing data types or formats + - Making optional fields required + - Tightening validation rules + + + Work with your existing code: + + - Adding new endpoints or fields + - Adding optional parameters + - Loosening validation + - Performance improvements + + + +--- + +## The Deprecation Timeline + +When we need to make a breaking change, we follow a predictable process: + + + + We mark the feature as deprecated in our API responses and in our documentation. Your code continues to work normally. + + + Both old and new approaches work side-by-side. We provide migration guides and send email notifications. + + + The deprecated feature is removed only on a fixed date: **Jan 15**, **Apr 15**, **Jul 15**, or **Oct 15** (23:59:59 UTC). This gives you a predictable timeline to plan for a migration. + + + +#### Example: + +Consider a field deprecated on **2026-01-01**. The field will be removed on **2026-07-15** (6 months + rounded to the next allowed removal date) + +``` + 2026-01-01 2026-07-15 + │ │ + ▼ ▼ +────────────●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●──────────── + │ │ + Deprecation Introduced Deprecation Removed + + ├──────────────────── Migration Period ────────────────────────┤ +``` + +--- + +## How You'll Know About Changes + + + + Every response using deprecated features includes an `X-Glean-Deprecated` header with full details. + + + Inline warnings on API pages, migration guides, and a complete [deprecation schedule](/deprecations). + + + +When you use a deprecated feature, the API response includes an `X-Glean-Deprecated` header: + +```http +X-Glean-Deprecated: kind=field;name=snippetText;introduced=2026-01-01;removal=2026-07-15;docs=https://developers.glean.com/deprecations +``` + +The header contains: + +| Field | Description | +|-------|-------------| +| `kind` | Type of deprecation: `endpoint`, `field`, or `parameter` | +| `name` | Name of the deprecated item | +| `introduced` | Date the deprecation was announced (ISO-8601) | +| `removal` | Date the item will be removed (ISO-8601) | +| `docs` | Link to migration documentation | + +:::tip Automated Monitoring +Parse this header in your integration tests or monitoring to catch deprecations early. +::: + +--- + +## Testing Future Changes + +Validate your integration before deprecated features are removed by including the `X-Glean-Exclude-Deprecated-After` header: + +```http +X-Glean-Exclude-Deprecated-After: 2027-01-15 +``` + +This header simulates how the API will behave after the deprecation date, helping you verify your code handles the changes correctly. + +--- + +## After Removal + +When deprecated items are removed, the API provides clear error responses: + +| Item Type | Response | +|-----------|----------| +| **Endpoints** | `410 Gone` with migration instructions | +| **Request fields** | `400 Bad Request` with details | +| **Response fields** | Simply omitted from the response | + +```json title="Example error response" +{ + "error": { + "code": "deprecated_field_removed", + "message": "Field 'userId' was removed on 2027-01-15. Use 'userIdentifier' instead.", + "docs": "https://developers.glean.com/docs/migrations/2027-01-15" + } +} +``` diff --git a/package.json b/package.json index 88fd973d..b8870110 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "changelog:aggregate": "pnpm run changelog:compile:json", "generate:rss": "pnpm run changelog:compile:rss", "generate:redirects": "tsx scripts/generate-redirects.ts", + "generate:deprecations": "node scripts/generate-deprecations.mjs", + "generate:deprecations:rss": "node scripts/generate-deprecations-rss.mjs", "typecheck": "tsc", "format": "prettier --write .", "test": "vitest run", @@ -37,9 +39,9 @@ "format:check": "prettier --check .", "links:check": "scripts/check-links.sh https://developers.glean.com true", "links:check:local": "pnpm build && (pnpm serve --port 8888 & SERVER_PID=$!; sleep 5; scripts/check-links.sh http://localhost:8888 true; RESULT=$?; kill $SERVER_PID 2>/dev/null || true; exit $RESULT)", - "openapi:regenerate:all": "pnpm run openapi:clean:before:all && pnpm run openapi:transform:all && pnpm run openapi:generate:all && pnpm run openapi:clean:after:all", - "openapi:regenerate:client": "pnpm run openapi:clean:before:client && pnpm run openapi:transform:client && pnpm run openapi:generate:client && pnpm run openapi:clean:after:client", - "openapi:regenerate:indexing": "pnpm run openapi:clean:before:indexing && pnpm run openapi:transform:indexing && pnpm run openapi:generate:indexing && pnpm run openapi:clean:after:indexing", + "openapi:regenerate:all": "pnpm run openapi:clean:before:all && pnpm run openapi:transform:all && pnpm run generate:deprecations && pnpm run generate:deprecations:rss && pnpm run openapi:generate:all && pnpm run openapi:clean:after:all", + "openapi:regenerate:client": "pnpm run openapi:clean:before:client && pnpm run openapi:transform:client && pnpm run generate:deprecations && pnpm run generate:deprecations:rss && pnpm run openapi:generate:client && pnpm run openapi:clean:after:client", + "openapi:regenerate:indexing": "pnpm run openapi:clean:before:indexing && pnpm run openapi:transform:indexing && pnpm run generate:deprecations && pnpm run generate:deprecations:rss && pnpm run openapi:generate:indexing && pnpm run openapi:clean:after:indexing", "openapi:clean:before:all": "pnpm run openapi:clean:before:client && pnpm run openapi:clean:before:indexing", "openapi:clean:before:client": "find docs/api/client-api -type f ! -name 'overview.mdx' -delete", "openapi:clean:before:indexing": "find docs/api/indexing-api -type f ! -name '*-overview.mdx' -delete", @@ -94,6 +96,7 @@ "docusaurus-plugin-openapi-docs": "^4.4.0", "docusaurus-plugin-search-glean": "^0.7.0", "docusaurus-theme-openapi-docs": "^4.4.0", + "feed": "^4.2.2", "js-yaml": "^4.1.0", "lucide-react": "^0.548.0", "prism-react-renderer": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a3609d3..bbcc6b9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: docusaurus-theme-openapi-docs: specifier: ^4.4.0 version: 4.5.1(8a62557045c2a565bc1454425a44ada4) + feed: + specifier: ^4.2.2 + version: 4.2.2 js-yaml: specifier: ^4.1.0 version: 4.1.0 diff --git a/scripts/deprecations-lib.mjs b/scripts/deprecations-lib.mjs new file mode 100644 index 00000000..8449252b --- /dev/null +++ b/scripts/deprecations-lib.mjs @@ -0,0 +1,581 @@ +/** + * Library functions for extracting x-glean-deprecated properties from OpenAPI specs + * + * This module exports all the functions used by generate-deprecations.mjs + * for testing purposes. + */ + +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { fileURLToPath } from 'url'; + +/** + * Extra deprecations for testing purposes. + * Add entries here to "fake" deprecations without modifying the OpenAPI specs. + * Each entry should be an EndpointGroup with method, path, and deprecations array. + * + * Example: + * { + * method: 'POST', + * path: '/api/v1/example', + * deprecations: [ + * { + * id: 'test-deprecation-1', + * type: 'field', + * name: 'exampleField', + * message: 'This field is deprecated for testing.', + * introduced: '2025-01-01', + * removal: '2025-07-15', + * }, + * ], + * } + * + * @type {EndpointGroup[]} + */ +export const EXTRA_DEPRECATIONS = []; + +/** + * @typedef {Object} XGleanDeprecated + * @property {string} id - UUID for internal bookkeeping + * @property {string} message - Customer-facing explanation + * @property {string} introduced - Date deprecation was introduced (YYYY-MM-DD) + * @property {string} removal - Date feature will be removed (YYYY-MM-DD) + * @property {string} [docs] - Additional documentation or URL + */ + +/** + * @typedef {Object} XGleanDeprecatedEnumValue + * @property {string} id - UUID for internal bookkeeping + * @property {string} 'enum-value' - The specific enum value being deprecated + * @property {string} message - Customer-facing explanation + * @property {string} introduced - Date deprecation was introduced (YYYY-MM-DD) + * @property {string} [removal] - Date feature will be removed (YYYY-MM-DD) + * @property {string} [docs] - Additional documentation or URL + */ + +/** + * @typedef {'endpoint' | 'field' | 'parameter' | 'enum-value'} DeprecationType + */ + +/** + * @typedef {Object} DeprecationItem + * @property {string} id + * @property {DeprecationType} type + * @property {string} name + * @property {string} message + * @property {string} introduced + * @property {string} removal + * @property {string} [docs] + * @property {string} [enumValue] - For enum-value deprecations, the specific value being deprecated + */ + +/** + * @typedef {Object} EndpointGroup + * @property {string} method + * @property {string} path + * @property {DeprecationItem[]} deprecations + */ + +/** + * Convert x-glean-deprecated to DeprecationItem + * @param {XGleanDeprecated} deprecated + * @param {DeprecationType} type + * @param {string} name + * @returns {DeprecationItem} + */ +export function toDeprecationItem(deprecated, type, name) { + const item = { + id: deprecated.id, + type, + name, + message: deprecated.message, + introduced: deprecated.introduced, + removal: deprecated.removal, + }; + + if (deprecated.docs) { + item.docs = deprecated.docs; + } + + return item; +} + +/** + * Convert x-glean-deprecated enum value array item to DeprecationItem + * @param {XGleanDeprecatedEnumValue} deprecated + * @param {string} fieldName - The field containing the enum + * @returns {DeprecationItem} + */ +export function toEnumValueDeprecationItem(deprecated, fieldName) { + const item = { + id: deprecated.id, + type: 'enum-value', + name: fieldName, + enumValue: deprecated['enum-value'], + message: deprecated.message, + introduced: deprecated.introduced, + removal: deprecated.removal, + }; + + if (deprecated.docs) { + item.docs = deprecated.docs; + } + + return item; +} + +/** + * Resolve a $ref pointer to the actual schema + * @param {Object} schemaOrRef - A schema object or $ref pointer + * @param {Object} spec - The full OpenAPI spec for resolving refs + * @returns {Object} The resolved schema + */ +export function resolveRef(schemaOrRef, spec) { + if (!schemaOrRef || typeof schemaOrRef !== 'object') { + return schemaOrRef; + } + + if (schemaOrRef.$ref) { + // Parse the $ref pointer (e.g., "#/components/schemas/Activity") + const refPath = schemaOrRef.$ref; + if (refPath.startsWith('#/')) { + const parts = refPath.slice(2).split('/'); + let resolved = spec; + for (const part of parts) { + resolved = resolved?.[part]; + } + return resolved; + } + } + + return schemaOrRef; +} + +/** + * Process x-glean-deprecated value which can be either an object or array format + * @param {XGleanDeprecated | XGleanDeprecatedEnumValue[]} deprecated + * @param {string} fieldName - The field name for context + * @returns {DeprecationItem[]} + */ +export function processDeprecatedValue(deprecated, fieldName) { + const deprecations = []; + + if (Array.isArray(deprecated)) { + // Array format: enum value deprecations + for (const enumDeprecation of deprecated) { + if (enumDeprecation['enum-value']) { + deprecations.push( + toEnumValueDeprecationItem(enumDeprecation, fieldName), + ); + } + } + } else if (deprecated && typeof deprecated === 'object') { + // Object format: standard field/parameter/endpoint deprecation + deprecations.push(toDeprecationItem(deprecated, 'field', fieldName)); + } + + return deprecations; +} + +/** + * Extract deprecations from schema properties recursively + * @param {Object} schema + * @param {Object} spec - The full OpenAPI spec for resolving refs + * @param {string} parentPath - Current path in the schema (for nested properties) + * @param {Set} visited - Set of visited $ref paths to prevent infinite recursion + * @returns {DeprecationItem[]} + */ +export function extractSchemaDeprecations( + schema, + spec, + parentPath = '', + visited = new Set(), +) { + const deprecations = []; + + if (!schema || typeof schema !== 'object') { + return deprecations; + } + + // Handle $ref - resolve and recurse + if (schema.$ref) { + if (visited.has(schema.$ref)) { + return deprecations; // Prevent infinite recursion + } + visited.add(schema.$ref); + const resolved = resolveRef(schema, spec); + if (resolved) { + deprecations.push( + ...extractSchemaDeprecations(resolved, spec, parentPath, visited), + ); + } + return deprecations; + } + + // Check if this schema itself has x-glean-deprecated + if (schema['x-glean-deprecated']) { + // This handles the case where a schema ref is deprecated + deprecations.push( + ...processDeprecatedValue( + schema['x-glean-deprecated'], + parentPath || 'schema', + ), + ); + } + + // Check properties + if (schema.properties && typeof schema.properties === 'object') { + for (const [propName, propSchema] of Object.entries(schema.properties)) { + const fullPath = parentPath ? `${parentPath}.${propName}` : propName; + + if (propSchema['x-glean-deprecated']) { + deprecations.push( + ...processDeprecatedValue(propSchema['x-glean-deprecated'], propName), + ); + } + + // Recursively check nested objects - pass the original propSchema so $ref handling works + if (propSchema.properties || propSchema.items || propSchema.$ref) { + deprecations.push( + ...extractSchemaDeprecations(propSchema, spec, fullPath, visited), + ); + } + } + } + + // Check items (for arrays) + if (schema.items) { + deprecations.push( + ...extractSchemaDeprecations(schema.items, spec, parentPath, visited), + ); + } + + // Check allOf, anyOf, oneOf + for (const keyword of ['allOf', 'anyOf', 'oneOf']) { + if (Array.isArray(schema[keyword])) { + for (const subSchema of schema[keyword]) { + deprecations.push( + ...extractSchemaDeprecations(subSchema, spec, parentPath, visited), + ); + } + } + } + + return deprecations; +} + +/** + * Extract deprecations from request body + * @param {Object} requestBody + * @param {Object} spec - The full OpenAPI spec for resolving refs + * @returns {DeprecationItem[]} + */ +export function extractRequestBodyDeprecations(requestBody, spec) { + const deprecations = []; + + if (!requestBody?.content) { + return deprecations; + } + + for (const mediaType of Object.values(requestBody.content)) { + if (mediaType.schema) { + deprecations.push(...extractSchemaDeprecations(mediaType.schema, spec)); + } + } + + return deprecations; +} + +/** + * Extract deprecations from responses + * @param {Object} responses + * @param {Object} spec - The full OpenAPI spec for resolving refs + * @returns {DeprecationItem[]} + */ +export function extractResponseDeprecations(responses, spec) { + const deprecations = []; + + if (!responses) { + return deprecations; + } + + for (const response of Object.values(responses)) { + if (response.content) { + for (const mediaType of Object.values(response.content)) { + if (mediaType.schema) { + deprecations.push( + ...extractSchemaDeprecations(mediaType.schema, spec), + ); + } + } + } + } + + return deprecations; +} + +/** + * Extract deprecations from an operation + * @param {Object} operation + * @param {string} method + * @param {string} path + * @param {Object} spec - The full OpenAPI spec for resolving refs + * @returns {DeprecationItem[]} + */ +export function extractOperationDeprecations(operation, method, path, spec) { + const deprecations = []; + + // Check if the operation itself is deprecated + if (operation['x-glean-deprecated']) { + deprecations.push( + toDeprecationItem( + operation['x-glean-deprecated'], + 'endpoint', + `${method.toUpperCase()} ${path}`, + ), + ); + } + + // Check parameters + if (Array.isArray(operation.parameters)) { + for (const param of operation.parameters) { + if (param['x-glean-deprecated']) { + const deprecated = param['x-glean-deprecated']; + if (Array.isArray(deprecated)) { + // Array format: enum value deprecations for this parameter + for (const enumDeprecation of deprecated) { + if (enumDeprecation['enum-value']) { + deprecations.push( + toEnumValueDeprecationItem(enumDeprecation, param.name), + ); + } + } + } else { + // Object format: the parameter itself is deprecated + deprecations.push( + toDeprecationItem(deprecated, 'parameter', param.name), + ); + } + } + } + } + + // Check request body + if (operation.requestBody) { + deprecations.push( + ...extractRequestBodyDeprecations(operation.requestBody, spec), + ); + } + + // Check responses + if (operation.responses) { + deprecations.push( + ...extractResponseDeprecations(operation.responses, spec), + ); + } + + return deprecations; +} + +/** + * Parse an OpenAPI spec and extract all deprecations + * @param {string} specPath + * @returns {EndpointGroup[]} + */ +export function parseSpec(specPath) { + console.log(`📖 Reading spec: ${specPath}`); + + if (!fs.existsSync(specPath)) { + console.warn(`⚠️ Spec file not found: ${specPath}`); + return []; + } + + const content = fs.readFileSync(specPath, 'utf8'); + const spec = yaml.load(content); + + if (!spec.paths) { + console.warn(`⚠️ No paths found in spec: ${specPath}`); + return []; + } + + const endpointGroups = []; + const httpMethods = ['get', 'post', 'put', 'patch', 'delete']; + + for (const [pathKey, pathItem] of Object.entries(spec.paths)) { + // Check path-level parameters + const pathLevelParams = pathItem.parameters || []; + + for (const method of httpMethods) { + const operation = pathItem[method]; + if (!operation) continue; + + // Merge path-level parameters + const allParams = [...pathLevelParams, ...(operation.parameters || [])]; + const operationWithParams = { ...operation, parameters: allParams }; + + const deprecations = extractOperationDeprecations( + operationWithParams, + method, + pathKey, + spec, + ); + + if (deprecations.length > 0) { + // Deduplicate by id + const uniqueDeprecations = []; + const seenIds = new Set(); + + for (const dep of deprecations) { + if (!seenIds.has(dep.id)) { + seenIds.add(dep.id); + uniqueDeprecations.push(dep); + } + } + + endpointGroups.push({ + method: method.toUpperCase(), + path: pathKey, + deprecations: uniqueDeprecations, + }); + } + } + } + + console.log(` Found ${endpointGroups.length} endpoints with deprecations`); + return endpointGroups; +} + +/** + * Parse an OpenAPI spec from a YAML string (for testing) + * @param {string} yamlContent - The YAML content as a string + * @returns {EndpointGroup[]} + */ +export function parseSpecFromString(yamlContent) { + const spec = yaml.load(yamlContent); + + if (!spec.paths) { + return []; + } + + const endpointGroups = []; + const httpMethods = ['get', 'post', 'put', 'patch', 'delete']; + + for (const [pathKey, pathItem] of Object.entries(spec.paths)) { + // Check path-level parameters + const pathLevelParams = pathItem.parameters || []; + + for (const method of httpMethods) { + const operation = pathItem[method]; + if (!operation) continue; + + // Merge path-level parameters + const allParams = [...pathLevelParams, ...(operation.parameters || [])]; + const operationWithParams = { ...operation, parameters: allParams }; + + const deprecations = extractOperationDeprecations( + operationWithParams, + method, + pathKey, + spec, + ); + + if (deprecations.length > 0) { + // Deduplicate by id + const uniqueDeprecations = []; + const seenIds = new Set(); + + for (const dep of deprecations) { + if (!seenIds.has(dep.id)) { + seenIds.add(dep.id); + uniqueDeprecations.push(dep); + } + } + + endpointGroups.push({ + method: method.toUpperCase(), + path: pathKey, + deprecations: uniqueDeprecations, + }); + } + } + } + + return endpointGroups; +} + +/** + * Main function to generate deprecations.json + * @param {Object} options - Configuration options + * @param {string} [options.rootDir] - Root directory (defaults to computed from import.meta.url) + * @param {string[]} [options.specFiles] - Spec file paths (defaults to standard locations) + * @param {string} [options.outputFile] - Output file path (defaults to src/data/deprecations.json) + * @param {EndpointGroup[]} [options.extraDeprecations] - Extra deprecations to include + */ +export function generateDeprecations(options = {}) { + // Compute root directory + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const rootDir = options.rootDir || path.resolve(__dirname, '..'); + + // Compute spec files + const specFiles = options.specFiles || [ + path.join(rootDir, 'openapi/client/client-capitalized.yaml'), + path.join(rootDir, 'openapi/indexing/indexing-capitalized.yaml'), + ]; + + // Compute output file + const outputFile = + options.outputFile || path.join(rootDir, 'src/data/deprecations.json'); + + // Extra deprecations + const extraDeprecations = options.extraDeprecations || EXTRA_DEPRECATIONS; + + console.log('🔍 Scanning OpenAPI specs for deprecations...\n'); + + const allEndpoints = []; + + for (const specPath of specFiles) { + const endpoints = parseSpec(specPath); + allEndpoints.push(...endpoints); + } + + // Merge extra deprecations (for testing) + if (extraDeprecations.length > 0) { + console.log( + `\n📌 Adding ${extraDeprecations.length} extra deprecation group(s) for testing`, + ); + allEndpoints.push(...extraDeprecations); + } + + // Sort endpoints by path and method + allEndpoints.sort((a, b) => { + const pathCompare = a.path.localeCompare(b.path); + if (pathCompare !== 0) return pathCompare; + return a.method.localeCompare(b.method); + }); + + // Count total deprecations + const totalCount = allEndpoints.reduce( + (sum, ep) => sum + ep.deprecations.length, + 0, + ); + + const output = { + endpoints: allEndpoints, + generatedAt: new Date().toISOString(), + totalCount, + }; + + // Ensure output directory exists + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(outputFile, JSON.stringify(output, null, 2) + '\n'); + + console.log(`\n✅ Generated ${outputFile}`); + console.log(` Total endpoints with deprecations: ${allEndpoints.length}`); + console.log(` Total deprecation items: ${totalCount}`); +} diff --git a/scripts/deprecations-lib.test.mjs b/scripts/deprecations-lib.test.mjs new file mode 100644 index 00000000..90d29961 --- /dev/null +++ b/scripts/deprecations-lib.test.mjs @@ -0,0 +1,928 @@ +import { describe, it, expect } from 'vitest'; +import { + toDeprecationItem, + toEnumValueDeprecationItem, + resolveRef, + processDeprecatedValue, + extractSchemaDeprecations, + extractRequestBodyDeprecations, + extractResponseDeprecations, + extractOperationDeprecations, + parseSpecFromString, +} from './deprecations-lib.mjs'; + +describe('toDeprecationItem', () => { + it('should convert x-glean-deprecated to DeprecationItem', () => { + const deprecated = { + id: 'test-id-123', + message: 'This field is deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }; + + const result = toDeprecationItem(deprecated, 'field', 'testField'); + + expect(result).toEqual({ + id: 'test-id-123', + type: 'field', + name: 'testField', + message: 'This field is deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }); + }); + + it('should include docs if present', () => { + const deprecated = { + id: 'test-id-123', + message: 'This field is deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + docs: 'https://docs.example.com/migration', + }; + + const result = toDeprecationItem(deprecated, 'endpoint', 'POST /api/test'); + + expect(result.docs).toBe('https://docs.example.com/migration'); + expect(result.type).toBe('endpoint'); + }); +}); + +describe('toEnumValueDeprecationItem', () => { + it('should convert enum value deprecation to DeprecationItem', () => { + const deprecated = { + id: 'enum-id-456', + 'enum-value': 'OLD_VALUE', + message: 'Use NEW_VALUE instead', + introduced: '2025-02-01', + removal: '2025-08-01', + }; + + const result = toEnumValueDeprecationItem(deprecated, 'status'); + + expect(result).toEqual({ + id: 'enum-id-456', + type: 'enum-value', + name: 'status', + enumValue: 'OLD_VALUE', + message: 'Use NEW_VALUE instead', + introduced: '2025-02-01', + removal: '2025-08-01', + }); + }); + + it('should include docs if present', () => { + const deprecated = { + id: 'enum-id-456', + 'enum-value': 'OLD_VALUE', + message: 'Use NEW_VALUE instead', + introduced: '2025-02-01', + removal: '2025-08-01', + docs: 'https://docs.example.com/enums', + }; + + const result = toEnumValueDeprecationItem(deprecated, 'status'); + + expect(result.docs).toBe('https://docs.example.com/enums'); + }); +}); + +describe('resolveRef', () => { + it('should return non-object values as-is', () => { + expect(resolveRef(null, {})).toBe(null); + expect(resolveRef(undefined, {})).toBe(undefined); + expect(resolveRef('string', {})).toBe('string'); + expect(resolveRef(123, {})).toBe(123); + }); + + it('should return schema without $ref as-is', () => { + const schema = { type: 'string' }; + expect(resolveRef(schema, {})).toBe(schema); + }); + + it('should resolve $ref pointers', () => { + const spec = { + components: { + schemas: { + User: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }; + + const result = resolveRef({ $ref: '#/components/schemas/User' }, spec); + + expect(result).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }); + }); + + it('should return undefined for non-existent refs', () => { + const spec = { components: { schemas: {} } }; + const result = resolveRef( + { $ref: '#/components/schemas/NonExistent' }, + spec, + ); + expect(result).toBeUndefined(); + }); +}); + +describe('processDeprecatedValue', () => { + it('should process object format (standard deprecation)', () => { + const deprecated = { + id: 'dep-1', + message: 'Deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }; + + const result = processDeprecatedValue(deprecated, 'myField'); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('field'); + expect(result[0].name).toBe('myField'); + }); + + it('should process array format (enum value deprecations)', () => { + const deprecated = [ + { + id: 'enum-1', + 'enum-value': 'VALUE_A', + message: 'Use VALUE_B', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + { + id: 'enum-2', + 'enum-value': 'VALUE_C', + message: 'Use VALUE_D', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + ]; + + const result = processDeprecatedValue(deprecated, 'enumField'); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('enum-value'); + expect(result[0].enumValue).toBe('VALUE_A'); + expect(result[1].enumValue).toBe('VALUE_C'); + }); + + it('should skip array items without enum-value', () => { + const deprecated = [ + { + id: 'enum-1', + 'enum-value': 'VALUE_A', + message: 'Valid', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + { + id: 'invalid', + message: 'No enum-value property', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + ]; + + const result = processDeprecatedValue(deprecated, 'enumField'); + + expect(result).toHaveLength(1); + expect(result[0].enumValue).toBe('VALUE_A'); + }); +}); + +describe('extractSchemaDeprecations', () => { + it('should return empty array for non-object schemas', () => { + expect(extractSchemaDeprecations(null, {})).toEqual([]); + expect(extractSchemaDeprecations(undefined, {})).toEqual([]); + expect(extractSchemaDeprecations('string', {})).toEqual([]); + }); + + it('should extract deprecation from schema with x-glean-deprecated', () => { + const schema = { + type: 'object', + 'x-glean-deprecated': { + id: 'schema-dep', + message: 'This schema is deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }; + + const result = extractSchemaDeprecations(schema, {}); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('schema-dep'); + }); + + it('should extract deprecations from properties', () => { + const schema = { + type: 'object', + properties: { + oldField: { + type: 'string', + 'x-glean-deprecated': { + id: 'field-dep', + message: 'Use newField instead', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + normalField: { + type: 'string', + }, + }, + }; + + const result = extractSchemaDeprecations(schema, {}); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('oldField'); + }); + + it('should extract deprecations from nested properties', () => { + const schema = { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + deepField: { + type: 'string', + 'x-glean-deprecated': { + id: 'deep-dep', + message: 'Deeply nested deprecation', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }, + }; + + const result = extractSchemaDeprecations(schema, {}); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('deepField'); + }); + + it('should extract deprecations from array items', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + itemField: { + type: 'string', + 'x-glean-deprecated': { + id: 'item-dep', + message: 'Array item field deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }; + + const result = extractSchemaDeprecations(schema, {}); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('itemField'); + }); + + it('should resolve $ref and extract deprecations', () => { + const spec = { + components: { + schemas: { + DeprecatedModel: { + type: 'object', + properties: { + legacyField: { + type: 'string', + 'x-glean-deprecated': { + id: 'ref-dep', + message: 'From referenced schema', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }, + }, + }; + + const schema = { $ref: '#/components/schemas/DeprecatedModel' }; + const result = extractSchemaDeprecations(schema, spec); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('ref-dep'); + }); + + it('should handle circular references without infinite loop', () => { + const spec = { + components: { + schemas: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/Node' }, + }, + }, + }, + }, + }, + }; + + const schema = { $ref: '#/components/schemas/Node' }; + + // Should not throw or hang + const result = extractSchemaDeprecations(schema, spec); + expect(result).toEqual([]); + }); + + it('should extract deprecations from allOf schemas', () => { + const schema = { + allOf: [ + { + type: 'object', + properties: { + baseField: { + type: 'string', + 'x-glean-deprecated': { + id: 'allof-dep', + message: 'From allOf', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + { + type: 'object', + properties: { + extendedField: { type: 'string' }, + }, + }, + ], + }; + + const result = extractSchemaDeprecations(schema, {}); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('allof-dep'); + }); +}); + +describe('extractRequestBodyDeprecations', () => { + it('should return empty array for missing content', () => { + expect(extractRequestBodyDeprecations(null, {})).toEqual([]); + expect(extractRequestBodyDeprecations({}, {})).toEqual([]); + expect(extractRequestBodyDeprecations({ content: null }, {})).toEqual([]); + }); + + it('should extract deprecations from request body schema', () => { + const requestBody = { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + deprecatedInput: { + type: 'string', + 'x-glean-deprecated': { + id: 'req-body-dep', + message: 'Request body field deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }, + }, + }; + + const result = extractRequestBodyDeprecations(requestBody, {}); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('req-body-dep'); + }); +}); + +describe('extractResponseDeprecations', () => { + it('should return empty array for missing responses', () => { + expect(extractResponseDeprecations(null, {})).toEqual([]); + expect(extractResponseDeprecations(undefined, {})).toEqual([]); + }); + + it('should extract deprecations from response schema', () => { + const responses = { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + legacyResult: { + type: 'string', + 'x-glean-deprecated': { + id: 'response-dep', + message: 'Response field deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }, + }, + }, + }; + + const result = extractResponseDeprecations(responses, {}); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('response-dep'); + }); +}); + +describe('extractOperationDeprecations', () => { + it('should extract endpoint deprecation', () => { + const operation = { + 'x-glean-deprecated': { + id: 'endpoint-dep', + message: 'This endpoint is deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }; + + const result = extractOperationDeprecations( + operation, + 'post', + '/api/v1/legacy', + {}, + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('endpoint'); + expect(result[0].name).toBe('POST /api/v1/legacy'); + }); + + it('should extract parameter deprecation', () => { + const operation = { + parameters: [ + { + name: 'oldParam', + in: 'query', + 'x-glean-deprecated': { + id: 'param-dep', + message: 'Use newParam instead', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + ], + }; + + const result = extractOperationDeprecations( + operation, + 'get', + '/api/v1/search', + {}, + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('parameter'); + expect(result[0].name).toBe('oldParam'); + }); + + it('should extract parameter enum value deprecation', () => { + const operation = { + parameters: [ + { + name: 'sortBy', + in: 'query', + 'x-glean-deprecated': [ + { + id: 'param-enum-dep', + 'enum-value': 'LEGACY_SORT', + message: 'Use NEW_SORT', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + ], + }, + ], + }; + + const result = extractOperationDeprecations( + operation, + 'get', + '/api/v1/list', + {}, + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('enum-value'); + expect(result[0].name).toBe('sortBy'); + expect(result[0].enumValue).toBe('LEGACY_SORT'); + }); + + it('should extract deprecations from request body and responses', () => { + const operation = { + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + inputField: { + type: 'string', + 'x-glean-deprecated': { + id: 'input-dep', + message: 'Input deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + outputField: { + type: 'string', + 'x-glean-deprecated': { + id: 'output-dep', + message: 'Output deprecated', + introduced: '2025-01-01', + removal: '2025-07-01', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const result = extractOperationDeprecations( + operation, + 'post', + '/api/v1/process', + {}, + ); + + expect(result).toHaveLength(2); + expect(result.map((d) => d.id).sort()).toEqual(['input-dep', 'output-dep']); + }); +}); + +describe('parseSpecFromString', () => { + it('should return empty array for spec without paths', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +`; + const result = parseSpecFromString(yaml); + expect(result).toEqual([]); + }); + + it('should extract endpoint deprecation from YAML spec', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/legacy: + post: + summary: Legacy endpoint + x-glean-deprecated: + id: legacy-endpoint + message: Use /api/v2/new instead + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].method).toBe('POST'); + expect(result[0].path).toBe('/api/v1/legacy'); + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].type).toBe('endpoint'); + }); + + it('should extract field deprecation from request body', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/users: + post: + summary: Create user + requestBody: + content: + application/json: + schema: + type: object + properties: + username: + type: string + legacyId: + type: string + x-glean-deprecated: + id: legacy-id-field + message: Use uuid instead + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].type).toBe('field'); + expect(result[0].deprecations[0].name).toBe('legacyId'); + }); + + it('should extract parameter deprecation', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/search: + get: + summary: Search + parameters: + - name: oldFilter + in: query + schema: + type: string + x-glean-deprecated: + id: old-filter-param + message: Use filter instead + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].type).toBe('parameter'); + expect(result[0].deprecations[0].name).toBe('oldFilter'); + }); + + it('should extract enum value deprecation', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/items: + get: + summary: List items + parameters: + - name: sortOrder + in: query + schema: + type: string + enum: [ASC, DESC, LEGACY_DEFAULT] + x-glean-deprecated: + - id: legacy-sort-value + enum-value: LEGACY_DEFAULT + message: Use ASC or DESC + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].type).toBe('enum-value'); + expect(result[0].deprecations[0].enumValue).toBe('LEGACY_DEFAULT'); + }); + + it('should resolve $ref and extract deprecations', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/data: + post: + summary: Submit data + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DataRequest' + responses: + 200: + description: Success +components: + schemas: + DataRequest: + type: object + properties: + value: + type: string + deprecatedField: + type: string + x-glean-deprecated: + id: ref-resolved-dep + message: Field in referenced schema deprecated + introduced: "2025-01-01" + removal: "2025-07-01" +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].id).toBe('ref-resolved-dep'); + }); + + it('should deduplicate deprecations by id', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/data: + post: + summary: Submit data + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SharedModel' + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/SharedModel' +components: + schemas: + SharedModel: + type: object + properties: + sharedField: + type: string + x-glean-deprecated: + id: shared-dep + message: Shared field deprecated + introduced: "2025-01-01" + removal: "2025-07-01" +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + // Should deduplicate - same schema referenced in request and response + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].id).toBe('shared-dep'); + }); + + it('should handle path-level parameters', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/resources/{id}: + parameters: + - name: legacyHeader + in: header + schema: + type: string + x-glean-deprecated: + id: path-level-param + message: Path-level param deprecated + introduced: "2025-01-01" + removal: "2025-07-01" + get: + summary: Get resource + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].deprecations).toHaveLength(1); + expect(result[0].deprecations[0].id).toBe('path-level-param'); + }); + + it('should handle multiple HTTP methods with deprecations', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/resource: + get: + summary: Get resource + x-glean-deprecated: + id: get-dep + message: GET deprecated + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success + post: + summary: Create resource + x-glean-deprecated: + id: post-dep + message: POST deprecated + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.method).sort()).toEqual(['GET', 'POST']); + }); + + it('should not include endpoints without deprecations', () => { + const yaml = ` +openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /api/v1/healthy: + get: + summary: Health check + responses: + 200: + description: Success + /api/v1/legacy: + get: + summary: Legacy endpoint + x-glean-deprecated: + id: legacy-dep + message: Deprecated + introduced: "2025-01-01" + removal: "2025-07-01" + responses: + 200: + description: Success +`; + const result = parseSpecFromString(yaml); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('/api/v1/legacy'); + }); +}); diff --git a/scripts/generate-deprecations-rss.mjs b/scripts/generate-deprecations-rss.mjs new file mode 100644 index 00000000..da901be4 --- /dev/null +++ b/scripts/generate-deprecations-rss.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +/** + * Generate deprecations RSS feed from deprecations.json + * + * This script reads the deprecations data and generates an RSS 2.0 feed + * for subscribers to track API deprecation announcements. + * + * Usage: node scripts/generate-deprecations-rss.mjs + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Feed } from 'feed'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, '..'); + +const DEPRECATIONS_DATA_FILE = path.join( + ROOT_DIR, + 'src', + 'data', + 'deprecations.json', +); +const RSS_OUTPUT_DIR = path.join(ROOT_DIR, 'static'); +const RSS_OUTPUT_FILE = path.join(RSS_OUTPUT_DIR, 'deprecations.xml'); + +const SITE_URL = 'https://developers.glean.com'; +const DEPRECATIONS_URL = `${SITE_URL}/deprecations`; + +/** + * Check if RSS regeneration is needed based on file modification times + */ +function needsRegeneration(dataFile, outputFile) { + if (!fs.existsSync(outputFile)) { + return true; + } + + if (!fs.existsSync(dataFile)) { + return true; + } + + const dataStats = fs.statSync(dataFile); + const outputStats = fs.statSync(outputFile); + + return dataStats.mtime > outputStats.mtime; +} + +/** + * Get location label from endpoint path + */ +function getLocationFromPath(endpointPath) { + if (endpointPath.startsWith('/indexing/')) { + return 'Indexing API'; + } + return 'Client API'; +} + +/** + * Generate RSS feed from deprecations data + */ +function generateRss() { + if (!fs.existsSync(DEPRECATIONS_DATA_FILE)) { + console.error( + 'Deprecations data file does not exist:', + DEPRECATIONS_DATA_FILE, + ); + console.log( + 'Run "pnpm generate:deprecations" first to generate the data file.', + ); + process.exit(1); + } + + if (!needsRegeneration(DEPRECATIONS_DATA_FILE, RSS_OUTPUT_FILE)) { + console.log( + 'No changes detected in deprecations data, skipping RSS generation', + ); + return; + } + + const deprecationsData = JSON.parse( + fs.readFileSync(DEPRECATIONS_DATA_FILE, 'utf-8'), + ); + const { endpoints, generatedAt } = deprecationsData; + + const feed = new Feed({ + title: 'Glean API Deprecations', + description: + 'Deprecated endpoints, fields, and parameters in Glean\'s APIs', + id: SITE_URL, + link: DEPRECATIONS_URL, + language: 'en', + image: `${SITE_URL}/img/glean-developer-logo-light.svg`, + favicon: `${SITE_URL}/img/favicon.png`, + copyright: `Copyright © ${new Date().getFullYear()} Glean`, + generator: 'Glean Developer Site', + feedLinks: { + rss2: `${SITE_URL}/deprecations.xml`, + }, + author: { + name: 'Glean', + link: 'https://glean.com', + }, + updated: new Date(generatedAt), + }); + + // Flatten all deprecations from all endpoints and sort by introduced date + const allDeprecations = []; + + for (const endpoint of endpoints) { + const location = getLocationFromPath(endpoint.path); + + for (const deprecation of endpoint.deprecations) { + allDeprecations.push({ + ...deprecation, + endpoint: { + method: endpoint.method, + path: endpoint.path, + }, + location, + }); + } + } + + // Sort by introduced date (most recent first) + allDeprecations.sort( + (a, b) => new Date(b.introduced) - new Date(a.introduced), + ); + + // Add each deprecation as an RSS item + for (const dep of allDeprecations) { + const title = `Deprecation: ${dep.name} ${dep.type} on ${dep.endpoint.method} ${dep.endpoint.path}`; + const pubDate = new Date(dep.introduced); + + const descriptionHtml = `

${dep.message}

+

Removal date: ${dep.removal}

`; + + feed.addItem({ + title, + id: dep.id, + link: DEPRECATIONS_URL, + description: descriptionHtml, + author: [ + { + name: 'Glean', + link: 'https://glean.com', + }, + ], + date: pubDate, + category: [{ name: dep.location }], + }); + } + + // Ensure output directory exists + fs.mkdirSync(RSS_OUTPUT_DIR, { recursive: true }); + + // Write RSS feed + const rssXml = feed.rss2(); + fs.writeFileSync(RSS_OUTPUT_FILE, rssXml); + + console.log( + `Generated deprecations RSS feed with ${allDeprecations.length} entries at ${RSS_OUTPUT_FILE}`, + ); +} + +// Run if called directly +generateRss(); diff --git a/scripts/generate-deprecations.mjs b/scripts/generate-deprecations.mjs new file mode 100644 index 00000000..91485a38 --- /dev/null +++ b/scripts/generate-deprecations.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +/** + * Generate deprecations.json from OpenAPI specs by extracting x-glean-deprecated properties + * + * Usage: node scripts/generate-deprecations.mjs + */ + +import { generateDeprecations } from './deprecations-lib.mjs'; + +generateDeprecations(); diff --git a/scripts/generator/customMdGenerators.ts b/scripts/generator/customMdGenerators.ts index fa12b757..f94f5f32 100644 --- a/scripts/generator/customMdGenerators.ts +++ b/scripts/generator/customMdGenerators.ts @@ -1,3 +1,5 @@ +import fs from 'fs'; +import path from 'path'; import { createAuthorization } from 'docusaurus-plugin-openapi-docs/lib/markdown/createAuthorization'; import { createCallbacks } from 'docusaurus-plugin-openapi-docs/lib/markdown/createCallbacks'; import { createDeprecationNotice } from 'docusaurus-plugin-openapi-docs/lib/markdown/createDeprecationNotice'; @@ -16,6 +18,63 @@ import { Props, } from 'docusaurus-plugin-openapi-docs/lib/markdown/utils'; import type { ApiPageMetadata } from 'docusaurus-plugin-openapi-docs/lib/types'; +import type { + DeprecationItem, + EndpointGroup, + DeprecationsData, +} from '../../src/types/deprecations'; + +function loadDeprecationsData(): DeprecationsData { + const deprecationsPath = path.resolve( + __dirname, + '../../src/data/deprecations.json', + ); + try { + const content = fs.readFileSync(deprecationsPath, 'utf8'); + return JSON.parse(content); + } catch { + return { endpoints: [], generatedAt: '', totalCount: 0 }; + } +} + +function normalizePath(p: string): string { + let normalized = p.replace(/\/+$/, ''); + normalized = normalized.replace(/^\/rest/, ''); + return normalized.toLowerCase(); +} + +function isDeprecationActive(deprecation: DeprecationItem): boolean { + const [year, month, day] = deprecation.removal.split('-').map(Number); + const removalDate = new Date(year, month - 1, day); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return removalDate >= today; +} + +function getActiveDeprecationsForEndpoint( + method: string, + endpointPath: string, + data: DeprecationsData, +): DeprecationItem[] { + const normalizedInputPath = normalizePath(endpointPath); + const normalizedInputMethod = method.toUpperCase(); + + for (const endpoint of data.endpoints) { + const normalizedEndpointPath = normalizePath(endpoint.path); + const normalizedEndpointMethod = endpoint.method.toUpperCase(); + + if ( + normalizedEndpointMethod === normalizedInputMethod && + normalizedEndpointPath === normalizedInputPath + ) { + return endpoint.deprecations.filter(isDeprecationActive); + } + } + + return []; +} + +const deprecationsData = loadDeprecationsData(); interface RequestBodyProps { title: string; @@ -53,6 +112,26 @@ function createPreviewNotice({ ); } +function createApiDeprecations( + method: string, + endpointPath: string, +): string | undefined { + const activeDeprecations = getActiveDeprecationsForEndpoint( + method, + endpointPath, + deprecationsData, + ); + + if (activeDeprecations.length === 0) { + return undefined; + } + + // Serialize deprecations as JSON for the component + const deprecationsJson = JSON.stringify(activeDeprecations); + + return `\n\n`; +} + export function customApiMdGenerator({ title, api, @@ -66,7 +145,7 @@ export function customApiMdGenerator({ 'x-beta': xBeta, description, method, - path, + path: endpointPath, extensions, parameters, requestBody, @@ -74,6 +153,9 @@ export function customApiMdGenerator({ callbacks, } = api as any; // Type assertion to access extension properties + const deprecationsMarkdown = createApiDeprecations(method, endpointPath); + const hasDeprecations = deprecationsMarkdown !== undefined; + return render([ `import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";\n`, `import ParamsDetails from "@theme/ParamsDetails";\n`, @@ -81,9 +163,13 @@ export function customApiMdGenerator({ `import StatusCodes from "@theme/StatusCodes";\n`, `import OperationTabs from "@theme/OperationTabs";\n`, `import TabItem from "@theme/TabItem";\n`, - `import Heading from "@theme/Heading";\n\n`, + `import Heading from "@theme/Heading";\n`, + hasDeprecations + ? `import ApiDeprecations from "@site/src/theme/ApiDeprecations";\n\n` + : '\n', createHeading(title), - createMethodEndpoint(method, path), + createMethodEndpoint(method, endpointPath), + deprecationsMarkdown, infoPath && createAuthorization(infoPath), frontMatter.show_extensions ? createVendorExtensions(extensions) diff --git a/sidebars.ts b/sidebars.ts index 806cf717..966771da 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -61,6 +61,27 @@ const baseSidebars: SidebarsConfig = { iconSet: 'feather', }, }, + { + type: 'category', + label: 'Deprecations', + customProps: { + icon: 'AlertTriangle', + iconSet: 'feather', + flag: 'x-glean-deprecated', + }, + items: [ + { + type: 'doc', + id: 'deprecations/overview', + label: 'Overview', + }, + { + type: 'doc', + id: 'deprecations/index', + label: 'Deprecation List', + }, + ], + }, ], }, { diff --git a/src/components/Deprecations/DeprecationEntry.module.css b/src/components/Deprecations/DeprecationEntry.module.css new file mode 100644 index 00000000..0de46abf --- /dev/null +++ b/src/components/Deprecations/DeprecationEntry.module.css @@ -0,0 +1,120 @@ +.deprecationEntry { + padding: 0.75rem 0; + border-bottom: 1px solid var(--ifm-color-emphasis-100); +} + +.deprecationEntry:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.deprecationEntry:first-child { + padding-top: 0; +} + +.entryHeader { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.entryTitle { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.entryName { + font-size: 1rem; + font-weight: 600; + color: var(--ifm-color-emphasis-900); + background: var(--ifm-color-emphasis-100); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.entryLocation { + font-size: 0.875rem; + color: var(--ifm-color-emphasis-600); +} + +.entryBody { + margin-bottom: 0.5rem; +} + +.entryMessage { + color: var(--ifm-color-emphasis-700); + line-height: 1.6; + margin: 0; +} + +.entryFooter { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.entryDates { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--ifm-color-emphasis-600); +} + +.dateLabel time { + font-weight: 500; + color: var(--ifm-color-emphasis-700); +} + +.dateSeparator { + color: var(--ifm-color-emphasis-400); +} + +.removalDate { + color: var(--ifm-color-danger) !important; +} + +.migrationNote { + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--ifm-color-info-contrast-background); + border-left: 3px solid var(--ifm-color-info); + border-radius: 0 4px 4px 0; + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); + line-height: 1.5; +} + +.migrationLabel { + font-weight: 600; + color: var(--ifm-color-info-dark); + margin-right: 0.5rem; +} + +.docsLink { + font-size: 0.875rem; + font-weight: 500; + color: var(--ifm-color-primary); + text-decoration: none; +} + +.docsLink:hover { + text-decoration: underline; +} + +@media (max-width: 768px) { + .entryFooter { + flex-direction: column; + align-items: flex-start; + } + + .entryDates { + flex-wrap: wrap; + } +} diff --git a/src/components/Deprecations/DeprecationEntry.tsx b/src/components/Deprecations/DeprecationEntry.tsx new file mode 100644 index 00000000..bcdd6378 --- /dev/null +++ b/src/components/Deprecations/DeprecationEntry.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react'; +import type { DeprecationItem } from '../../types/deprecations'; +import styles from './DeprecationEntry.module.css'; + +interface DeprecationEntryProps { + entry: DeprecationItem; + showRemovalDate?: boolean; +} + +function isUrl(text: string | undefined): boolean { + if (!text) return false; + return /^https?:\/\/[^\s]+$/.test(text.trim()); +} + +export default function DeprecationEntry({ + entry, + showRemovalDate = false, +}: DeprecationEntryProps): ReactElement { + const docsIsUrl = isUrl(entry.docs); + + // Check if the deprecation has already been removed + const [year, month, day] = entry.removal.split('-').map(Number); + const removalDate = new Date(year, month - 1, day); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const isPast = removalDate < today; + + // Build display name: for enum-value deprecations, show "fieldName: enumValue" + const displayName = + entry.type === 'enum-value' && entry.enumValue + ? `${entry.name}: ${entry.enumValue}` + : entry.name; + + return ( +
+
+
+ {displayName} +
+
+ +
+

{entry.message}

+ {entry.docs && !docsIsUrl && ( +
+ Note: + {entry.docs} +
+ )} +
+ +
+
+ + Deprecation introduced on + + {(showRemovalDate || isPast) && ( + <> + | + + {isPast ? 'Removed on' : 'Will be removed on'}{' '} + + + + )} +
+ {docsIsUrl && ( + + More details + + )} +
+
+ ); +} diff --git a/src/components/Deprecations/DeprecationsEntries.module.css b/src/components/Deprecations/DeprecationsEntries.module.css new file mode 100644 index 00000000..c21ca32d --- /dev/null +++ b/src/components/Deprecations/DeprecationsEntries.module.css @@ -0,0 +1,81 @@ +.deprecationsEntries { + display: flex; + flex-direction: column; + gap: 0; +} + +.deprecationsEmpty { + text-align: center; + padding: 2rem; + color: var(--ifm-color-emphasis-600); +} + +.filterControls { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 0.75rem 1rem; + background: var(--ifm-color-emphasis-100); + border-radius: 8px; +} + +.filterGroup { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filterLabel { + font-size: 0.9375rem; + color: var(--ifm-color-emphasis-700); + font-weight: 500; +} + +.filterSelect { + padding: 0.375rem 0.75rem; + font-size: 0.9375rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background: var(--ifm-background-color); + color: var(--ifm-color-emphasis-800); + cursor: pointer; +} + +.filterSelect:hover { + border-color: var(--ifm-color-emphasis-400); +} + +.filterSelect:focus { + outline: none; + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 2px var(--ifm-color-primary-lightest); +} + +.dateSection { + margin-bottom: 2rem; +} + +.dateSection:last-child { + margin-bottom: 0; +} + +.dateSectionHeader { + font-size: 1.25rem; + font-weight: 600; + color: var(--ifm-color-emphasis-800); + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--ifm-color-emphasis-200); +} + +@media (max-width: 576px) { + .filterControls { + flex-direction: column; + align-items: flex-start; + } + + .dateSectionHeader { + font-size: 1.1rem; + } +} diff --git a/src/components/Deprecations/DeprecationsEntries.tsx b/src/components/Deprecations/DeprecationsEntries.tsx new file mode 100644 index 00000000..c20ee463 --- /dev/null +++ b/src/components/Deprecations/DeprecationsEntries.tsx @@ -0,0 +1,248 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { useHistory, useLocation } from '@docusaurus/router'; +import type { + EndpointGroup as EndpointGroupType, + DeprecationItem, +} from '../../types/deprecations'; +import EndpointGroup from './EndpointGroup'; +import styles from './DeprecationsEntries.module.css'; + +/** Fixed removal dates throughout the year */ +const REMOVAL_DATES = [ + { month: 0, day: 15 }, // Jan 15 + { month: 3, day: 15 }, // Apr 15 + { month: 6, day: 15 }, // Jul 15 + { month: 9, day: 15 }, // Oct 15 +]; + +/** Get the next 4 removal dates starting from today */ +function getNextRemovalDates(): Date[] { + const now = new Date(); + const dates: Date[] = []; + let year = now.getFullYear(); + + // Generate removal dates for this year and next + const allDates: Date[] = []; + for (let y = year; y <= year + 2; y++) { + for (const { month, day } of REMOVAL_DATES) { + allDates.push(new Date(y, month, day)); + } + } + + // Filter to only future dates and take next 4 + for (const date of allDates) { + if (date > now && dates.length < 4) { + dates.push(date); + } + } + + return dates; +} + +/** Format a date as ISO-8601 (YYYY-MM-DD) */ +function formatDateAsIso(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +type RemovalFilter = 'all' | 'past' | string; // string for specific dates like "2026-01-15" + +function isValidFilter(value: string | null, validDates: string[]): boolean { + if (value === null) return false; + if (value === 'all' || value === 'past') return true; + return validDates.includes(value); +} + +interface DateSection { + date: Date; + dateKey: string; + displayDate: string; + endpoints: EndpointGroupType[]; +} + +interface DeprecationsEntriesProps { + endpoints: EndpointGroupType[]; +} + +/** Parse a date string like "2026-01-15" into components */ +function parseDateString(dateStr: string): { + year: number; + month: number; + day: number; +} { + const [year, month, day] = dateStr.split('-').map(Number); + return { year, month: month - 1, day }; // month is 0-indexed +} + +/** Group deprecations by removal date, then by endpoint */ +function groupDeprecationsByDate( + endpoints: EndpointGroupType[], + removalDates: Date[], + filter: RemovalFilter, +): DateSection[] { + const now = new Date(); + const sections: DateSection[] = []; + + // Handle past deprecations filter + if (filter === 'past') { + const pastEndpoints = endpoints + .map((group) => ({ + ...group, + deprecations: group.deprecations.filter((dep) => { + const depDate = parseDateString(dep.removal); + const removalDate = new Date(depDate.year, depDate.month, depDate.day); + return removalDate <= now; + }), + })) + .filter((group) => group.deprecations.length > 0); + + if (pastEndpoints.length > 0) { + sections.push({ + date: now, + dateKey: 'past', + displayDate: 'Past Deprecations', + endpoints: pastEndpoints, + }); + } + return sections; + } + + // For specific date filter or "all" + const datesToShow = + filter === 'all' + ? removalDates + : removalDates.filter((d) => formatDateAsIso(d) === filter); + + for (const removalDate of datesToShow) { + const dateKey = formatDateAsIso(removalDate); + + // Get endpoints with deprecations matching this removal date + const matchingEndpoints = endpoints + .map((group) => ({ + ...group, + deprecations: group.deprecations.filter((dep) => { + // Compare using the date string directly to avoid timezone issues + const depDate = parseDateString(dep.removal); + return ( + depDate.year === removalDate.getFullYear() && + depDate.month === removalDate.getMonth() && + depDate.day === removalDate.getDate() + ); + }), + })) + .filter((group) => group.deprecations.length > 0); + + if (matchingEndpoints.length > 0) { + sections.push({ + date: removalDate, + dateKey, + displayDate: formatDateAsIso(removalDate), + endpoints: matchingEndpoints, + }); + } + } + + return sections; +} + +export default function DeprecationsEntries({ + endpoints, +}: DeprecationsEntriesProps): React.ReactElement { + const history = useHistory(); + const location = useLocation(); + + // Calculate the next 4 removal dates + const removalDates = useMemo(() => getNextRemovalDates(), []); + const validDateKeys = useMemo( + () => removalDates.map(formatDateAsIso), + [removalDates], + ); + + const getFilterFromUrl = (): RemovalFilter => { + const params = new URLSearchParams(location.search); + const filterParam = params.get('filter'); + return isValidFilter(filterParam, validDateKeys) ? filterParam : 'all'; + }; + + const [removalFilter, setRemovalFilter] = + useState(getFilterFromUrl); + + useEffect(() => { + const filterFromUrl = getFilterFromUrl(); + if (filterFromUrl !== removalFilter) { + setRemovalFilter(filterFromUrl); + } + }, [location.search, validDateKeys]); + + const handleFilterChange = (newFilter: RemovalFilter) => { + setRemovalFilter(newFilter); + const params = new URLSearchParams(location.search); + if (newFilter === 'all') { + params.delete('filter'); + } else { + params.set('filter', newFilter); + } + const newSearch = params.toString(); + history.push({ + pathname: location.pathname, + search: newSearch ? `?${newSearch}` : '', + }); + }; + + const dateSections = useMemo(() => { + return groupDeprecationsByDate(endpoints, removalDates, removalFilter); + }, [endpoints, removalDates, removalFilter]); + + return ( +
+
+
+ + +
+
+ + {dateSections.length === 0 ? ( +
+

No deprecations found matching the selected filter.

+
+ ) : ( + dateSections.map((section) => ( +
+

+ {section.dateKey === 'past' + ? section.displayDate + : `Removal date: ${section.displayDate}`} +

+ {section.endpoints.map((group) => ( + + ))} +
+ )) + )} +
+ ); +} diff --git a/src/components/Deprecations/DeprecationsHeader.module.css b/src/components/Deprecations/DeprecationsHeader.module.css new file mode 100644 index 00000000..b290632f --- /dev/null +++ b/src/components/Deprecations/DeprecationsHeader.module.css @@ -0,0 +1,35 @@ +.header { + margin-bottom: 1.5rem; +} + +.title { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0; + font-size: 2.5rem; + font-weight: 700; + line-height: 1.2; +} + +.rssLink { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + border-radius: 0.375rem; + transition: all 0.2s ease-in-out; + text-decoration: none; + color: var(--ifm-color-primary); +} + +.rssLink:hover { + color: var(--ifm-color-primary-dark); + background-color: var(--ifm-color-primary-lightest); + text-decoration: none; + transform: scale(1.05); +} + +.rssIcon { + font-size: 1.25rem; +} diff --git a/src/components/Deprecations/DeprecationsHeader.tsx b/src/components/Deprecations/DeprecationsHeader.tsx new file mode 100644 index 00000000..253e4e1d --- /dev/null +++ b/src/components/Deprecations/DeprecationsHeader.tsx @@ -0,0 +1,26 @@ +import type React from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import { Rss } from 'react-feather'; +import styles from './DeprecationsHeader.module.css'; + +export default function DeprecationsHeader(): React.ReactElement { + const rssUrl = useBaseUrl('/deprecations.xml'); + + return ( +
+

+ Deprecations + + + +

+
+ ); +} diff --git a/src/components/Deprecations/DeprecationsList.module.css b/src/components/Deprecations/DeprecationsList.module.css new file mode 100644 index 00000000..469082b1 --- /dev/null +++ b/src/components/Deprecations/DeprecationsList.module.css @@ -0,0 +1,4 @@ +.deprecationsList { + max-width: none; + margin: 0; +} diff --git a/src/components/Deprecations/DeprecationsList.tsx b/src/components/Deprecations/DeprecationsList.tsx new file mode 100644 index 00000000..f318fbb7 --- /dev/null +++ b/src/components/Deprecations/DeprecationsList.tsx @@ -0,0 +1,18 @@ +import type React from 'react'; +import DeprecationsEntries from './DeprecationsEntries'; +import type { EndpointGroup } from '../../types/deprecations'; +import styles from './DeprecationsList.module.css'; + +interface DeprecationsListProps { + endpoints: EndpointGroup[]; +} + +export default function DeprecationsList({ + endpoints, +}: DeprecationsListProps): React.ReactElement { + return ( +
+ +
+ ); +} diff --git a/src/components/Deprecations/EndpointGroup.module.css b/src/components/Deprecations/EndpointGroup.module.css new file mode 100644 index 00000000..40c7bb77 --- /dev/null +++ b/src/components/Deprecations/EndpointGroup.module.css @@ -0,0 +1,117 @@ +.endpointGroup { + display: block; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 8px; + margin-bottom: 1.5rem; + overflow: hidden; +} + +.groupHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: var(--ifm-color-emphasis-100); + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.endpointInfo { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.methodBadge { + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + font-family: var(--ifm-font-family-monospace); + text-transform: uppercase; + min-width: 4rem; + text-align: center; +} + +.get { + background-color: rgba(34, 197, 94, 0.2); + color: #15803d; +} + +.post { + background-color: rgba(96, 165, 250, 0.2); + color: #1d4ed8; +} + +.put { + background-color: rgba(251, 191, 36, 0.2); + color: #a16207; +} + +.delete { + background-color: rgba(239, 68, 68, 0.2); + color: #dc2626; +} + +.patch { + background-color: rgba(139, 92, 246, 0.2); + color: #7c3aed; +} + +/* Dark mode overrides */ +:global([data-theme='dark']) .get { + color: #4ade80; +} + +:global([data-theme='dark']) .post { + color: #60a5fa; +} + +:global([data-theme='dark']) .put { + color: #fbbf24; +} + +:global([data-theme='dark']) .delete { + color: #f87171; +} + +:global([data-theme='dark']) .patch { + color: #a78bfa; +} + +.endpointPath { + font-size: 1rem; + font-weight: 600; + color: var(--ifm-color-emphasis-900); + background: none; + border: none; + padding: 0; + font-family: var(--ifm-font-family-monospace); +} + +.location { + font-size: 0.8125rem; + color: var(--ifm-color-emphasis-500); + background: var(--ifm-color-emphasis-200); + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.deprecationsList { + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.deprecationsList > :last-child { + margin-bottom: 0; +} + +@media (max-width: 768px) { + .groupHeader { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/components/Deprecations/EndpointGroup.tsx b/src/components/Deprecations/EndpointGroup.tsx new file mode 100644 index 00000000..7cc73ad2 --- /dev/null +++ b/src/components/Deprecations/EndpointGroup.tsx @@ -0,0 +1,44 @@ +import type React from 'react'; +import { + type EndpointGroup as EndpointGroupType, + getLocationFromPath, +} from '../../types/deprecations'; +import DeprecationEntry from './DeprecationEntry'; +import styles from './EndpointGroup.module.css'; + +interface EndpointGroupProps { + group: EndpointGroupType; + showRemovalDate?: boolean; +} + +function MethodBadge({ method }: { method: EndpointGroupType['method'] }): React.ReactElement { + return ( + + {method} + + ); +} + +export default function EndpointGroup({ + group, + showRemovalDate = false, +}: EndpointGroupProps): React.ReactElement { + const location = getLocationFromPath(group.path); + + return ( +
+
+
+ + {group.path} +
+ {location} +
+
+ {group.deprecations.map((deprecation) => ( + + ))} +
+
+ ); +} diff --git a/src/data/deprecations.json b/src/data/deprecations.json new file mode 100644 index 00000000..e4665460 --- /dev/null +++ b/src/data/deprecations.json @@ -0,0 +1,5 @@ +{ + "endpoints": [], + "generatedAt": "2025-12-10T21:44:01.253Z", + "totalCount": 0 +} diff --git a/src/theme/ApiDeprecations/index.tsx b/src/theme/ApiDeprecations/index.tsx new file mode 100644 index 00000000..b93da966 --- /dev/null +++ b/src/theme/ApiDeprecations/index.tsx @@ -0,0 +1,54 @@ +import type React from 'react'; +import { useContext } from 'react'; +import Link from '@docusaurus/Link'; +import { FeatureFlagsContext } from '../Root'; +import type { DeprecationItem } from '../../types/deprecations'; +import DeprecationEntry from '../../components/Deprecations/DeprecationEntry'; +import styles from './styles.module.css'; + +interface ApiDeprecationsProps { + deprecations: DeprecationItem[]; +} + +export default function ApiDeprecations({ + deprecations, +}: ApiDeprecationsProps): React.ReactElement | null { + const { isEnabled } = useContext(FeatureFlagsContext); + + if (!isEnabled('x-glean-deprecated')) { + return null; + } + + if (!deprecations || deprecations.length === 0) { + return null; + } + + const deprecationCount = deprecations.length; + const summaryText = + deprecationCount === 1 + ? '1 active deprecation' + : `${deprecationCount} active deprecations`; + + return ( +
+ + + {summaryText} + +
+ {deprecations.map((deprecation) => ( + + ))} +
+ + View All Deprecations + +
+
+
+ ); +} diff --git a/src/theme/ApiDeprecations/styles.module.css b/src/theme/ApiDeprecations/styles.module.css new file mode 100644 index 00000000..7017a885 --- /dev/null +++ b/src/theme/ApiDeprecations/styles.module.css @@ -0,0 +1,64 @@ +.deprecationsContainer { + margin: 1rem 0; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 8px; + overflow: hidden; +} + +.deprecationsSummary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + cursor: pointer; + font-weight: 500; + color: var(--ifm-color-warning-contrast-foreground); + list-style: none; + user-select: none; + background: var(--ifm-color-warning-contrast-background); + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.deprecationsSummary::-webkit-details-marker { + display: none; +} + +.deprecationsSummary::before { + content: '\25B6'; + font-size: 0.75rem; + transition: transform 0.2s ease; +} + +details[open] .deprecationsSummary::before { + transform: rotate(90deg); +} + +.warningIcon { + font-size: 1rem; +} + +.summaryText { + font-size: 1rem; +} + +.deprecationsList { + padding: 1rem 1.25rem; + background: var(--ifm-background-color); +} + +.viewAllContainer { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--ifm-color-emphasis-200); + text-align: right; +} + +.viewAllLink { + font-size: 0.875rem; + font-weight: 500; + color: var(--ifm-color-primary); +} + +.viewAllLink:hover { + text-decoration: underline; +} diff --git a/src/types/deprecations.ts b/src/types/deprecations.ts new file mode 100644 index 00000000..c61a29b2 --- /dev/null +++ b/src/types/deprecations.ts @@ -0,0 +1,57 @@ +export type DeprecationType = 'endpoint' | 'field' | 'parameter' | 'enum-value'; +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface DeprecationItem { + id: string; + type: DeprecationType; + name: string; + message: string; + introduced: string; + removal: string; + docs?: string; + /** + * For enum-value deprecations, the specific enum value being deprecated + */ + enumValue?: string; +} + +export interface EndpointGroup { + method: HttpMethod; + path: string; + deprecations: DeprecationItem[]; +} + +export function getLocationFromPath(path: string): string { + if (path.startsWith('/indexing/')) { + return 'Indexing API'; + } + return 'Client API'; +} + +export interface DeprecationsData { + endpoints: EndpointGroup[]; + generatedAt: string; + totalCount: number; +} + +export const TYPE_LABELS: Record = { + endpoint: 'Endpoint', + field: 'Field', + parameter: 'Parameter', + 'enum-value': 'Enum Value', +}; + +export const TYPE_COLORS: Record = { + endpoint: 'danger', + field: 'warning', + parameter: 'info', + 'enum-value': 'secondary', +}; + +export const METHOD_COLORS: Record = { + GET: 'success', + POST: 'primary', + PUT: 'warning', + PATCH: 'warning', + DELETE: 'danger', +}; diff --git a/src/utils/buildTimeFlags.ts b/src/utils/buildTimeFlags.ts index 2b6c5196..2ffca3e2 100644 --- a/src/utils/buildTimeFlags.ts +++ b/src/utils/buildTimeFlags.ts @@ -18,7 +18,8 @@ export function getBuildTimeFlags(): FeatureFlagsMap { for (const [key, value] of Object.entries(process.env)) { if (!key.startsWith('FF_')) continue; - const slug = key.replace(/^FF_/, '').toLowerCase().replace(/__/g, '-'); + // converts FF_FOO_BAZ_BAR to foo-baz-bar + const slug = key.replace(/^FF_/, '').toLowerCase().replace(/_/g, '-'); flags[slug] = { enabled: value === 'true' }; } diff --git a/static/deprecations.xml b/static/deprecations.xml new file mode 100644 index 00000000..75757758 --- /dev/null +++ b/static/deprecations.xml @@ -0,0 +1,18 @@ + + + + Glean API Deprecations + https://developers.glean.com/deprecations + Deprecated endpoints, fields, and parameters in Glean's APIs + Wed, 10 Dec 2025 21:44:01 GMT + https://validator.w3.org/feed/docs/rss2.html + Glean Developer Site + en + + Glean API Deprecations + https://developers.glean.com/img/glean-developer-logo-light.svg + https://developers.glean.com/deprecations + + Copyright © 2025 Glean + + \ No newline at end of file