diff --git a/biome.json b/biome.json index 2188c15..ce5e6ff 100644 --- a/biome.json +++ b/biome.json @@ -16,7 +16,22 @@ "rules": { "style": { "useConst": "off", - "useImportType": "off" + "useImportType": "off", + "noUselessElse": "off" + } + } + } + }, + { + "include": ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.json"], + "linter": { + "rules": { + "style": { + "noUselessElse": "off", + "useNodejsImportProtocol": "off" + }, + "complexity": { + "noThisInStatic": "off" } } } diff --git a/packages/parser/package.json b/packages/parser/package.json index 5c40868..b6a01eb 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -28,7 +28,11 @@ "@biomejs/biome": "catalog:", "@rspack/cli": "catalog:", "@rspack/core": "catalog:", + "@types/js-yaml": "^4.0.9", "ts-loader": "catalog:", "typescript": "catalog:" + }, + "dependencies": { + "js-yaml": "^4.1.0" } } diff --git a/packages/parser/rspack.config.js b/packages/parser/rspack.config.js index 8dd4a09..bb3e3e2 100644 --- a/packages/parser/rspack.config.js +++ b/packages/parser/rspack.config.js @@ -1,5 +1,10 @@ +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; import { defineConfig } from "@rspack/cli"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const commonConfig = { entry: { index: "./src/index.ts", @@ -16,7 +21,7 @@ const commonConfig = { resolve: { extensions: [".ts", ".js"], alias: { - "@": "./src", + "@": resolve(__dirname, "src"), }, }, }; diff --git a/packages/parser/src/core/docutopia.ts b/packages/parser/src/core/docutopia.ts new file mode 100644 index 0000000..316711c --- /dev/null +++ b/packages/parser/src/core/docutopia.ts @@ -0,0 +1,11 @@ +import {SpecLoader} from "@/core/loader"; +import {ParserFactory} from "@/core/parser"; +import type {DocutopiaParserOutput} from "@/types/output"; + +export class DocutopiaParser { + public static async parse(source: string): Promise { + const spec = await SpecLoader.load(source); + const parser = ParserFactory.createParser(spec); + return parser.parse(); + } +} diff --git a/packages/parser/src/core/loader.ts b/packages/parser/src/core/loader.ts new file mode 100644 index 0000000..1c9f188 --- /dev/null +++ b/packages/parser/src/core/loader.ts @@ -0,0 +1,35 @@ +import * as yaml from "js-yaml"; +import type {OpenAPISpec} from "@/types/openapi"; + +export class SpecLoader { + static async load(source: string): Promise { + if (SpecLoader.isUrl(source)) { + return SpecLoader.loadFromUrl(source); + } + throw new Error("Invalid source provided. Please provide a valid URL."); + } + + private static isUrl(source: string): boolean { + return /^https?:\/\//.test(source); + } + + private static async loadFromUrl(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch OpenAPI spec from URL: ${url}`); + } + const contentType = response.headers.get("content-type"); + const text = await response.text(); + + if (contentType?.includes("application/json")) { + return JSON.parse(text); + } else if ( + contentType?.includes("application/x-yaml") || + contentType?.includes("text/yaml") + ) { + return yaml.load(text); + } else { + throw new Error("Unsupported content type. Please provide JSON or YAML."); + } + } +} diff --git a/packages/parser/src/core/parser.ts b/packages/parser/src/core/parser.ts new file mode 100644 index 0000000..96a1df9 --- /dev/null +++ b/packages/parser/src/core/parser.ts @@ -0,0 +1,13 @@ +import type {OpenAPISpec} from "@/types/openapi"; +import type {BaseParser} from "@/parsers/base"; +import {OpenAPIParserV3} from "@/parsers/openapi-v3"; + +export class ParserFactory { + static createParser(spec: OpenAPISpec): BaseParser { + const version = spec.openapi; + if (version.startsWith("3.0")) { + return new OpenAPIParserV3(spec); + } + throw new Error(`Unsupported OpenAPI version: ${version}`); + } +} diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 5c58fd8..871cd0d 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -1 +1,2 @@ -export * from "./parser"; +export * from "./core/docutopia"; +export * from "./types/output"; diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts deleted file mode 100644 index 48649a0..0000000 --- a/packages/parser/src/parser.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class DocutopiaParser { - constructor() {} - - parse() { - console.log("Parsing..."); - } -} diff --git a/packages/parser/src/parsers/base.ts b/packages/parser/src/parsers/base.ts new file mode 100644 index 0000000..ccb6640 --- /dev/null +++ b/packages/parser/src/parsers/base.ts @@ -0,0 +1,20 @@ +import type {OpenAPISpec} from "@/types/openapi"; +import type {BaseResolver} from "@/resolvers/base"; +import {ResolverFactory} from "@/resolvers/factory"; +import type {DocutopiaParserOutput} from "@/types/output"; + +export abstract class BaseParser { + protected spec: OpenAPISpec; + protected resolver: BaseResolver; + + constructor(spec: OpenAPISpec) { + this.spec = spec; + this.resolver = ResolverFactory.createResolver(spec); + } + + public abstract parse(): DocutopiaParserOutput; + + protected replaceRefs(obj: any): any { + return this.resolver.replaceRefs(obj); + } +} diff --git a/packages/parser/src/parsers/openapi-v3.ts b/packages/parser/src/parsers/openapi-v3.ts new file mode 100644 index 0000000..c4cb66c --- /dev/null +++ b/packages/parser/src/parsers/openapi-v3.ts @@ -0,0 +1,77 @@ +import type {DocutopiaParserOutput} from "@/types/output"; +import {BaseParser} from "@/parsers/base"; + +export class OpenAPIParserV3 extends BaseParser { + private groups: Array = []; + + public parse(): DocutopiaParserOutput { + this.groups = this.parseGroups(); + + return { + info: this.parseInfo(), + servers: this.parseServers(), + groups: this.groups, + schemas: this.parseSchemas(), + sidebar: this.generateSidebar(this.groups), + }; + } + + private parseInfo() { + return this.spec.info; + } + + private parseServers() { + return this.spec.servers; + } + + private parseGroups() { + const groupsMap: { [key: string]: any } = {}; + + for (const path in this.spec.paths) { + for (const method in this.spec.paths[path]) { + const operation = this.spec.paths[path][method]; + const group = operation.tags?.[0] || "Default Group"; + const summary = operation.summary || ""; + const description = operation.description || ""; + + if (!groupsMap[group]) { + groupsMap[group] = { + group, + description: group, + endpoints: [], + }; + } + + const endpoint = { + path, + method: method.toUpperCase(), + summary, + description, + parameters: operation.parameters || [], + requestBody: this.replaceRefs(operation.requestBody || null), + responses: this.replaceRefs(operation.responses || {}), + }; + + groupsMap[group].endpoints.push(endpoint); + } + } + + return Object.values(groupsMap); + } + + private generateSidebar(groups: Array) { + return groups.map((group) => ({ + group: group.group, + description: group.description, + endpoints: group.endpoints.map((endpoint: any) => ({ + method: endpoint.method, + description: endpoint.summary, + path: `#${endpoint.method.toLowerCase()}-${endpoint.path.replace(/\//g, "-")}`, + })), + })); + } + + private parseSchemas() { + return this.spec.components?.schemas || {}; + } +} diff --git a/packages/parser/src/resolvers/base.ts b/packages/parser/src/resolvers/base.ts new file mode 100644 index 0000000..6facea2 --- /dev/null +++ b/packages/parser/src/resolvers/base.ts @@ -0,0 +1,23 @@ +import type {OpenAPISpec} from "@/types/openapi"; + +export abstract class BaseResolver { + protected spec: OpenAPISpec; + + constructor(spec: OpenAPISpec) { + this.spec = spec; + } + + public abstract resolveRef(ref: string): any; + + public replaceRefs(obj: any): any { + if (typeof obj === "object" && obj !== null) { + if (obj.$ref) { + return this.resolveRef(obj.$ref); + } + for (const key in obj) { + obj[key] = this.replaceRefs(obj[key]); + } + } + return obj; + } +} diff --git a/packages/parser/src/resolvers/factory.ts b/packages/parser/src/resolvers/factory.ts new file mode 100644 index 0000000..2ceb879 --- /dev/null +++ b/packages/parser/src/resolvers/factory.ts @@ -0,0 +1,13 @@ +import type {OpenAPISpec} from "@/types/openapi"; +import type {BaseResolver} from "@/resolvers/base"; +import {OpenAPIResolverV3} from "@/resolvers/openapi-v3"; + +export class ResolverFactory { + public static createResolver(spec: OpenAPISpec): BaseResolver { + const version = spec.openapi; + if (version.startsWith("3.0")) { + return new OpenAPIResolverV3(spec); + } + throw new Error(`Unsupported OpenAPI version: ${version}`); + } +} diff --git a/packages/parser/src/resolvers/openapi-v3.ts b/packages/parser/src/resolvers/openapi-v3.ts new file mode 100644 index 0000000..c9f5bf3 --- /dev/null +++ b/packages/parser/src/resolvers/openapi-v3.ts @@ -0,0 +1,15 @@ +import {BaseResolver} from "@/resolvers/base"; + +export class OpenAPIResolverV3 extends BaseResolver { + public resolveRef(ref: string): any { + const [_, type, name] = ref.split("/"); + if ( + type === "components" && + this.spec.components && + this.spec.components.schemas + ) { + return this.spec.components.schemas[name]; + } + throw new Error(`Reference ${ref} not found`); + } +} diff --git a/packages/parser/src/types/openapi.ts b/packages/parser/src/types/openapi.ts new file mode 100644 index 0000000..9355d30 --- /dev/null +++ b/packages/parser/src/types/openapi.ts @@ -0,0 +1,29 @@ +export interface OpenAPISpec { + openapi: string; + info: { + title: string; + description: string; + version: string; + }; + servers: Array<{ + url: string; + description: string; + }>; + paths: { + [path: string]: { + [method: string]: { + tags?: string[]; + summary?: string; + description?: string; + parameters?: any[]; + requestBody?: any; + responses?: any; + }; + }; + }; + components?: { + schemas?: { + [key: string]: any; + }; + }; +} diff --git a/packages/parser/src/types/output.ts b/packages/parser/src/types/output.ts new file mode 100644 index 0000000..1a9b3bc --- /dev/null +++ b/packages/parser/src/types/output.ts @@ -0,0 +1,36 @@ +export interface DocutopiaParserOutput { + info: { + title: string; + description: string; + version: string; + }; + servers: Array<{ + url: string; + description: string; + }>; + groups: Array<{ + group: string; + description: string; + endpoints: Array<{ + path: string; + method: string; + summary: string; + description: string; + parameters: any[]; + requestBody: any; + responses: any; + }>; + }>; + schemas: { + [key: string]: any; + }; + sidebar: Array<{ + group: string; + description: string; + endpoints: Array<{ + method: string; + description: string; + path: string; + }>; + }>; +} diff --git a/packages/parser/tsconfig.json b/packages/parser/tsconfig.json index 32cf6fc..110fa97 100644 --- a/packages/parser/tsconfig.json +++ b/packages/parser/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES6", + "target": "ES2020", "module": "ESNext", "moduleResolution": "Node", "strict": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cd6398..335fc00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,10 @@ importers: version: 5.5.4 packages/parser: + dependencies: + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 devDependencies: '@biomejs/biome': specifier: 'catalog:' @@ -70,6 +74,12 @@ importers: '@rspack/core': specifier: 'catalog:' version: 1.0.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.5.1 + version: 22.5.1 ts-loader: specifier: 'catalog:' version: 9.5.1(typescript@5.5.4)(webpack@5.94.0) @@ -304,6 +314,9 @@ packages: '@types/http-proxy@1.17.15': resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -476,6 +489,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1077,6 +1093,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2142,6 +2162,8 @@ snapshots: dependencies: '@types/node': 22.5.1 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/mime@1.3.5': {} @@ -2341,6 +2363,8 @@ snapshots: arg@5.0.2: {} + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -2936,6 +2960,10 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {}