Skip to content

Commit

Permalink
Fix the parsing and typing in openapi integration
Browse files Browse the repository at this point in the history
  • Loading branch information
gregberge committed Feb 26, 2025
1 parent 5d56b9c commit 6e4e7f1
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 108 deletions.
17 changes: 10 additions & 7 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
},
"integrations/formspree": {
"name": "@gitbook/integration-formspree",
"version": "0.2.3",
"version": "0.2.4",
"dependencies": {
"@gitbook/runtime": "packages/runtime",
},
Expand Down Expand Up @@ -340,13 +340,12 @@
},
"integrations/openapi": {
"name": "@gitbook/integration-openapi",
"version": "0.0.0",
"version": "0.0.1",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/document": "*",
"@gitbook/openapi-parser": "^1.0.1",
"@gitbook/runtime": "*",
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
},
"devDependencies": {
"@gitbook/cli": "workspace:*",
Expand Down Expand Up @@ -534,7 +533,7 @@
},
"packages/api": {
"name": "@gitbook/api",
"version": "0.94.0",
"version": "0.96.0",
"dependencies": {
"event-iterator": "^2.0.0",
"eventsource-parser": "^3.0.0",
Expand Down Expand Up @@ -577,7 +576,7 @@
},
"packages/document": {
"name": "@gitbook/document",
"version": "0.0.0",
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "*",
},
Expand All @@ -588,7 +587,7 @@
},
"packages/runtime": {
"name": "@gitbook/runtime",
"version": "0.18.0",
"version": "0.19.0",
"dependencies": {
"@gitbook/api": "*",
},
Expand Down Expand Up @@ -917,6 +916,8 @@

"@gitbook/integration-zoominfo": ["@gitbook/integration-zoominfo@workspace:integrations/zoominfo"],

"@gitbook/openapi-parser": ["@gitbook/[email protected]", "", { "dependencies": { "@scalar/openapi-parser": "^0.10.4", "@scalar/openapi-types": "^0.1.6", "swagger2openapi": "^7.0.8", "yaml": "1.10.2" } }, "sha512-aq0y6mrlnJmp6wRUEasqF8P0g3J/nBV3acGntj4aUtkaOdKizFr4C+kACv73xdzp57hN9Umd7RFbQ/DajMnhkw=="],

"@gitbook/runtime": ["@gitbook/runtime@workspace:packages/runtime"],

"@gitbook/tsconfig": ["@gitbook/tsconfig@workspace:packages/tsconfig"],
Expand Down Expand Up @@ -2255,6 +2256,8 @@

"@gitbook/integration-slack/itty-router": ["[email protected]", "", {}, "sha512-hIPHtXGymCX7Lzb2I4G6JgZFE4QEEQwst9GORK7sMYUpJvLfy4yZJr95r04e8DzoAnj6HcxM2m4TbK+juu+18g=="],

"@gitbook/openapi-parser/yaml": ["[email protected]", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],

"@graphql-codegen/add/tslib": ["[email protected]", "", {}, "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="],

"@graphql-codegen/cli/chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
Expand Down
3 changes: 1 addition & 2 deletions integrations/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
"@gitbook/api": "*",
"@gitbook/runtime": "*",
"@gitbook/document": "*",
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6"
"@gitbook/openapi-parser": "^1.0.1"
},
"devDependencies": {
"@gitbook/cli": "workspace:*",
Expand Down
154 changes: 78 additions & 76 deletions integrations/openapi/src/contentSources/generate.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,49 @@
import { ContentRefOpenAPI, InputPage } from '@gitbook/api';
import type {
ContentRefOpenAPI,
DocumentBlocksTopLevels,
InputPage,
JSONDocument,
} from '@gitbook/api';
import * as doc from '@gitbook/document';
import {
ContentSourceDependenciesValueFromRef,
type ContentSourceDependenciesValueFromRef,
createContentSource,
ExposableError,
} from '@gitbook/runtime';
import { openapi } from '@scalar/openapi-parser';
import { fetchUrls } from '@scalar/openapi-parser/plugins/fetch-urls';
import { OpenAPIV3 } from '@scalar/openapi-types';
import { getTagTitle, improveTagName } from './utils';
import { OpenAPIRuntimeContext } from '../types';
import { dereference, type OpenAPIV3 } from '@gitbook/openapi-parser';
import { assertNever, getTagTitle, improveTagName } from './utils';
import type { OpenAPIRuntimeContext } from '../types';

const HTTP_METHODS = [
OpenAPIV3.HttpMethods.GET,
OpenAPIV3.HttpMethods.POST,
OpenAPIV3.HttpMethods.PUT,
OpenAPIV3.HttpMethods.DELETE,
OpenAPIV3.HttpMethods.OPTIONS,
OpenAPIV3.HttpMethods.HEAD,
OpenAPIV3.HttpMethods.PATCH,
OpenAPIV3.HttpMethods.TRACE,
];

/** Props passed to the `getRevision` method. */
export type GenerateContentSourceProps = {
'get',
'post',
'put',
'delete',
'options',
'head',
'patch',
'trace',
] as OpenAPIV3.HttpMethods[]; // @TODO expose enum in @gitbook/openapi-parser

type GetRevisionProps = {
/**
* Should a models page be generated?
* @default true
*/
models?: boolean;
};

/** Props passed to the `getPageDocument` method. */
type GenerateGroupPageProps = GenerateContentSourceProps & {
type GenerateGroupPageProps = {
doc: 'operations';
group: string;
};
type GenerateModelsPageProps = GenerateContentSourceProps & {

type GenerateModelsPageProps = {
doc: 'models';
};

type GetPageDocumentProps = GenerateGroupPageProps | GenerateModelsPageProps;

export type GenerateContentSourceDependencies = {
spec: { ref: ContentRefOpenAPI };
};
Expand All @@ -48,18 +52,18 @@ export type GenerateContentSourceDependencies = {
* Content source to generate pages from an OpenAPI specification.
*/
export const generateContentSource = createContentSource<
GenerateContentSourceProps | GenerateGroupPageProps | GenerateModelsPageProps,
GetRevisionProps,
GetPageDocumentProps,
GenerateContentSourceDependencies
>({
sourceId: 'generate',

getRevision: async ({ props, dependencies }, ctx) => {
const { data: spec, specSlug } = await getOpenAPISpec({ props, dependencies }, ctx);
const groups = divideOpenAPISpec(props, spec);
const spec = await getOpenAPISpec({ dependencies }, ctx);
const groups = divideOpenAPISpec(spec.schema);

const groupPages = groups.map((group) => {
const documentProps: GenerateGroupPageProps = {
...props,
doc: 'operations',
group: group.id,
};
Expand All @@ -75,7 +79,7 @@ export const generateContentSource = createContentSource<
props: documentProps,
dependencies: {
spec: {
ref: { kind: 'openapi' as const, spec: specSlug },
ref: { kind: 'openapi' as const, spec: spec.slug },
},
},
},
Expand All @@ -98,7 +102,7 @@ export const generateContentSource = createContentSource<
props: { doc: 'models' },
dependencies: {
spec: {
ref: { kind: 'openapi' as const, spec: specSlug },
ref: { kind: 'openapi' as const, spec: spec.slug },
},
},
},
Expand All @@ -110,76 +114,80 @@ export const generateContentSource = createContentSource<
},

getPageDocument: async ({ props, dependencies }, ctx) => {
if (!('doc' in props)) {
throw new Error('Doc is required');
switch (props.doc) {
case 'operations':
return generateGroupDocument({ props, dependencies }, ctx);
case 'models':
return generateModelsDocument({ props, dependencies }, ctx);
default:
assertNever(props);
}

if (props.doc === 'models') {
return generateModelsDocument({ props, dependencies }, ctx);
}

if (props.doc === 'operations') {
return generateGroupDocument({ props, dependencies }, ctx);
}

throw new Error('Invalid document generation request');
},
});

/**
* Generate a document for a group in the OpenAPI specification.
*/
async function generateGroupDocument(
{
props,
dependencies,
}: {
input: {
props: GenerateGroupPageProps;
dependencies: ContentSourceDependenciesValueFromRef<GenerateContentSourceDependencies>;
},
ctx: OpenAPIRuntimeContext,
) {
const { data: spec, specSlug } = await getOpenAPISpec({ props, dependencies }, ctx);
const groups = divideOpenAPISpec(props, spec);
const { props, dependencies } = input;
const spec = await getOpenAPISpec({ dependencies }, ctx);
const groups = divideOpenAPISpec(spec.schema);

const group = groups.find((g) => g.id === props.group);

if (!group) {
throw new Error(`Group ${props.group} not found`);
}

const operations = extractOperations(group);

return doc.document([
// TODO: return or parse the description as markdown
...(group.tag?.description ? [doc.paragraph(doc.text(group.tag.description))] : []),
...(group.tag ? getTagDescriptionNodes(group.tag) : []),
...operations.map((operation) => {
return doc.openapi({
ref: { kind: 'openapi', spec: specSlug },
ref: { kind: 'openapi', spec: spec.slug },
method: operation.method,
path: operation.path,
});
}),
]);
}

function getTagDescriptionNodes(tag: OpenAPIV3.TagObject): DocumentBlocksTopLevels[] {
if ('x-gitbook-description-document' in tag && tag['x-gitbook-description-document']) {
const descriptionDocument = tag['x-gitbook-description-document'] as JSONDocument;
return descriptionDocument.nodes;
}

if (tag.description) {
return [doc.paragraph(doc.text(tag.description))];
}

return [];
}

/**
* Generate a document for the models page in the OpenAPI specification.
*/
async function generateModelsDocument(
{
props,
dependencies,
}: {
input: {
props: GenerateModelsPageProps;
dependencies: ContentSourceDependenciesValueFromRef<GenerateContentSourceDependencies>;
},
ctx: OpenAPIRuntimeContext,
) {
const { data: spec } = await getOpenAPISpec({ props, dependencies }, ctx);
const { props, dependencies } = input;
const spec = await getOpenAPISpec({ dependencies }, ctx);

return doc.document([
doc.paragraph(doc.text('Models')),
...Object.entries(spec.components?.schemas ?? {})
...Object.entries(spec.schema.components?.schemas ?? {})
.map(([name, schema]) => {
if ('$ref' in schema) {
return [doc.heading1(doc.text(name)), doc.paragraph(doc.text(schema.$ref))];
Expand All @@ -199,21 +207,20 @@ async function generateModelsDocument(
* Get the OpenAPI specification from the OpenAPI specification dependency.
*/
async function getOpenAPISpec(
{
dependencies,
}: {
props: GenerateContentSourceProps;
input: {
dependencies: ContentSourceDependenciesValueFromRef<GenerateContentSourceDependencies>;
},
ctx: OpenAPIRuntimeContext,
) {
const { dependencies } = input;
const { api } = ctx;
const { installation } = ctx.environment;
const specValue = dependencies.spec.value;

if (!installation) {
throw new ExposableError('Installation not found');
}

if (specValue?.object !== 'openapi-spec') {
throw new ExposableError('Invalid spec');
}
Expand All @@ -222,28 +229,23 @@ async function getOpenAPISpec(
throw new ExposableError('No version found for spec');
}

const { data: version } = await api.orgs.getOpenApiSpecVersionById(
const { data: content } = await api.orgs.getOpenApiSpecVersionContentById(
installation.target.organization,
specValue.slug,
specValue.lastVersion,
);

const result = await openapi()
.load(version.url, {
plugins: [
fetchUrls({
limit: 10,
}),
],
})
.upgrade()
.get();
const dereferenceResult = await dereference(content.filesystem);

if (result.errors && result.errors.length > 0) {
throw new ExposableError(`Failed to parse OpenAPI spec`);
if (!dereferenceResult.schema) {
throw new ExposableError('Failed to dereference OpenAPI spec');
}

return { data: result.specification as OpenAPIV3.Document, specSlug: specValue.slug };
return {
schema: dereferenceResult.schema as OpenAPIV3.Document,
url: content.url,
slug: specValue.slug,
};
}

interface OpenAPIGroup {
Expand All @@ -255,7 +257,7 @@ interface OpenAPIGroup {
/**
* Split the OpenAPI specification into a set of pages
*/
function divideOpenAPISpec(props: GenerateContentSourceProps, spec: OpenAPIV3.Document) {
function divideOpenAPISpec(schema: OpenAPIV3.Document) {
const groups: OpenAPIGroup[] = [];
const groupsIndexById = new Map<string, number>();

Expand Down Expand Up @@ -286,7 +288,7 @@ function divideOpenAPISpec(props: GenerateContentSourceProps, spec: OpenAPIV3.Do
}
};

Object.entries(spec.paths ?? {}).forEach(([path, pathItem]) => {
Object.entries(schema.paths ?? {}).forEach(([path, pathItem]) => {
if (!pathItem) {
return;
}
Expand All @@ -298,7 +300,7 @@ function divideOpenAPISpec(props: GenerateContentSourceProps, spec: OpenAPIV3.Do
}

const firstTag = operation.tags?.[0] ?? 'default';
const tag = spec.tags?.find((t) => t.name === firstTag);
const tag = schema.tags?.find((t) => t.name === firstTag);
indexOperation(firstTag, tag, path, pathItem, httpMethod, operation);
});
});
Expand Down
Loading

0 comments on commit 6e4e7f1

Please sign in to comment.