Compare commits

...

7 Commits

Author SHA1 Message Date
syuilo 6db7aef322 Merge branch 'develop' into refine-api-doc 2021-01-23 20:35:43 +09:00
syuilo 0039f5774d Update ja-JP.yml 2021-01-10 13:49:40 +09:00
syuilo ec6c93b082 wip 2021-01-10 11:40:13 +09:00
syuilo 0ff714d11e Update value.object.vue 2021-01-10 11:25:31 +09:00
syuilo 10c1e8161f wip 2021-01-10 11:16:20 +09:00
syuilo e5107f47cf Merge branch 'develop' into refine-api-doc 2021-01-10 10:34:53 +09:00
syuilo b96651a478 wip 2021-01-09 12:46:07 +09:00
17 changed files with 728 additions and 170 deletions

View File

@ -5,3 +5,6 @@ files:
- source: /src/docs/ja-JP/*.md
translation: /src/docs/%locale%/%original_file_name%
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/'));
});
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([
'./src/emojilist.json',
'./src/server/web/views/**/*',

View File

@ -1590,3 +1590,6 @@ _deck:
list: "リスト"
mentions: "あなた宛て"
direct: "ダイレクト"
_apiDoc:
accessTokenRequired: "アクセストークンが必要です"

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

@ -58,7 +58,7 @@ export default defineComponent({
created() {
os.api('endpoints').then(endpoints => {
this.endpoints = endpoints;
this.endpoints = endpoints.map(x => x.name);
});
},

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 v-if="ep.spec.responses[status].content" :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,67 @@
<template>
<div class="smzyuecx">
<div class="tags">
<button v-for="tag in tags" :key="tag" class="tag _button">
{{ tag }}
</button>
</div>
<ul>
<li v-for="endpoint in endpoints" :key="endpoint.name">
<MkA :to="`/api-docs/endpoints/${endpoint.name}`">{{ endpoint.name }}</MkA>
</li>
</ul>
</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: [],
tags: [],
faQuestionCircle
}
},
created() {
os.api('endpoints').then(endpoints => {
this.endpoints = endpoints;
const tags = new Set();
for (const endpoint of this.endpoints) {
if (endpoint.tags) {
for (const tag of endpoint.tags) {
tags.add(tag);
}
}
}
this.tags = Array.from(tags);
});
},
});
</script>
<style lang="scss" scoped>
.smzyuecx {
> .tags {
> .tag {
display: inline-block;
border: solid 1px var(--divider);
border-radius: 6px;
padding: 12px 16px;
margin: 8px;
}
}
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<div class="">
Array of
<div class="">
<XValue class="" :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,78 @@
<template>
<div class="jhpkzgfz">
<div class="empty" v-if="kvs.length === 0">
No fields
</div>
<div class="kvs" v-else>
<div class="kv" v-for="kv in kvs" :key="kv[0]">
<div class="k _monospace">{{ kv[0] }}</div>
<XValue class="v" :value="kv[1]" :schemas="schemas"/>
</div>
</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 {
border: solid 1px var(--divider);
border-radius: 4px;
padding: 16px;
> .kvs {
> .kv {
display: flex;
&:not(:first-child) {
margin-top: 16px;
padding-top: 16px;
border-top: solid 1px var(--divider);
}
> .k {
font-weight: bold;
margin-right: 1em;
min-width: 8em;
}
> .v {
flex: 1;
}
}
}
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="ezkosiua">
<div class="header _monospace">
<span v-if="value.$ref" class="ref">
<button class="_textButton" @click="resolveRef = true">
{{ value.$ref.replace('#/components/schemas/', '') }}
</button>
</span>
<span class="type">{{ type }}</span>
</div>
<div class="body">
<div class="description">{{ value.description }}</div>
<div v-if="value.$ref" class="ref">
<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>
</div>
</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
},
},
data() {
return {
resolveRef: false,
type: getType(this.value)
};
},
created() {
},
});
</script>
<style lang="scss" scoped>
.ezkosiua {
> .header {
> .ref {
margin-right: 8px;
}
> .type {
border: solid 1px var(--divider);
border-radius: 4px;
padding: 3px 6px;
}
}
> .body {
margin-top: 8px;
}
}
</style>

View File

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

View File

@ -31,6 +31,9 @@ export const router = createRouter({
{ path: '/theme-editor', component: page('theme-editor') },
{ path: '/advanced-theme-editor', component: page('advanced-theme-editor') },
{ 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/tags/:tag', props: true, component: page('explore') },
{ path: '/search', component: page('search') },

View File

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

View File

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

View File

@ -11,5 +11,8 @@ export const meta = {
};
export default define(meta, async () => {
return endpoints.map(x => x.name);
return endpoints.map(x => ({
name: x.name,
tags: x.meta.tags,
}));
});

View File

@ -1,44 +1,22 @@
import endpoints from '../endpoints';
import endpoints, { IEndpoint } from '../endpoints';
import { Context } from 'cafy';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
import config from '../../../config';
import { errors as basicErrors } from './errors';
import { schemas, convertSchemaToOpenApiSchema } from './schemas';
import { getDescription } from './description';
export function genOpenapiSpec(lang = 'ja-JP') {
const spec = {
openapi: '3.0.0',
export function genOpenapiSpecForEndpoint(endpoint: IEndpoint, lang = 'ja-JP') {
let locale;
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'
}
}
}
};
try {
locale = yaml.safeLoad(fs.readFileSync(__dirname + `/../../../api-docs/${lang}/` + endpoint.name + '.yml', 'utf-8'));
} catch (e) {
locale = {
params: {}
};
}
function genProps(props: { [key: string]: Context; }) {
const properties = {} as any;
@ -79,157 +57,195 @@ export function genOpenapiSpec(lang = 'ja-JP') {
};
}
for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) {
const porops = {} as any;
const errors = {} as any;
const porops = {} as any;
const errors = {} as any;
if (endpoint.meta.errors) {
for (const e of Object.values(endpoint.meta.errors)) {
errors[e.code] = {
value: {
error: e
if (endpoint.meta.errors) {
for (const e of Object.values(endpoint.meta.errors)) {
errors[e.code] = {
value: {
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) : {}
}
};
}
}
}
if (endpoint.meta.params) {
for (const [k, v] of Object.entries(endpoint.meta.params)) {
if (v.validator.data == null) v.validator.data = {};
if (v.desc) v.validator.data.desc = v.desc[lang];
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 = (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) : {}
},
responses: {
...(endpoint.meta.res ? {
'200': {
description: 'OK (with results)',
content: {
'application/json': {
schema: resSchema
}
}
}
} : {
'204': {
description: 'OK (without any results)',
}
}),
'400': {
description: 'Client error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: { ...errors, ...basicErrors['400'] }
}
}
},
responses: {
...(endpoint.meta.res ? {
'200': {
description: 'OK (with results)',
content: {
'application/json': {
schema: resSchema
}
}
'401': {
description: 'Authentication error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
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: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: { ...errors, ...basicErrors['400'] }
examples: basicErrors['429']
}
}
},
'401': {
description: 'Authentication error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
examples: basicErrors['401']
}
}
} : {}),
'500': {
description: 'Internal server error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
},
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] = {
post: info
post: genOpenapiSpecForEndpoint(endpoint, lang)
};
}