Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: openapi schema #104

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions web/app/openapi.yml/openapi.yml.liquid
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
openapi: 3.0.0
info:
title: OSSInsight widgets api document
version: 1.0.0
description: TODO

servers:
- url: https://next.ossinsight.io

tags:
- name: widgets
description: All public widgets

paths:
{%- for widget in widgets %}
/widgets/official/{{ widget.normalized_name }}/manifest.json:
get:
tags:
- widgets
operationId: {{ widget.manifest.id | quote }}
summary: {{ widget.manifest.summary | quote }}
description: {{ widget.manifest.description | quote }}
parameters:
{%- for parameter in widget.parameters %}
- name: {{ parameter.name | quote }}
in: {{ parameter.in }}
required: {{ parameter.required }}
description: {{ parameter.description | quote }}
schema:
{{- parameter.schema | yaml: 6 }}
{{- parameter.extra | yaml: 5 }}
{%- endfor %}
responses:
200:
description: 'Successful response for manifest request'
content:
application/json:
schema:
$ref: '#/components/schemas/WidgetManifest'
400:
$ref: '#/components/responses/bad_request'
404:
$ref: '#/components/responses/not_found'
{%- endfor%}

components:
schemas:
WidgetManifest:
type: object
description: Manifest for the analyze
properties:
imageUrl:
type: string
format: uri
description: URL of the thumbnail image.
pageUrl:
type: string
format: uri
description: URL of the analysis page.
title:
type: string
description: Title of the analysis.
description:
type: string
description: Description of the analysis.
keywords:
type: array
items:
type: string
description: Keywords related to the analysis.

responses:
bad_request:
description: Bad request - parameters missing or invalid.
not_found:
description: Resource not found.

examples:
tidb:
description: "The id of GitHub Repository pingcap/tidb is `41986369`"
value: 41986369
tikv:
description: "The id of GitHub Repository pingcap/tikv is `48833910`"
value: 48833910
pingcap:
description: "The id of GitHub Organization @pingcap is `11855343`"
value: 11855343
torvalds:
description: "The id of GitHub User @pingcap is `11855343`"
value: 1024025
132 changes: 132 additions & 0 deletions web/app/openapi.yml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { filterWidgetUrlParameters } from '@/app/widgets/[vendor]/[name]/utils';
import widgets from '@ossinsight/widgets';
import { createWidgetContext } from '@ossinsight/widgets-core/src/utils/context';
import { NextRequest, NextResponse } from 'next/server';
import { compile, TemplateParameter, TemplateScope } from './utils';

export async function GET (req: NextRequest) {
const names = req.nextUrl.searchParams.getAll('names').map(decodeURIComponent);

const scopes = await Promise.allSettled(
Object.entries(widgets)
.filter(([name]) => {
if (names.length > 0) {
return names.includes(name);
} else {
return true;
}
})
.filter(([_, widget]) => {
return !widget.meta.private;
})
.map(async ([widgetName, widget]) => {
const { description, keywords, private: isPrive } = widget.meta;

const generateMetadata = await widget.metadataGenerator();
const parameterDefinitions = await widget.parameterDefinition();

const metadata = await generateMetadata({
...createWidgetContext('client', {}, null as any),
getCollection () { return { id: 0, name: 'Collection', public: true }; },
getRepo () { return { id: 0, fullName: 'Repository' }; },
getUser () { return { id: 0, login: 'Developer' };},
getOrg () { return { id: 0, login: 'Organization' }; },
getTimeParams () { return { zone: 'TimeZone', period: 'Period' }; },
});

const parsedWidgetName = widgetName.replaceAll('@ossinsight/widget-', '');
const finalTitle = metadata.title ?? parsedWidgetName;
const finalDescription = metadata.description ?? description ?? parsedWidgetName;

// TODO: generate more detailed schema for special parameters
const parameters = Object.entries(parameterDefinitions)
.filter(([param]) => filterWidgetUrlParameters(widgetName, param))
.filter(([, def]) => !('expression' in def))
.map(([name, def]) => {
let schema: any;
let extra: any;

switch (def.type) {
case 'repo-id':
case 'repo-ids':
case 'user-id':
case 'collection-id':
case 'owner-id':
case 'limit':
case 'time-zone':
schema = { type: 'number' };
break;
case 'month':
case 'day':
schema = { type: 'string' };
break;
default:
schema = { type: 'string' };
break;
}

if (def.array) {
schema = { type: 'array', items: { oneOf: [schema] } };
}

if ('enums' in def) {
schema.enum = def.enums;
}

switch (def.type) {
case 'repo-id':
extra = {
examples: {
'pingcap/tidb': { $ref: '#/components/examples/tidb' },
'tikv/tikv': { $ref: '#/components/examples/tikv' },
},
};
break;
case 'user-id':
extra = {
examples: {
'torvalds': { $ref: '#/components/examples/torvalds' },
},
};
break;
case 'owner-id':
extra = {
examples: {
'pingcap': { $ref: '#/components/examples/pingcap' },
},
};
break;
}

return {
name,
schema,
extra,
in: 'query',
description: [def.description, def.title].filter(Boolean).join(' - '),
required: def.required ? 'true' : 'false',
} satisfies TemplateParameter;
});

return {
normalized_name: parsedWidgetName,
group: `widgets/${finalTitle}`,
title: finalTitle,
description: finalDescription,
manifest: {
id: 'getManifest',
summary: 'Manifest for ' + finalTitle,
description: finalDescription,
},
parameters,
} satisfies TemplateScope;
}));

const yml = await compile({ widgets: scopes.flatMap(s => s.status === 'fulfilled' ? [s.value] : []) });

return new NextResponse(yml, {
headers: {
'Content-Type': 'application/openapi+yaml',
},
});
}
50 changes: 50 additions & 0 deletions web/app/openapi.yml/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Liquid } from 'liquidjs';
import { stringify } from 'yaml';
import template from './openapi.yml.liquid';

