Skip to content

Commit

Permalink
Add OpenAPI v3 parser and loader (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisllontop authored Sep 4, 2024
1 parent 96126a4 commit 9892029
Show file tree
Hide file tree
Showing 17 changed files with 329 additions and 11 deletions.
17 changes: 16 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
7 changes: 6 additions & 1 deletion packages/parser/rspack.config.js
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -16,7 +21,7 @@ const commonConfig = {
resolve: {
extensions: [".ts", ".js"],
alias: {
"@": "./src",
"@": resolve(__dirname, "src"),
},
},
};
Expand Down
11 changes: 11 additions & 0 deletions packages/parser/src/core/docutopia.ts
Original file line number Diff line number Diff line change
@@ -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<DocutopiaParserOutput> {
const spec = await SpecLoader.load(source);
const parser = ParserFactory.createParser(spec);
return parser.parse();
}
}
35 changes: 35 additions & 0 deletions packages/parser/src/core/loader.ts
Original file line number Diff line number Diff line change
@@ -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<OpenAPISpec> {
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<OpenAPISpec> {
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 <OpenAPISpec>JSON.parse(text);
} else if (
contentType?.includes("application/x-yaml") ||
contentType?.includes("text/yaml")
) {
return <OpenAPISpec>yaml.load(text);
} else {
throw new Error("Unsupported content type. Please provide JSON or YAML.");
}
}
}
13 changes: 13 additions & 0 deletions packages/parser/src/core/parser.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
3 changes: 2 additions & 1 deletion packages/parser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./parser";
export * from "./core/docutopia";
export * from "./types/output";
7 changes: 0 additions & 7 deletions packages/parser/src/parser.ts

This file was deleted.

20 changes: 20 additions & 0 deletions packages/parser/src/parsers/base.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
77 changes: 77 additions & 0 deletions packages/parser/src/parsers/openapi-v3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {DocutopiaParserOutput} from "@/types/output";
import {BaseParser} from "@/parsers/base";

export class OpenAPIParserV3 extends BaseParser {
private groups: Array<any> = [];

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<any>) {
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 || {};
}
}
23 changes: 23 additions & 0 deletions packages/parser/src/resolvers/base.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions packages/parser/src/resolvers/factory.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
15 changes: 15 additions & 0 deletions packages/parser/src/resolvers/openapi-v3.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
29 changes: 29 additions & 0 deletions packages/parser/src/types/openapi.ts
Original file line number Diff line number Diff line change
@@ -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;
};
};
}
36 changes: 36 additions & 0 deletions packages/parser/src/types/output.ts
Original file line number Diff line number Diff line change
@@ -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;
}>;
}>;
}
2 changes: 1 addition & 1 deletion packages/parser/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES6",
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
Expand Down
Loading

0 comments on commit 9892029

Please sign in to comment.