This commit is contained in:
syuilo 2021-01-09 12:46:07 +09:00
parent f7ebf14501
commit b96651a478
14 changed files with 676 additions and 170 deletions

View File

@ -5,3 +5,6 @@ files:
- source: /src/docs/ja-JP/*.md - source: /src/docs/ja-JP/*.md
translation: /src/docs/%locale%/%original_file_name% translation: /src/docs/%locale%/%original_file_name%
update_option: update_as_unapproved update_option: update_as_unapproved
- source: /src/api-docs/ja-JP/**/*.yml
translation: /src/api-docs/%locale%/**/%original_file_name%
update_option: update_as_unapproved

View File

@ -60,7 +60,14 @@ gulp.task('build:client:style', () => {
.pipe(gulp.dest('./built/server/web/')); .pipe(gulp.dest('./built/server/web/'));
}); });
gulp.task('build:copy', gulp.parallel('build:copy:locales', 'build:copy:views', 'build:client:script', 'build:client:style', 'build:copy:fonts', () => gulp.task('copy:api-docs', () =>
gulp.src([
'./src/api-docs/**/*',
])
.pipe(gulp.dest('./built/api-docs/'))
);
gulp.task('build:copy', gulp.parallel('build:copy:locales', 'copy:api-docs', 'build:copy:views', 'build:client:script', 'build:client:style', 'build:copy:fonts', () =>
gulp.src([ gulp.src([
'./src/emojilist.json', './src/emojilist.json',
'./src/server/web/views/**/*', './src/server/web/views/**/*',

View File

@ -0,0 +1,10 @@
description: "インスタンスのメタ情報を取得します。"
params:
detail: "追加情報を含めるか否か"
res:
version: "Misskeyのバージョン"
announcements: "お知らせ"
announcements.title: "タイトル"
announcements.text: "本文"

View File

@ -0,0 +1,7 @@
description: "ノートを作成します。"
params:
visibility: "ノートの公開範囲"
res:
createdNote: "作成したノート"

View File

@ -0,0 +1,201 @@
<template>
<div class="rfbvytqb" v-size="{ max: [500] }">
<div class="title">{{ endpoint }}</div>
<div class="body" v-if="ep">
<div class="url _code">POST {{ apiUrl }}/{{ endpoint }}</div>
<section class="description">{{ ep.spec.description }}</section>
<MkA to="/api-console" :behavior="'window'">API console</MkA>
<section class="params">
<h2>Params</h2>
<XValue :value="ep.spec.requestBody.content['application/json'].schema" :schemas="ep.schemas"/>
</section>
<section class="res">
<h2>Response</h2>
<section v-for="status in Object.keys(ep.spec.responses)" :key="status">
<h3>{{ status }}</h3>
<XValue :value="ep.spec.responses[status].content['application/json'].schema" :schemas="ep.schemas"/>
</section>
</section>
<section class="raw">
<h2>Raw spec info</h2>
<details>
<summary>Show</summary>
<pre class="_code">{{ JSON.stringify(ep.spec, null, '\t') }}</pre>
</details>
</section>
</div>
<div class="footer">
<MkLink :url="`https://github.com/syuilo/misskey/blob/master/src/docs/${lang}/${doc}.md`" class="at">{{ $ts.docSource }}</MkLink>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'
import { url, lang, apiUrl } from '@/config';
import MkLink from '@/components/link.vue';
import XValue from './value.vue';
import * as os from '@/os';
export default defineComponent({
components: {
MkLink,
XValue,
},
props: {
endpoint: {
type: String,
required: true
}
},
data() {
return {
INFO: computed(() => this.ep ? {
title: this.endpoint,
icon: faQuestionCircle,
} : null),
ep: null,
lang,
apiUrl,
}
},
watch: {
endpoint: {
handler() {
this.fetchDoc();
},
immediate: true,
}
},
methods: {
fetchDoc() {
os.api('endpoint', {
endpoint: this.endpoint,
lang: lang
}).then(ep => {
this.ep = ep;
});
},
}
});
</script>
<style lang="scss" scoped>
.rfbvytqb {
padding: 32px;
&.max-width_500px {
padding: 16px;
}
> .title {
font-size: 1.5em;
font-weight: bold;
padding: 0 0 0.75em 0;
margin: 0 0 1em 0;
border-bottom: solid 2px var(--divider);
}
> .body {
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
> .url {
padding: 6px 12px;
border-radius: 4px;
margin-bottom: 16px;
}
> .raw {
> details {
> pre {
overflow: auto;
}
}
}
::v-deep(a) {
color: var(--link);
}
::v-deep(blockquote) {
display: block;
margin: 8px;
padding: 6px 0 6px 12px;
color: var(--fg);
border-left: solid 3px var(--fg);
opacity: 0.7;
p {
margin: 0;
}
}
::v-deep(h2) {
font-size: 1.25em;
padding: 0 0 0.5em 0;
margin: 1.5em 0 1em 0;
border-bottom: solid 1px var(--divider);
}
::v-deep(table) {
width: 100%;
max-width: 100%;
overflow: auto;
}
::v-deep(kbd.group) {
display: inline-block;
padding: 2px;
border: 1px solid var(--divider);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
::v-deep(kbd.key) {
display: inline-block;
padding: 6px 8px;
border: solid 1px var(--divider);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
::v-deep(code) {
display: inline-block;
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
tab-size: 2;
background: #272822;
color: #f8f8f2;
border-radius: 6px;
padding: 4px 6px;
}
::v-deep(pre) {
background: #272822;
color: #f8f8f2;
border-radius: 6px;
padding: 12px 16px;
> code {
padding: 0;
}
}
}
> .footer {
padding: 1.5em 0 0 0;
margin: 1.5em 0 0 0;
border-top: solid 2px var(--divider);
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div>
<main class="_section">
<div class="_content">
<ul>
<li v-for="endpoint in endpoints" :key="endpoint">
<MkA :to="`/api-docs/endpoints/${endpoint}`">{{ endpoint }}</MkA>
</li>
</ul>
</div>
</main>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'
import { url, lang } from '@/config';
import * as os from '@/os';
export default defineComponent({
data() {
return {
INFO: {
title: 'Misskey API',
icon: faQuestionCircle
},
endpoints: [],
faQuestionCircle
}
},
created() {
os.api('endpoints').then(endpoints => {
this.endpoints = endpoints;
});
},
});
</script>

View File

@ -0,0 +1,46 @@
<template>
<div class="">
Array of
<div class="">
<XValue class="kv _vMargin _shadow" :value="array" :schemas="schemas"/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XValue from './value.vue';
import MkContainer from '@/components/ui/container.vue';
export default defineComponent({
name: 'XArray',
components: {
MkContainer,
XValue,
},
props: {
array: {
type: Object,
required: true
},
schemas: {
type: Object,
required: true
},
},
data() {
return {
};
},
created() {
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,59 @@
<template>
<div class="jhpkzgfz">
<div class="kvs">
<XValue v-for="kv in kvs" :key="kv[0]" class="kv _vMargin _shadow" :name="kv[0]" :value="kv[1]" :schemas="schemas"/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XValue from './value.vue';
import MkContainer from '@/components/ui/container.vue';
export default defineComponent({
name: 'XObject',
components: {
MkContainer,
XValue,
},
props: {
obj: {
type: Object,
required: true
},
schemas: {
type: Object,
required: true
},
},
data() {
return {
kvs: Object.entries(this.obj)
};
},
created() {
},
});
</script>
<style lang="scss" scoped>
.jhpkzgfz {
> .kvs {
> .kv {
::v-deep(.k) {
font-weight: bold;
}
::v-deep(.v) {
padding: 16px;
}
}
}
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<MkContainer :body-togglable="true" class="ezkosiua">
<template #header>
<div class="header _monospace">
<span class="name">{{ name }}</span>
<span class="type">{{ type }}</span>
</div>
</template>
<div class="body">
<div class="description">{{ value.description }}</div>
<div v-if="value.$ref" class="ref">
<button class="_textButton" @click="resolveRef = true">
{{ value.$ref.replace('#/components/schemas/', '') }}
</button>
<div v-if="resolveRef">
<XValue :value="schemas[value.$ref.replace('#/components/schemas/', '')]" :schemas="schemas"/>
</div>
</div>
<div v-else-if="value.type === 'object'">
<XObject :obj="value.properties || {}" :schemas="schemas"/>
</div>
<div v-else-if="value.type === 'array'">
<XArray :array="value.items" :schemas="schemas"/>
</div>
<div v-else-if="value.type === 'string'">
</div>
<div v-else>
unknown
</div>
</div>
</MkContainer>
</template>
<script lang="ts">
import Button from '@/components/ui/button.vue';
import { defineComponent, defineAsyncComponent } from 'vue';
import MkContainer from '@/components/ui/container.vue';
function getType(value) {
let t = value.type === 'array' ? `${getType(value.items)}[]` : value.type;
if (value.nullable) t = `(${t} | null)`;
return t;
}
export default defineComponent({
name: 'XValue',
components: {
MkContainer,
XObject: defineAsyncComponent(() => import('./value.object.vue')),
XArray: defineAsyncComponent(() => import('./value.array.vue')),
Button,
},
props: {
value: {
type: Object,
required: true
},
schemas: {
type: Object,
required: true
},
name: {
type: String,
required: false
},
},
data() {
return {
resolveRef: false,
type: getType(this.value)
};
},
created() {
},
});
</script>
<style lang="scss" scoped>
.ezkosiua {
::v-deep(.header) {
> .name {
font-weight: bold;
margin-right: 1em;
&:empty {
display: none;
}
}
> .type {
border: solid 1px var(--divider);
border-radius: 4px;
padding: 3px 6px;
}
}
::v-deep(.body) {
padding: 16px;
}
}
</style>

View File

@ -7,6 +7,11 @@
<MkA :to="`/docs/${doc.path}`">{{ doc.title }}</MkA> <MkA :to="`/docs/${doc.path}`">{{ doc.title }}</MkA>
</li> </li>
</ul> </ul>
<ul>
<li>
<MkA :to="`/api-docs`">API reference</MkA>
</li>
</ul>
</div> </div>
</main> </main>
</div> </div>

View File

@ -28,8 +28,10 @@ export const router = createRouter({
{ path: '/about-misskey', component: page('about-misskey') }, { path: '/about-misskey', component: page('about-misskey') },
{ path: '/featured', component: page('featured') }, { path: '/featured', component: page('featured') },
{ path: '/docs', component: page('docs') }, { path: '/docs', component: page('docs') },
{ path: '/theme-editor', component: page('theme-editor') },
{ path: '/docs/:doc', component: page('doc'), props: route => ({ doc: route.params.doc }) }, { path: '/docs/:doc', component: page('doc'), props: route => ({ doc: route.params.doc }) },
{ path: '/api-docs', component: page('api-docs/index') },
{ path: '/api-docs/endpoints/:endpoint(.*)', component: page('api-docs/endpoint'), props: route => ({ endpoint: route.params.endpoint }) },
{ path: '/theme-editor', component: page('theme-editor') },
{ path: '/explore', component: page('explore') }, { path: '/explore', component: page('explore') },
{ path: '/explore/tags/:tag', props: true, component: page('explore') }, { path: '/explore/tags/:tag', props: true, component: page('explore') },
{ path: '/search', component: page('search') }, { path: '/search', component: page('search') },

View File

@ -473,6 +473,7 @@ hr {
color: #ccc; color: #ccc;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
tab-size: 2;
padding: 5px; padding: 5px;
} }

View File

@ -1,6 +1,8 @@
import $ from 'cafy'; import $ from 'cafy';
import define from '../define'; import define from '../define';
import endpoints from '../endpoints'; import endpoints from '../endpoints';
import { genOpenapiSpecForEndpoint } from '../openapi/gen-spec';
import { schemas } from '../openapi/schemas';
export const meta = { export const meta = {
requireCredential: false as const, requireCredential: false as const,
@ -9,18 +11,28 @@ export const meta = {
params: { params: {
endpoint: { endpoint: {
// TODO: セキュリティリスクになりうるためバリデーションしたい
validator: $.str, validator: $.str,
},
lang: {
// TODO: セキュリティリスクになりうるためバリデーションしたい
validator: $.str,
default: 'ja-JP'
} }
}, },
}; };
export default define(meta, async (ps) => { export default define(meta, async (ps) => {
if (ps.endpoint.includes('.')) return null;
if (ps.lang.includes('.')) return null;
const ep = endpoints.find(x => x.name === ps.endpoint); const ep = endpoints.find(x => x.name === ps.endpoint);
if (ep == null) return null; if (ep == null) return null;
return { return {
params: Object.entries(ep.meta.params || {}).map(([k, v]) => ({ params: Object.entries(ep.meta.params || {}).map(([k, v]) => ({
name: k, name: k,
type: v.validator.name === 'ID' ? 'String' : v.validator.name type: v.validator.name === 'ID' ? 'String' : v.validator.name
})) })),
schemas: schemas,
spec: genOpenapiSpecForEndpoint(ep, ps.lang)
}; };
}); });

View File

@ -1,44 +1,14 @@
import endpoints from '../endpoints'; import endpoints, { IEndpoint } from '../endpoints';
import { Context } from 'cafy'; import { Context } from 'cafy';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
import config from '../../../config'; import config from '../../../config';
import { errors as basicErrors } from './errors'; import { errors as basicErrors } from './errors';
import { schemas, convertSchemaToOpenApiSchema } from './schemas'; import { schemas, convertSchemaToOpenApiSchema } from './schemas';
import { getDescription } from './description'; import { getDescription } from './description';
export function genOpenapiSpec(lang = 'ja-JP') { export function genOpenapiSpecForEndpoint(endpoint: IEndpoint, lang = 'ja-JP') {
const spec = { const locale = yaml.safeLoad(fs.readFileSync(__dirname + `/../../../api-docs/${lang}/` + endpoint.name + '.yml', 'utf-8'));
openapi: '3.0.0',
info: {
version: 'v1',
title: 'Misskey API',
description: getDescription(lang),
'x-logo': { url: '/assets/api-doc.png' }
},
externalDocs: {
description: 'Repository',
url: 'https://github.com/syuilo/misskey'
},
servers: [{
url: config.apiUrl
}],
paths: {} as any,
components: {
schemas: schemas,
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'body',
name: 'i'
}
}
}
};
function genProps(props: { [key: string]: Context; }) { function genProps(props: { [key: string]: Context; }) {
const properties = {} as any; const properties = {} as any;
@ -79,157 +49,195 @@ export function genOpenapiSpec(lang = 'ja-JP') {
}; };
} }
for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { const porops = {} as any;
const porops = {} as any; const errors = {} as any;
const errors = {} as any;
if (endpoint.meta.errors) { if (endpoint.meta.errors) {
for (const e of Object.values(endpoint.meta.errors)) { for (const e of Object.values(endpoint.meta.errors)) {
errors[e.code] = { errors[e.code] = {
value: { value: {
error: e error: e
}
};
}
}
if (endpoint.meta.params) {
for (const [k, v] of Object.entries(endpoint.meta.params)) {
if (v.validator.data == null) v.validator.data = {};
v.validator.data.desc = locale.params[k];
if (v.deprecated) v.validator.data.deprecated = v.deprecated;
if (v.default) v.validator.data.default = v.default;
porops[k] = v.validator;
}
}
const required = endpoint.meta.params ? Object.entries(endpoint.meta.params).filter(([k, v]) => !v.validator.isOptional).map(([k, v]) => k) : [];
const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {};
let desc = (locale.description || 'No description provided.') + '\n\n';
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
if (endpoint.meta.kind) {
const kind = endpoint.meta.kind;
desc += ` / **Permission**: *${kind}*`;
}
const info = {
operationId: endpoint.name,
summary: endpoint.name,
description: desc,
externalDocs: {
description: 'Source code',
url: `https://github.com/syuilo/misskey/blob/develop/src/server/api/endpoints/${endpoint.name}.ts`
},
...(endpoint.meta.tags ? {
tags: [endpoint.meta.tags[0]]
} : {}),
...(endpoint.meta.requireCredential ? {
security: [{
ApiKeyAuth: []
}]
} : {}),
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
...(required.length > 0 ? { required } : {}),
properties: endpoint.meta.params ? genProps(porops) : {}
} }
}; }
} }
} },
responses: {
if (endpoint.meta.params) { ...(endpoint.meta.res ? {
for (const [k, v] of Object.entries(endpoint.meta.params)) { '200': {
if (v.validator.data == null) v.validator.data = {}; description: 'OK (with results)',
if (v.desc) v.validator.data.desc = v.desc[lang]; content: {
if (v.deprecated) v.validator.data.deprecated = v.deprecated; 'application/json': {
if (v.default) v.validator.data.default = v.default; schema: resSchema
porops[k] = v.validator;
}
}
const required = endpoint.meta.params ? Object.entries(endpoint.meta.params).filter(([k, v]) => !v.validator.isOptional).map(([k, v]) => k) : [];
const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {};
let desc = (endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.') + '\n\n';
desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`;
if (endpoint.meta.kind) {
const kind = endpoint.meta.kind;
desc += ` / **Permission**: *${kind}*`;
}
const info = {
operationId: endpoint.name,
summary: endpoint.name,
description: desc,
externalDocs: {
description: 'Source code',
url: `https://github.com/syuilo/misskey/blob/develop/src/server/api/endpoints/${endpoint.name}.ts`
},
...(endpoint.meta.tags ? {
tags: [endpoint.meta.tags[0]]
} : {}),
...(endpoint.meta.requireCredential ? {
security: [{
ApiKeyAuth: []
}]
} : {}),
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
...(required.length > 0 ? { required } : {}),
properties: endpoint.meta.params ? genProps(porops) : {}
} }
} }
} }
} : {
'204': {
description: 'OK (without any results)',
}
}),
'400': {
description: 'Client error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: { ...errors, ...basicErrors['400'] }
}
}
}, },
responses: { '401': {
...(endpoint.meta.res ? { description: 'Authentication error',
'200': { content: {
description: 'OK (with results)', 'application/json': {
content: { schema: {
'application/json': { $ref: '#/components/schemas/Error'
schema: resSchema },
} examples: basicErrors['401']
}
} }
} : { }
'204': { },
description: 'OK (without any results)', '403': {
description: 'Forbiddon error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['403']
} }
}), }
'400': { },
description: 'Client error', '418': {
description: 'I\'m Ai',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['418']
}
}
},
...(endpoint.meta.limit ? {
'429': {
description: 'To many requests',
content: { content: {
'application/json': { 'application/json': {
schema: { schema: {
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
}, },
examples: { ...errors, ...basicErrors['400'] } examples: basicErrors['429']
} }
} }
}, }
'401': { } : {}),
description: 'Authentication error', '500': {
content: { description: 'Internal server error',
'application/json': { content: {
schema: { 'application/json': {
$ref: '#/components/schemas/Error' schema: {
}, $ref: '#/components/schemas/Error'
examples: basicErrors['401'] },
} examples: basicErrors['500']
} }
}, }
'403': { },
description: 'Forbiddon error', }
content: { };
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['403']
}
}
},
'418': {
description: 'I\'m Ai',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['418']
}
}
},
...(endpoint.meta.limit ? {
'429': {
description: 'To many requests',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['429']
}
}
}
} : {}),
'500': {
description: 'Internal server error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['500']
}
}
},
}
};
return info;
}
export function genOpenapiSpec(lang = 'ja-JP') {
const spec = {
openapi: '3.0.0',
info: {
version: 'v1',
title: 'Misskey API',
description: getDescription(lang),
'x-logo': { url: '/assets/api-doc.png' }
},
externalDocs: {
description: 'Repository',
url: 'https://github.com/syuilo/misskey'
},
servers: [{
url: config.apiUrl
}],
paths: {} as any,
components: {
schemas: schemas,
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'body',
name: 'i'
}
}
}
};
for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) {
spec.paths['/' + endpoint.name] = { spec.paths['/' + endpoint.name] = {
post: info post: genOpenapiSpecForEndpoint(endpoint, lang)
}; };
} }