digitaltwin-core
Version:
Minimalist framework to collect and handle data in a Digital Twin project
293 lines • 10.5 kB
JavaScript
/**
* @fileoverview OpenAPI specification generator for Digital Twin components
*
* This module provides utilities to automatically generate OpenAPI 3.0
* documentation from Digital Twin components that implement OpenAPIDocumentable.
*/
import { isOpenAPIDocumentable } from './types.js';
/**
* Generates OpenAPI 3.0 specifications from Digital Twin components.
*
* The generator aggregates OpenAPI specs from all components that implement
* the OpenAPIDocumentable interface and produces a complete OpenAPI document.
*
* @example
* ```typescript
* import { OpenAPIGenerator } from 'digitaltwin-core'
* import * as components from './src/components'
*
* const spec = OpenAPIGenerator.generate({
* info: {
* title: 'My Digital Twin API',
* version: '1.0.0',
* description: 'API documentation'
* },
* components: Object.values(components),
* servers: [{ url: 'http://localhost:3000' }]
* })
*
* // Write to file
* OpenAPIGenerator.writeYAML(spec, './openapi.yaml')
* ```
*/
export class OpenAPIGenerator {
/**
* Generates an OpenAPI document from the provided components.
*
* @param options - Generation options including components and metadata
* @returns Complete OpenAPI document
*/
static generate(options) {
const { info, servers, components, additionalSchemas, additionalTags, includeAuth = true } = options;
// Filter components that implement OpenAPIDocumentable
const documentableComponents = components.filter(isOpenAPIDocumentable);
// Aggregate paths, tags, and schemas from all components
const allPaths = {};
const allTags = additionalTags ? [...additionalTags] : [];
const allSchemas = additionalSchemas ? { ...additionalSchemas } : {};
const tagNames = new Set(additionalTags?.map(t => t.name) || []);
for (const component of documentableComponents) {
try {
const spec = component.getOpenAPISpec();
// Merge paths
for (const [path, pathItem] of Object.entries(spec.paths)) {
if (allPaths[path]) {
// Merge operations into existing path
allPaths[path] = { ...allPaths[path], ...pathItem };
}
else {
allPaths[path] = pathItem;
}
}
// Merge tags (avoid duplicates)
if (spec.tags) {
for (const tag of spec.tags) {
if (!tagNames.has(tag.name)) {
allTags.push(tag);
tagNames.add(tag.name);
}
}
}
// Merge schemas
if (spec.schemas) {
Object.assign(allSchemas, spec.schemas);
}
}
catch (error) {
const componentName = 'getConfiguration' in component
? component.getConfiguration().name
: 'unknown';
console.warn(`Warning: Failed to get OpenAPI spec from component "${componentName}":`, error);
}
}
// Sort tags alphabetically
allTags.sort((a, b) => a.name.localeCompare(b.name));
// Build components section
const componentsSection = {};
if (Object.keys(allSchemas).length > 0) {
componentsSection.schemas = allSchemas;
}
if (includeAuth) {
componentsSection.securitySchemes = this.getDefaultSecuritySchemes();
}
// Build final document
const document = {
openapi: '3.0.3',
info,
paths: allPaths
};
if (servers && servers.length > 0) {
document.servers = servers;
}
if (allTags.length > 0) {
document.tags = allTags;
}
if (Object.keys(componentsSection).length > 0) {
document.components = componentsSection;
}
return document;
}
/**
* Returns default security schemes for APISIX/Keycloak authentication.
*/
static getDefaultSecuritySchemes() {
return {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'x-user-id',
description: 'Keycloak user ID (forwarded by APISIX)'
}
};
}
/**
* Converts an OpenAPI document to YAML string.
*
* @param document - OpenAPI document to convert
* @returns YAML string representation
*/
static toYAML(document) {
return this.objectToYAML(document, 0);
}
/**
* Converts an OpenAPI document to JSON string.
*
* @param document - OpenAPI document to convert
* @param pretty - Whether to format with indentation (default: true)
* @returns JSON string representation
*/
static toJSON(document, pretty = true) {
return JSON.stringify(document, null, pretty ? 2 : undefined);
}
/**
* Recursively converts an object to YAML format.
* Simple implementation without external dependencies.
*/
static objectToYAML(obj, indent) {
const spaces = ' '.repeat(indent);
if (obj === null || obj === undefined) {
return 'null';
}
if (typeof obj === 'string') {
// Check if string needs quoting
if (obj === '' ||
obj.includes(':') ||
obj.includes('#') ||
obj.includes('\n') ||
obj.includes('"') ||
obj.includes("'") ||
obj.startsWith(' ') ||
obj.endsWith(' ') ||
obj === 'true' ||
obj === 'false' ||
obj === 'null' ||
/^[\d.]+$/.test(obj)) {
// Use double quotes and escape
return `"${obj.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
}
return obj;
}
if (typeof obj === 'number' || typeof obj === 'boolean') {
return String(obj);
}
if (Array.isArray(obj)) {
if (obj.length === 0) {
return '[]';
}
const items = obj.map(item => {
const value = this.objectToYAML(item, indent + 1);
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
// Object in array - put first property on same line as dash
const lines = value.split('\n');
if (lines.length > 0) {
return `${spaces}- ${lines[0].trimStart()}\n${lines.slice(1).join('\n')}`;
}
}
return `${spaces}- ${value}`;
});
return items.join('\n').replace(/\n+$/, '');
}
if (typeof obj === 'object') {
const entries = Object.entries(obj);
if (entries.length === 0) {
return '{}';
}
const lines = entries.map(([key, value]) => {
// Handle special keys that need quoting
const quotedKey = /[:\s#[\]{}]/.test(key) ? `"${key}"` : key;
if (value === null || value === undefined) {
return `${spaces}${quotedKey}: null`;
}
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0) {
return `${spaces}${quotedKey}:\n${this.objectToYAML(value, indent + 1)}`;
}
if (Array.isArray(value) && value.length > 0) {
return `${spaces}${quotedKey}:\n${this.objectToYAML(value, indent + 1)}`;
}
return `${spaces}${quotedKey}: ${this.objectToYAML(value, indent + 1)}`;
});
return lines.join('\n');
}
return String(obj);
}
/**
* Helper to create a simple schema reference.
*/
static schemaRef(name) {
return { $ref: `#/components/schemas/${name}` };
}
/**
* Helper to create a common response for 200 OK with content.
*/
static successResponse(contentType, schema, description = 'Successful response') {
return {
'200': {
description,
content: {
[contentType]: { schema }
}
}
};
}
/**
* Helper to create common error responses.
*/
static errorResponses(codes = [400, 401, 404, 500]) {
const responses = {};
const descriptions = {
400: 'Bad request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not found',
500: 'Internal server error'
};
for (const code of codes) {
responses[String(code)] = { description: descriptions[code] };
}
return responses;
}
/**
* Common schemas used across components.
*/
static { this.commonSchemas = {
Error: {
type: 'object',
properties: {
error: { type: 'string' },
message: { type: 'string' }
}
},
Point: {
type: 'object',
required: ['type', 'coordinates'],
properties: {
type: { type: 'string', enum: ['Point'] },
coordinates: {
type: 'array',
items: { type: 'number' }
}
}
},
Feature: {
type: 'object',
required: ['type', 'geometry', 'properties'],
properties: {
type: { type: 'string', enum: ['Feature'] },
geometry: { type: 'object' },
properties: { type: 'object' }
}
},
FeatureCollection: {
type: 'object',
required: ['type', 'features'],
properties: {
type: { type: 'string', enum: ['FeatureCollection'] },
features: {
type: 'array',
items: { $ref: '#/components/schemas/Feature' }
}
}
}
}; }
}
//# sourceMappingURL=generator.js.map