From e307b47a8293cd52256cfda8ae1f9288c913a900 Mon Sep 17 00:00:00 2001 From: Jagger <634750802@qq.com> Date: Wed, 13 Dec 2023 17:56:14 +0800 Subject: [PATCH 1/3] feat: openapi schema --- web/app/openapi.yml/openapi.yml.liquid | 75 +++++++++++++++ web/app/openapi.yml/route.ts | 94 +++++++++++++++++++ web/app/openapi.yml/utils.ts | 38 ++++++++ .../[vendor]/[name]/manifest.json/route.ts | 4 +- 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 web/app/openapi.yml/openapi.yml.liquid create mode 100644 web/app/openapi.yml/route.ts create mode 100644 web/app/openapi.yml/utils.ts diff --git a/web/app/openapi.yml/openapi.yml.liquid b/web/app/openapi.yml/openapi.yml.liquid new file mode 100644 index 00000000..c8339022 --- /dev/null +++ b/web/app/openapi.yml/openapi.yml.liquid @@ -0,0 +1,75 @@ +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: + type: {{ parameter.type }} + {%- 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. diff --git a/web/app/openapi.yml/route.ts b/web/app/openapi.yml/route.ts new file mode 100644 index 00000000..03ba968a --- /dev/null +++ b/web/app/openapi.yml/route.ts @@ -0,0 +1,94 @@ +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)) + .map(([name, def]) => { + let type: TemplateParameter['type']; + switch (def.type) { + case 'repo-id': + case 'user-id': + case 'collection-id': + case 'owner-id': + case 'limit': + case 'time-zone': + type = 'number'; + break; + case 'month': + case 'day': + type = 'string'; + break; + default: + type = 'string'; + break; + } + + return { + name, + type, + in: 'query', + description: [def.title, def.description].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', + }, + }); +} \ No newline at end of file diff --git a/web/app/openapi.yml/utils.ts b/web/app/openapi.yml/utils.ts new file mode 100644 index 00000000..aedfcc50 --- /dev/null +++ b/web/app/openapi.yml/utils.ts @@ -0,0 +1,38 @@ +import { Liquid } from 'liquidjs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +export type TemplateParameter = { + name: string + in: 'query' | 'path' + required: 'true' | 'false' + description: string + type: 'string' | 'number' | 'boolean' +} + +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)); +}); + +export async function compile (scope: { widgets: TemplateScope[] }): Promise { + const tmplPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'openapi.yml.liquid'); + const tmpl = await liquid.parseFile(tmplPath); + return await liquid.render(tmpl, scope); +} diff --git a/web/app/widgets/[vendor]/[name]/manifest.json/route.ts b/web/app/widgets/[vendor]/[name]/manifest.json/route.ts index aa3832a4..f534c697 100644 --- a/web/app/widgets/[vendor]/[name]/manifest.json/route.ts +++ b/web/app/widgets/[vendor]/[name]/manifest.json/route.ts @@ -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); From 08ccaf4d51d5cb8f1ca15e2871c42366ac161108 Mon Sep 17 00:00:00 2001 From: Jagger <634750802@qq.com> Date: Wed, 13 Dec 2023 18:04:54 +0800 Subject: [PATCH 2/3] fix --- web/app/openapi.yml/utils.ts | 6 ++---- web/env.d.ts | 5 +++++ web/next.config.mjs | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/app/openapi.yml/utils.ts b/web/app/openapi.yml/utils.ts index aedfcc50..5e8c0f3b 100644 --- a/web/app/openapi.yml/utils.ts +++ b/web/app/openapi.yml/utils.ts @@ -1,6 +1,5 @@ import { Liquid } from 'liquidjs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import template from './openapi.yml.liquid'; export type TemplateParameter = { name: string @@ -32,7 +31,6 @@ liquid.registerFilter('quote', value => { }); export async function compile (scope: { widgets: TemplateScope[] }): Promise { - const tmplPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'openapi.yml.liquid'); - const tmpl = await liquid.parseFile(tmplPath); + const tmpl = await liquid.parse(template); return await liquid.render(tmpl, scope); } diff --git a/web/env.d.ts b/web/env.d.ts index d4d25bfc..47c274ad 100644 --- a/web/env.d.ts +++ b/web/env.d.ts @@ -1,2 +1,7 @@ /// /// + +declare module '*.liquid' { + declare const template: string + export default template; +} diff --git a/web/next.config.mjs b/web/next.config.mjs index 2560e68f..e2f1289c 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -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; From 73236f56b323b691a1364877a116c17af1e3099b Mon Sep 17 00:00:00 2001 From: Jagger <634750802@qq.com> Date: Thu, 14 Dec 2023 15:28:41 +0800 Subject: [PATCH 3/3] add examples and enum --- pnpm-lock.yaml | 9 +++-- web/app/openapi.yml/openapi.yml.liquid | 17 ++++++++- web/app/openapi.yml/route.ts | 50 ++++++++++++++++++++++---- web/app/openapi.yml/utils.ts | 16 ++++++++- web/package.json | 3 +- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 139acac4..79841efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,6 +455,9 @@ importers: tailwind-merge: specifier: ^1.14.0 version: 1.14.0 + yaml: + specifier: ^2.3.4 + version: 2.3.4 widgets: dependencies: @@ -11911,7 +11914,7 @@ packages: dependencies: lilconfig: 2.1.0 postcss: 8.4.26 - yaml: 2.3.3 + yaml: 2.3.4 /postcss-loader@7.3.3(postcss@8.4.26)(typescript@5.2.2)(webpack@5.89.0): resolution: {integrity: sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==} @@ -14372,8 +14375,8 @@ packages: engines: {node: '>= 6'} dev: true - /yaml@2.3.3: - resolution: {integrity: sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==} + /yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} /yargs-parser@20.2.9: diff --git a/web/app/openapi.yml/openapi.yml.liquid b/web/app/openapi.yml/openapi.yml.liquid index c8339022..6d62ddf7 100644 --- a/web/app/openapi.yml/openapi.yml.liquid +++ b/web/app/openapi.yml/openapi.yml.liquid @@ -27,7 +27,8 @@ paths: required: {{ parameter.required }} description: {{ parameter.description | quote }} schema: - type: {{ parameter.type }} + {{- parameter.schema | yaml: 6 }} + {{- parameter.extra | yaml: 5 }} {%- endfor %} responses: 200: @@ -73,3 +74,17 @@ components: 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 \ No newline at end of file diff --git a/web/app/openapi.yml/route.ts b/web/app/openapi.yml/route.ts index 03ba968a..12215248 100644 --- a/web/app/openapi.yml/route.ts +++ b/web/app/openapi.yml/route.ts @@ -41,31 +41,69 @@ export async function GET (req: NextRequest) { // 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 type: TemplateParameter['type']; + 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': - type = 'number'; + schema = { type: 'number' }; break; case 'month': case 'day': - type = 'string'; + schema = { type: 'string' }; break; default: - type = 'string'; + 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, - type, + schema, + extra, in: 'query', - description: [def.title, def.description].filter(Boolean).join(' - '), + description: [def.description, def.title].filter(Boolean).join(' - '), required: def.required ? 'true' : 'false', } satisfies TemplateParameter; }); diff --git a/web/app/openapi.yml/utils.ts b/web/app/openapi.yml/utils.ts index 5e8c0f3b..1f7b46e2 100644 --- a/web/app/openapi.yml/utils.ts +++ b/web/app/openapi.yml/utils.ts @@ -1,4 +1,5 @@ import { Liquid } from 'liquidjs'; +import { stringify } from 'yaml'; import template from './openapi.yml.liquid'; export type TemplateParameter = { @@ -6,7 +7,8 @@ export type TemplateParameter = { in: 'query' | 'path' required: 'true' | 'false' description: string - type: 'string' | 'number' | 'boolean' + schema: object + extra?: object } export type TemplateScope = { @@ -30,6 +32,18 @@ liquid.registerFilter('quote', 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 { const tmpl = await liquid.parse(template); return await liquid.render(tmpl, scope); diff --git a/web/package.json b/web/package.json index 8d97eb91..aa8e8bb0 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } }