#60 [api] Public API

This commit is contained in:
Avris 2020-11-10 23:41:56 +01:00
parent 8fcf96040e
commit 629a12698e
23 changed files with 241 additions and 44 deletions

View File

@ -182,7 +182,7 @@
if (this.nounsRaw !== undefined) {
return;
}
this.nounsRaw = await this.$axios.$get(`/nouns/all`);
this.nounsRaw = await this.$axios.$get(`/nouns`);
},
async setFilter(filter) {
this.filter = filter;

View File

@ -37,12 +37,20 @@
<Icon v="gitlab" set="b"/>
</SquareButton>
</div>
<p v-if="config.user.enabled" class="small">
<ul v-if="config.user.enabled" class="list-inline small">
<li class="list-inline-item">
<nuxt-link :to="`/${config.user.termsRoute}`">
<Icon v="gavel"/>
<T>terms.header</T>
</nuxt-link>
</p>
</li>
<li class="list-inline-item">
<nuxt-link to="/api">
<Icon v="laptop-code"/>
<T>api.header</T>
</nuxt-link>
</li>
</ul>
<Share/>
</div>
</div>

View File

@ -87,8 +87,8 @@
return jwt.verify(this.token, process.env.PUBLIC_KEY, {
algorithm: 'RS256',
audience: process.env.BASE_URL,
issuer: process.env.BASE_URL,
audience: this.$base,
issuer: this.$base,
});
}
},

View File

@ -118,7 +118,6 @@
<script>
import { nounTemplates } from '../src/data';
import config from "../data/config.suml";
export default {
data() {

View File

@ -59,7 +59,7 @@
data() {
return {
preset: {
url: process.env.BASE_URL + this.$route.path,
url: this.$base + this.$route.path,
title: this.title,
extra: {
media: '',

View File

@ -247,3 +247,16 @@ profile:
redirects:
- { from: '^/neutratywy', to: '/rzeczowniki' }
- { from: '^/literatura', to: '/korpus' }
api:
examples:
pronouns_all: ['/api/pronouns']
pronouns_one:
- '/api/pronouns/ono/jej'
- '/api/pronouns/ono/jej?examples[]=Czy%20chcia%C5%82%7Bverb_end_inter%7Dby%C5%9B%20skorzysta%C4%87%20z%20naszej%20oferty%3F%7CCzy%20chci%7Bverb_middle_inter%7Dby%C5%9Bcie%20skorzysta%C4%87%20z%20naszej%20oferty%3F%7C0'
sources_all: ['/api/sources']
sources_one: ['/api/sources/queerZaimki']
nouns_all: ['/api/nouns']
nouns_search: ['/api/nouns/search/ateis']

View File

@ -805,3 +805,8 @@ table:
count: 'Liczba'
sort: 'Przeciągnij by posortować'
scrollUp: 'Przewiń na samą górę'
api:
header: 'Publiczne API'
example: 'Przykład'
query: 'Query string parameters'

View File

@ -1,8 +1,10 @@
import translations from './server/translations';
import config from './server/config';
import { loadSuml } from './server/loader';
import fs from 'fs';
import {buildDict} from "./src/helpers";
const config = loadSuml('config');
const translations = loadSuml('translations');
const locale = config.locale;
const title = translations.title;
const description = translations.description;
@ -158,6 +160,8 @@ export default {
routes.push({ path: '/' + config.template.any.route, component: resolve(__dirname, 'routes/any.vue') });
}
routes.push({ path: '/api', component: resolve(__dirname, 'routes/api.vue') });
routes.push({ name: 'all', path: '*', component: resolve(__dirname, 'routes/template.vue') });
},
},

View File

@ -5,6 +5,7 @@ import { locales } from '../src/data';
import {buildDict} from "../src/helpers";
export default ({ app }) => {
Vue.prototype.$base = process.env.BASE_URL;
Vue.prototype.$t = t;
Vue.prototype.config = config;
Vue.prototype.locales = buildDict(function* () {

78
routes/api.vue Normal file
View File

@ -0,0 +1,78 @@
<template>
<div class="container">
<h2>
<Icon v="laptop-code"/>
<T>api.header</T>
</h2>
<section v-for="group in groups" v-if="group.enabled" class="py-2">
<h3>
<Icon :v="group.icon"/>
<T>{{group.header}}</T>
</h3>
<ul>
<li v-for="([method, path, queryString], endpoint) in group.endpoints" class="my-3">
<p>
<span class="badge badge-primary">{{method}}</span>
<code>{{path}}</code>
<a v-for="example in config.api.examples[endpoint]" :href="$base + example" class="badge badge-light border mx-1" target="_blank" rel="noopener">
<Icon v="cog"/>
<T>api.example</T>
</a>
</p>
<p v-if="queryString" class="mb-0 small">
<T>api.query</T>:
</p>
<ul v-if="queryString" class="small">
<li v-for="(description, param) in queryString">
<code>{{param}}</code> <span v-html="description"></span>
</li>
</ul>
</li>
</ul>
</section>
</div>
</template>
<script>
import {head} from "../src/helpers";
export default {
data() {
return {
groups: [{
enabled: this.config.template.enabled,
header: 'home.header',
icon: 'tags',
endpoints: {
pronouns_all: ['GET', '/api/pronouns'],
pronouns_one: ['GET', '/api/pronouns/{pronoun}', {
'examples[]': 'Overwrite the default example sentences with custom ones. For each of them use the following format: <code>{sentenceSingular}|{sentencePlural}|{isHonorific}</code>. If <code>sentencePlural</code> is missing, if defaults to being the same as <code>sentenceSingular</code>. <code>isHonorific</code> can be <code>0</code> (default) or <code>1</code>.',
}],
},
}, {
enabled: this.config.sources.enabled,
header: 'sources.header',
icon: 'books',
endpoints: {
sources_all: ['GET', '/api/sources'],
sources_one: ['GET', '/api/sources/{key}'],
},
}, {
enabled: this.config.nouns.enabled,
header: 'nouns.header',
icon: 'atom-alt',
endpoints: {
nouns_all: ['GET', '/api/nouns'],
nouns_search: ['GET', '/api/nouns/search/{term}'],
},
}],
};
},
head() {
return head({
title: this.$t('api.header'),
});
},
}
</script>

View File

@ -243,14 +243,14 @@
if (!this.selectedTemplate.pronoun()) {
return null;
}
return this.addSlash(process.env.BASE_URL + '/' + (this.usedBaseEquals ? this.usedBase : this.longLink));
return this.addSlash(this.$base + '/' + (this.usedBaseEquals ? this.usedBase : this.longLink));
},
linkMultiple() {
if (!this.multiple.length) {
return null;
}
return this.addSlash(process.env.BASE_URL + '/' + this.multiple.join('&'));
return this.addSlash(this.$base + '/' + this.multiple.join('&'));
},
sources() {
return getSources(this.selectedTemplate);

View File

@ -141,7 +141,7 @@
for (let pronoun in this.profile.pronouns) {
if (!this.profile.pronouns.hasOwnProperty(pronoun)) { continue; }
const link = pronoun.replace(new RegExp('^' + process.env.BASE_URL), '').replace(new RegExp('^/'), '');
const link = pronoun.replace(new RegExp('^' + this.$base), '').replace(new RegExp('^/'), '');
const template = buildTemplate(templates, link);
if (template) {

View File

@ -188,7 +188,7 @@
this.$router.push(`/@${this.$user().username}`)
},
validatePronoun(pronoun) {
const link = pronoun.replace(new RegExp('^' + process.env.BASE_URL), '').replace(new RegExp('^/'), '');
const link = pronoun.replace(new RegExp('^' + this.$base), '').replace(new RegExp('^/'), '');
const template = buildTemplate(templates, link);
return template ? null : 'profile.pronounsNotFound'

View File

@ -1,3 +0,0 @@
import Suml from 'suml';
const fs = require('fs');
export default new Suml().parse(fs.readFileSync('./data/config.suml').toString());

View File

@ -5,7 +5,7 @@ import session from 'express-session';
import cookieParser from 'cookie-parser';
import grant from "grant";
import router from "./routes/user";
import config from './config';
import { loadSuml } from './loader';
const app = express()
@ -18,7 +18,7 @@ app.use(session({
}));
app.use(async function (req, res, next) {
req.config = config;
req.config = loadSuml('config');
req.rawUser = authenticate(req);
req.user = req.rawUser && req.rawUser.authenticated ? req.rawUser : null;
req.admin = req.user && req.user.roles === 'admin';
@ -34,8 +34,9 @@ app.use(require('./routes/user').default);
app.use(require('./routes/profile').default);
app.use(require('./routes/admin').default);
app.use(require('./routes/nouns').default);
app.use(require('./routes/templates').default);
app.use(require('./routes/sources').default);
app.use(require('./routes/nouns').default);
export default {
path: '/api',

12
server/loader.js Normal file
View File

@ -0,0 +1,12 @@
const fs = require('fs');
import Suml from 'suml';
import Papa from 'papaparse';
export const loadSuml = name => new Suml().parse(fs.readFileSync(`./data/${name}.suml`).toString());
export const loadTsv = name => Papa.parse(fs.readFileSync(`./data/${name}.tsv`).toString(), {
dynamicTyping: true,
header: true,
skipEmptyLines: true,
delimiter: '\t',
}).data;

View File

@ -1,11 +1,13 @@
import { Router } from 'express';
import SQL from 'sql-template-strings';
import {createCanvas, loadImage, registerFont} from "canvas";
import translations from "../translations";
import { loadSuml } from '../loader';
import avatar from '../avatar';
import {buildTemplate, parseTemplates} from "../../src/buildTemplate";
import {loadTsv} from "../../src/tsv";
const translations = loadSuml('translations');
const drawCircle = (context, image, x, y, size) => {
context.save();
context.beginPath();

View File

@ -23,7 +23,7 @@ const approve = async (db, id) => {
const router = Router();
router.get('/nouns/all', async (req, res) => {
router.get('/nouns', async (req, res) => {
return res.json(await req.db.all(SQL`
SELECT * FROM nouns
WHERE locale = ${req.config.locale}
@ -32,6 +32,17 @@ router.get('/nouns/all', async (req, res) => {
`));
});
router.get('/nouns/search/:term', async (req, res) => {
const term = '%' + req.params.term + '%';
return res.json(await req.db.all(SQL`
SELECT * FROM nouns
WHERE locale = ${req.config.locale}
AND approved >= ${req.admin ? 0 : 1}
AND (masc like ${term} OR fem like ${term} OR neutr like ${term} OR mascPl like ${term} OR femPl like ${term} OR neutrPl like ${term})
ORDER BY approved, masc
`));
});
router.post('/nouns/submit', async (req, res) => {
if (!(req.user && req.user.admin) && isTroll(JSON.stringify(req.body))) {
return res.json('ok');

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import mailer from "../../src/mailer";
import {camelCase, capitalise} from "../../src/helpers";
import { camelCase } from "../../src/helpers";
import { loadTsv } from '../loader';
const generateId = title => {
return camelCase(title.split(' ').slice(0, 2));
@ -21,8 +22,26 @@ const buildEmail = (data, user) => {
return `<ul>${human.join('')}</ul><pre>${tsv.join('\t')}</pre>`;
}
const loadSources = () => {
return loadTsv('sources/sources').map(s => {
if (s.author) {
s.author = s.author.replace('^', '');
}
s.fragments = s.fragments.split('@').map(f => f.replace(/\|/g, '\n'));
return s;
});
}
const router = Router();
router.get('/sources', async (req, res) => {
return res.json(loadSources());
});
router.get('/sources/:key', async (req, res) => {
return res.json([...loadSources().filter(s => s.key === req.params.key), null][0]);
});
router.post('/sources/submit', async (req, res) => {
const emailBody = buildEmail(req.body, req.user);

View File

@ -0,0 +1,56 @@
import { Router } from 'express';
import { loadTsv } from '../loader';
import {buildTemplate, parseTemplates} from "../../src/buildTemplate";
import {buildList} from "../../src/helpers";
import {Example} from "../../src/classes";
const buildExample = e => new Example(
Example.parse(e.singular),
Example.parse(e.plural || e.singular),
e.isHonorific,
)
const requestExamples = r => {
if (!r || !r.length) {
return loadTsv('templates/examples');
}
return buildList(function* () {
for (let rr of r) {
let [singular, plural, isHonorific] = rr.split('|');
yield { singular, plural, isHonorific: !!isHonorific};
}
});
}
const addExamples = (pronoun, examples) => {
return buildList(function* () {
for (let example of examples) {
yield buildExample(example).format(pronoun);
}
});
}
const router = Router();
router.get('/pronouns', async (req, res) => {
const templates = parseTemplates(loadTsv('templates/templates'));
for (let template in templates) {
if (!templates.hasOwnProperty(template)) { continue; }
templates[template].examples = addExamples(templates[template], requestExamples(req.query.examples))
}
return res.json(templates);
});
router.get('/pronouns/:pronoun*', async (req, res) => {
const pronoun = buildTemplate(
parseTemplates(loadTsv('templates/templates')),
req.params.pronoun + req.params[0],
);
if (pronoun) {
pronoun.examples = addExamples(pronoun, requestExamples(req.query.examples))
}
return res.json(pronoun);
});
export default router;

View File

@ -2,13 +2,15 @@ import { Router } from 'express';
import SQL from 'sql-template-strings';
import {ulid} from "ulid";
import {buildDict, makeId, now} from "../../src/helpers";
import translations from "../translations";
import jwt from "../../src/jwt";
import mailer from "../../src/mailer";
import config from '../config';
import { loadSuml } from '../loader';
import avatar from '../avatar';
import { config as socialLoginConfig, handlers as socialLoginHandlers } from '../social';
const config = loadSuml('config');
const translations = loadSuml('translations');
const USERNAME_CHARS = 'A-Za-zĄĆĘŁŃÓŚŻŹąćęłńóśżź0-9._-';
const normalise = s => s.trim().toLowerCase();

View File

@ -1,3 +0,0 @@
import Suml from 'suml';
const fs = require('fs');
export default new Suml().parse(fs.readFileSync('./data/translations.suml').toString());

View File

@ -6,14 +6,6 @@ export class ExamplePart {
this.variable = variable;
this.str = str;
}
format(form) {
if (!this.variable) {
return this.str[form.plural];
}
return form[this.str[form.plural]];
}
}
export class Example {
@ -44,12 +36,12 @@ export class Example {
return parts;
}
format(form) {
return Example.ucfirst(this.parts.map(part => part.format(form)).join(''));
}
format(pronoun) {
const plural = this.isHonorific ? pronoun.pluralHonorific[0] : pronoun.plural[0];
static ucfirst(str) {
return str[0].toUpperCase() + str.slice(1);
return capitalise(this[plural ? 'pluralParts' : 'singularParts'].map(part => {
return part.variable ? pronoun.getMorpheme(part.str) : part.str;
}).join(''));
}
}