export type TemplateParameter = {
name: string
in: 'query' | 'path'
required: 'true' | 'false'
description: string
schema: object
extra?: object
}

export type TemplateScope = {
group: string
title: string
description: string
normalized_name: string
manifest: {
id: string
summary: string
description: string
}
parameters: TemplateParameter[]
}

const liquid = new Liquid();
liquid.registerFilter('quote', value => {
if (typeof value === 'string') {
return JSON.stringify(value);
}
return JSON.stringify(String(value));
});

liquid.registerFilter('yaml', function (value, tabs) {
if (!value) {
return '';
}

const pfx = ' '.repeat(parseInt(tabs || 0));
return '\n' + stringify(value)
.split('\n')
.map(line => line ? `${pfx}${line}` : line)
.join('\n');
});

export async function compile (scope: { widgets: TemplateScope[] }): Promise<string> {
const tmpl = await liquid.parse(template);
return await liquid.render(tmpl, scope);
}
4 changes: 1 addition & 3 deletions web/app/widgets/[vendor]/[name]/manifest.json/route.ts
Original file line number Diff line number Diff line change
@@ -17,8 +17,7 @@ export async function GET (request: NextRequest, { params: { vendor, name: param
notFound();
}

const { description, keywords } = widgetMeta(name)
console.log(description, keywords)
const { description, keywords } = widgetMeta(name);

const generateMetadata = await widgetMetadataGenerator(name);

@@ -30,7 +29,6 @@ export async function GET (request: NextRequest, { params: { vendor, name: param
parameters[key] = value;
});


const paramDef = await widgetParameterDefinitions(name);
Object.assign(parameters, resolveExpressions(paramDef));
const linkedData = await resolveParameters(paramDef, parameters);
5 changes: 5 additions & 0 deletions web/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
/// <reference types="@ossinsight/widgets-types/modules" />
/// <reference types="@ossinsight/data-service/modules" />

declare module '*.liquid' {
declare const template: string
export default template;
}
7 changes: 5 additions & 2 deletions web/next.config.mjs
Original file line number Diff line number Diff line change
@@ -19,8 +19,11 @@ const nextConfig = {
},
webpack: config => {
config.module.rules.push({
test: /\.sql$/,
use: 'raw-loader',
test: /\.sql$/,
use: 'raw-loader',
}, {
test: /\.liquid$/,
use: 'raw-loader',
})
config.externals.push('@napi-rs/canvas')
return config;
3 changes: 2 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"luxon": "^3.4.3",
"raw-loader": "^4.0.2",
"sass": "^1.68.0",
"tailwind-merge": "^1.14.0"
"tailwind-merge": "^1.14.0",
"yaml": "^2.3.4"
}
}