diff --git a/docusaurus.config.ts b/docusaurus.config.ts index cb8d5e40..0f4d5478 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -198,8 +198,13 @@ const config: Config = { }, ], copyright: ` - Copyright Ā© ${new Date().getFullYear()} OpenTDF - +
+ Copyright Ā© ${new Date().getFullYear()} OpenTDF +
+ `, }, prism: { diff --git a/package-lock.json b/package-lock.json index 72df6c82..40cdc6ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4565,6 +4565,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5093,6 +5094,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5115,6 +5117,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5137,6 +5140,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5153,6 +5157,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5169,6 +5174,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5185,6 +5191,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5201,6 +5208,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5217,6 +5225,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5233,6 +5242,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5249,6 +5259,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5265,6 +5276,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5281,6 +5293,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5303,6 +5316,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5325,6 +5339,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5347,6 +5362,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5369,6 +5385,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5391,6 +5408,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5413,6 +5431,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -5432,6 +5451,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5451,6 +5471,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5470,6 +5491,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/package.json b/package.json index 6ac4bdec..23382a91 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "gen-api-docs-all": "docusaurus gen-api-docs all --all-versions", + "gen-api-docs-all": "docusaurus gen-api-docs all --all-versions && npm run update-openapi-index", "gen-api-docs-clean": "docusaurus clean-api-docs all", + "update-openapi-index": "tsx scripts/update-openapi-index.ts", "check-vendored-yaml": "tsx src/openapi/check-vendored-yaml.ts", "update-vendored-yaml": "tsx src/openapi/update-vendored-yaml.ts" }, diff --git a/scripts/update-openapi-index.ts b/scripts/update-openapi-index.ts new file mode 100644 index 00000000..23276c64 --- /dev/null +++ b/scripts/update-openapi-index.ts @@ -0,0 +1,16 @@ +/** + * Post-processing script to update the OpenAPI index page with correct links + * after the OpenAPI docs have been generated. + */ + +import { updateOpenApiIndex, renameInfoFilesToIndex } from '../src/openapi/preprocessing'; + +try { + console.log('šŸ”„ Running post-generation OpenAPI processing...'); + renameInfoFilesToIndex(); + updateOpenApiIndex(); + console.log('āœ… OpenAPI post-processing complete'); +} catch (error) { + console.error('āŒ OpenAPI post-processing failed:', error); + process.exit(1); +} diff --git a/src/css/custom.css b/src/css/custom.css index 4054e8a9..6ed14ec6 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -371,3 +371,16 @@ div[class*="language-shell"] code::before { html[data-theme='dark'] div[class*="language-shell"] code::before { color: var(--ifm-color-gray-400); } + +/************** +** FOOTER +***************/ +.footer__license-info { + margin-top: 0.5rem; + font-size: 0.85rem; + opacity: 0.8; +} + +.footer__license-info a { + text-decoration: underline; +} diff --git a/src/openapi/check-vendored-yaml.ts b/src/openapi/check-vendored-yaml.ts index f82bd05d..285f21fd 100644 --- a/src/openapi/check-vendored-yaml.ts +++ b/src/openapi/check-vendored-yaml.ts @@ -3,8 +3,12 @@ When making changes to this file, consider: https://virtru.atlassian.net/browse/ */ import * as fs from 'fs'; import * as crypto from 'crypto'; +import * as yaml from 'js-yaml'; import { openApiSpecsArray } from './preprocessing'; +const PLATFORM_API_BASE = 'https://api.github.com/repos/opentdf/platform'; +const PLATFORM_RAW_BASE = 'https://raw.githubusercontent.com/opentdf/platform/refs/heads/main'; + function fileHash(filePath: string): string { if (!fs.existsSync(filePath)) return ''; const data = fs.readFileSync(filePath); @@ -32,16 +36,70 @@ function downloadFile(url: string, dest: string): Promise { }); } +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + import('https').then(https => { + https.get(url, { headers: { 'User-Agent': 'opentdf-docs-check-vendored-yaml' } } as any, (response: any) => { + let data = ''; + response.on('data', (chunk: string) => { data += chunk; }); + response.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { reject(new Error(`Failed to parse JSON from ${url}: ${e}`)); } + }); + }).on('error', reject); + }).catch(reject); + }); +} + +function fetchText(url: string): Promise { + return new Promise((resolve, reject) => { + import('https').then(https => { + https.get(url, { headers: { 'User-Agent': 'opentdf-docs-check-vendored-yaml' } } as any, (response: any) => { + let data = ''; + response.on('data', (chunk: string) => { data += chunk; }); + response.on('end', () => resolve(data)); + }).on('error', reject); + }).catch(reject); + }); +} + +/** + * Recursively fetches all .yaml file paths under docs/openapi/ in the platform repo. + */ +async function fetchRemoteSpecPaths(dirPath = 'docs/openapi'): Promise { + const specPaths: string[] = []; + const contents = await fetchJson(`${PLATFORM_API_BASE}/contents/${dirPath}`); + + for (const item of contents) { + if (item.type === 'file' && item.name.endsWith('.yaml')) { + specPaths.push(item.path); + } else if (item.type === 'dir') { + specPaths.push(...await fetchRemoteSpecPaths(item.path)); + } + } + + return specPaths; +} + +/** + * Returns true if the spec at the given raw URL has actual API paths defined + * (i.e. it is a real API spec, not a shared schema-only file like common or entity). + */ +async function hasApiPaths(rawUrl: string): Promise { + const content = await fetchText(rawUrl); + const spec = yaml.load(content) as any; + return spec?.paths != null && Object.keys(spec.paths).length > 0; +} + async function main() { let hasDiff = false; + + // --- Check 1: vendored files are up to date --- for (const spec of openApiSpecsArray) { - if (!spec.url) continue; // Only process specs with a URL - // absPaths is the absolute path to the spec file + if (!spec.url) continue; const absPath = spec.specPath; const tmpPath = absPath + '.tmp'; - // Download to tmpPath await downloadFile(spec.url, tmpPath); - // Compare hashes const oldHash = fileHash(absPath); const newHash = fileHash(tmpPath); if (oldHash !== newHash) { @@ -52,6 +110,32 @@ async function main() { } fs.unlinkSync(tmpPath); } + + // --- Check 2: no unregistered spec files in the platform repo --- + console.log('\nšŸ” Checking for unregistered spec files in opentdf/platform...'); + const registeredUrls = new Set( + openApiSpecsArray.flatMap(spec => spec.url ? [spec.url] : []) + ); + + const remoteSpecPaths = await fetchRemoteSpecPaths(); + + for (const remotePath of remoteSpecPaths) { + const expectedUrl = `${PLATFORM_RAW_BASE}/${remotePath}`; + if (registeredUrls.has(expectedUrl)) continue; + + // Not registered — check if it actually has API paths (vs shared schema file) + if (await hasApiPaths(expectedUrl)) { + hasDiff = true; + console.error( + `āŒ Unregistered spec found in platform repo: ${remotePath}\n` + + ` Add an entry to openApiSpecsArray in src/openapi/preprocessing.ts with:\n` + + ` url: '${expectedUrl}'` + ); + } else { + console.log(`ā„¹ļø Skipping schema-only file (no paths): ${remotePath}`); + } + } + process.exit(hasDiff ? 1 : 0); } diff --git a/src/openapi/preprocessing.ts b/src/openapi/preprocessing.ts index 1e410980..02fd8f20 100644 --- a/src/openapi/preprocessing.ts +++ b/src/openapi/preprocessing.ts @@ -4,6 +4,7 @@ When making changes to this file, consider: https://virtru.atlassian.net/browse/ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; +import matter from 'gray-matter'; import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs"; // Utility to find the repo root (directory containing package.json) @@ -28,13 +29,42 @@ const ADD_TIMESTAMP_TO_DESCRIPTION = false; const OUTPUT_PREFIX = path.join(repoRoot, 'docs', 'OpenAPI-clients'); // The index page for OpenAPI documentation, to support bookmarking & sharing the URL -const OPENAPI_INDEX_PAGE = `${OUTPUT_PREFIX}/index.md`; +const OPENAPI_INDEX_PAGE = path.join(OUTPUT_PREFIX, 'index.md'); + +// Service descriptions and categorization for OpenAPI index generation +const SERVICE_DESCRIPTIONS: Record = { + 'Well-Known Configuration': 'Platform configuration and service discovery', + 'kas': 'Key Access Service for TDF encryption/decryption', + 'V1 Authorization': 'Authorization decisions (v1)', + 'V2 Authorization': 'Authorization decisions (v2)', + 'V1 Entity Resolution': 'Entity resolution from JWT tokens (v1)', + 'V2 Entity Resolution': 'Entity resolution from tokens (v2)', + 'Policy Objects': 'Core policy objects and management', + 'Policy Attributes': 'Attribute definitions and values', + 'Policy Namespaces': 'Namespace management', + 'Policy Actions': 'Action definitions', + 'Policy Subject Mapping': 'Map subjects to attributes', + 'Policy Resource Mapping': 'Map resources to attributes', + 'Policy Obligations': 'Usage obligations and triggers', + 'Policy Registered Resources': 'Resource registration', + 'Policy KAS Registry': 'KAS registration and management', + 'Key Management': 'Cryptographic key management', + 'Policy Unsafe Service': 'Administrative operations', +}; + +const CATEGORY_MAPPING: Record = { + 'Core Services': ['Well-Known Configuration', 'kas'], + 'Authorization & Entity Resolution': ['V1 Authorization', 'V2 Authorization', 'V1 Entity Resolution', 'V2 Entity Resolution'], + 'Policy Management': ['Policy Objects', 'Policy Attributes', 'Policy Namespaces', 'Policy Actions', + 'Policy Subject Mapping', 'Policy Resource Mapping', 'Policy Obligations', + 'Policy Registered Resources', 'Policy KAS Registry', 'Key Management', 'Policy Unsafe Service'], +}; // Read BUILD_OPENAPI_SAMPLES once const BUILD_OPENAPI_SAMPLES = process.env.BUILD_OPENAPI_SAMPLES === '1'; // Initialize empty samples configuration - will be populated conditionally -let samplesConfiguration = {}; +let samplesConfiguration: Record = {}; interface ApiSpecDefinition { id: string; // Unique key for the API spec, e.g., "authorization" @@ -408,33 +438,148 @@ async function preprocessOpenApiSpecs() { console.error(`āŒ Error processing ${sourcePath}:`, error); } - spec.specPath = spec.specPathModified; // Update the original specPath to the modified one + // Rebuild the entry with the processed path, dropping specPathModified cleanly + const { specPathModified: _, ...cleanSpec } = spec; + openApiSpecs[id] = { ...cleanSpec, specPath: spec.specPathModified! }; + } + + console.log('✨ OpenAPI preprocessing complete'); +}; + - // Delete the specPathModified property to avoid confusion - delete spec.specPathModified; +/** + * Renames all .info.mdx files to index.mdx so they become category index pages + * instead of appearing as separate items in the sidebar. + */ +function renameInfoFilesToIndex() { + console.log('šŸ”„ Renaming .info.mdx files to index.mdx...'); + + function processDirectory(dir: string) { + if (!fs.existsSync(dir)) return; + + const items = fs.readdirSync(dir, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(dir, item.name); + + if (item.isDirectory()) { + processDirectory(fullPath); + } else if (item.name.endsWith('.info.mdx')) { + const newPath = path.join(dir, 'index.mdx'); + if (fs.existsSync(newPath)) { + console.warn(`āš ļø Skipping rename of ${fullPath} because destination ${newPath} already exists.`); + } else { + fs.renameSync(fullPath, newPath); + console.log(` Renamed: ${fullPath} → ${newPath}`); + } + } + } + } + + processDirectory(OUTPUT_PREFIX); + console.log('āœ… Renamed all .info.mdx files to index.mdx'); +} + +/** + * Reads the document ID from a generated index.mdx file and returns the doc path, + * or null if the file does not exist or has no id in frontmatter. + */ +function getDocIdFromInfoFile(outputDir: string): string | null { + try { + const indexPath = path.join(outputDir, 'index.mdx'); + const fileContent = fs.readFileSync(indexPath, 'utf8'); + const parsed = matter(fileContent); + if (parsed.data.id) { + const relativePath = path.relative(OUTPUT_PREFIX, outputDir); + return `OpenAPI-clients/${relativePath}`; + } + } catch (error) { + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + // Missing index file likely means doc generation failed for this spec — link will be skipped. + console.warn(`āš ļø Could not find index file for an API spec, so it will be skipped. Path: ${path.join(outputDir, 'index.mdx')}`); + } else { + console.warn(`Could not read or parse info file in ${outputDir}:`, error); + } + } + return null; +} + +/** + * Updates the OpenAPI index page with links to generated docs. + * This should be called AFTER the OpenAPI docs have been generated. + */ +function updateOpenApiIndex() { + console.log('šŸ“ Updating OpenAPI index page with generated doc links...'); + + // Build service links dynamically from openApiSpecsArray + // Track which specs are categorized to find uncategorized ones + const specsById = new Map(openApiSpecsArray.map(spec => [spec.id, spec])); + const categorizedSpecs = new Set(); + let serviceLinksMarkdown = ''; + + Object.entries(CATEGORY_MAPPING).forEach(([category, specIds]) => { + serviceLinksMarkdown += `\n## ${category}\n\n`; + specIds.forEach(specId => { + categorizedSpecs.add(specId); + const spec = specsById.get(specId); + if (spec) { + const docId = getDocIdFromInfoFile(spec.outputDir); + if (docId) { + let description = SERVICE_DESCRIPTIONS[specId]; + if (!description) { + console.warn(`āš ļø Missing description for service "${specId}". Using default.`); + description = 'API documentation'; + } + serviceLinksMarkdown += `- **[${spec.id}](/${docId})** - ${description}\n`; + } + } + }); + }); + + // Add uncategorized APIs to a catch-all category + const uncategorizedSpecs = openApiSpecsArray.filter(spec => !categorizedSpecs.has(spec.id)); + if (uncategorizedSpecs.length > 0) { + console.warn(`āš ļø Found ${uncategorizedSpecs.length} uncategorized API(s): ${uncategorizedSpecs.map(s => s.id).join(', ')}`); + serviceLinksMarkdown += `\n## Other APIs\n\n`; + uncategorizedSpecs.forEach(spec => { + const docId = getDocIdFromInfoFile(spec.outputDir); + if (docId) { + let description = SERVICE_DESCRIPTIONS[spec.id]; + if (!description) { + console.warn(`āš ļø Missing description for service "${spec.id}". Using default.`); + description = 'API documentation'; + } + serviceLinksMarkdown += `- **[${spec.id}](/${docId})** - ${description}\n`; + } + }); } - // Create the index page for OpenAPI documentation const indexContent = `--- title: OpenAPI Clients sidebar_position: 7 --- # OpenAPI Clients -OpenAPI client examples are available for platform endpoints. +Interactive API documentation for OpenTDF Platform services. Each endpoint includes request/response examples, parameter descriptions, and the ability to try requests directly in your browser. -Expand each section in the navigation panel to access the OpenAPI documentation for each service. -` +${serviceLinksMarkdown} - // Ensure the file 'OPENAPI_INDEX_PAGE' exists - fs.mkdirSync(path.dirname(OPENAPI_INDEX_PAGE), { recursive: true }); +## Getting Started - fs.writeFileSync(OPENAPI_INDEX_PAGE, indexContent, 'utf8'); - console.log(`āœ… Created OpenAPI index page at ${OPENAPI_INDEX_PAGE}`); +1. Select a service from the list above or navigation sidebar +2. Browse available endpoints and operations +3. Review request parameters and response schemas +4. Test endpoints using the "Try it" feature - console.log('✨ OpenAPI preprocessing complete'); -}; +## Authentication +Most endpoints require authentication. Configure your access token in the API documentation interface before testing endpoints. +`; + + fs.mkdirSync(path.dirname(OPENAPI_INDEX_PAGE), { recursive: true }); + fs.writeFileSync(OPENAPI_INDEX_PAGE, indexContent, 'utf8'); + console.log(`āœ… Updated OpenAPI index page at ${OPENAPI_INDEX_PAGE}`); +} // Export the function and data without automatically executing it -export { openApiSpecs, openApiSpecsArray, preprocessOpenApiSpecs }; \ No newline at end of file +export { openApiSpecs, openApiSpecsArray, preprocessOpenApiSpecs, updateOpenApiIndex, renameInfoFilesToIndex }; \ No newline at end of file