feat(openapi): Started working on Openapi generation
- Updated to Typebox 1.0.0 to better support Openapi type generation - Changed dockerfile to fetch depedencies only once - Fixed Routes to properly handle openapi - Fixed Routes to respond with multiples status code (no more only 200) - Fixed Schemas so the auth-gated endpoint properly reflect that - Added Makefile rule to generate openapi client (none working due to missing files)
This commit is contained in:
parent
1bd2b4594b
commit
b7c2a3dff9
36 changed files with 5472 additions and 833 deletions
|
|
@ -11,7 +11,8 @@
|
|||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"@fastify/swagger": "^9.6.0",
|
||||
"@fastify/swagger-ui": "^5.2.3",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
|
@ -19,10 +20,11 @@
|
|||
"fastify-plugin": "^5.1.0",
|
||||
"joi": "^18.0.1",
|
||||
"otp": "^1.1.2",
|
||||
"typebox": "^1.0.51",
|
||||
"uuidv7": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.18.13"
|
||||
"@types/node": "^22.19.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import cookie from '@fastify/cookie';
|
|||
import fastifyJwt from '@fastify/jwt';
|
||||
import fp from 'fastify-plugin';
|
||||
import { FastifyPluginAsync, preValidationAsyncHookHandler } from 'fastify';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { Static, TSchema, Type } from 'typebox';
|
||||
import * as Typebox from 'typebox';
|
||||
import { UserId } from '@shared/database/mixin/user';
|
||||
import { useDatabase } from '@shared/database';
|
||||
import { isNullish, makeResponse } from '@shared/utils';
|
||||
import { isNullish, typeResponse } from '@shared/utils';
|
||||
|
||||
const kRouteAuthDone = Symbol('shared-route-auth-done');
|
||||
|
||||
|
|
@ -53,11 +54,8 @@ export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
|||
|
||||
export const JwtType = Type.Object({
|
||||
kind: Type.Union([
|
||||
Type.Const('otp', {
|
||||
description: 'the token is only valid for otp call',
|
||||
}),
|
||||
Type.Const('auth', {
|
||||
description: 'the token is valid for authentication',
|
||||
Type.Enum(['otp', 'auth'], {
|
||||
description: 'otp: token represent an inflight login request\nauth: represent a logged in user',
|
||||
}),
|
||||
]),
|
||||
who: Type.String({ description: 'the login of the user' }),
|
||||
|
|
@ -68,62 +66,75 @@ export const JwtType = Type.Object({
|
|||
|
||||
export type JwtType = Static<typeof JwtType>;
|
||||
|
||||
let authAdded = false;
|
||||
export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
||||
void _opts;
|
||||
export const authSchema = typeResponse('notLoggedIn', ['auth.noCookie', 'auth.invalidKind', 'auth.noUser', 'auth.invalid']);
|
||||
|
||||
if (authAdded) return void console.log('skipping');
|
||||
let authAdded = false;
|
||||
export const authPlugin = fp<{ onlySchema?: boolean }>(async (fastify, { onlySchema }) => {
|
||||
|
||||
if (authAdded) return;
|
||||
const bOnlySchema = onlySchema ?? false;
|
||||
authAdded = true;
|
||||
await fastify.register(useDatabase as FastifyPluginAsync, {});
|
||||
await fastify.register(jwtPlugin as FastifyPluginAsync, {});
|
||||
await fastify.register(cookie);
|
||||
if (!fastify.hasRequestDecorator('authUser')) { fastify.decorateRequest('authUser', undefined); }
|
||||
if (!bOnlySchema) {
|
||||
await fastify.register(useDatabase as FastifyPluginAsync, {});
|
||||
await fastify.register(jwtPlugin as FastifyPluginAsync, {});
|
||||
await fastify.register(cookie);
|
||||
if (!fastify.hasRequestDecorator('authUser')) { fastify.decorateRequest('authUser', undefined); }
|
||||
}
|
||||
fastify.addHook('onRoute', (routeOpts) => {
|
||||
if (
|
||||
routeOpts.config?.requireAuth &&
|
||||
!routeOpts[kRouteAuthDone]
|
||||
) {
|
||||
const f: preValidationAsyncHookHandler = async function(req, res) {
|
||||
try {
|
||||
if (isNullish(req.cookies.token)) {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(
|
||||
JSON.stringify(makeResponse('notLoggedIn', 'auth.noCookie')),
|
||||
);
|
||||
}
|
||||
const tok = this.jwt.verify<JwtType>(req.cookies.token);
|
||||
if (tok.kind != 'auth') {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(
|
||||
JSON.stringify(makeResponse('notLoggedIn', 'auth.invalidKind')),
|
||||
);
|
||||
}
|
||||
const user = this.db.getUser(tok.who);
|
||||
if (isNullish(user)) {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(
|
||||
JSON.stringify(makeResponse('notLoggedIn', 'auth.noUser')),
|
||||
);
|
||||
}
|
||||
req.authUser = { id: user.id, name: tok.who };
|
||||
routeOpts.schema = routeOpts.schema ?? {};
|
||||
routeOpts.schema.response = routeOpts.schema.response ?? {};
|
||||
let schema: TSchema = authSchema;
|
||||
if ('401' in (routeOpts.schema.response as { [k: string]: TSchema })) {
|
||||
const schema_orig = (routeOpts.schema.response as { [k: string]: TSchema })['401'];
|
||||
if (schema_orig[Typebox.Kind] === 'Union') {
|
||||
schema = Typebox.Union([...((schema_orig as Typebox.TUnion).anyOf), authSchema]);
|
||||
}
|
||||
catch {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(JSON.stringify(makeResponse('notLoggedIn', 'auth.invalid')));
|
||||
else if (schema_orig[Typebox.Kind] === 'Object') {
|
||||
schema = Typebox.Union([schema_orig, authSchema]);
|
||||
}
|
||||
};
|
||||
if (!routeOpts.preValidation) {
|
||||
routeOpts.preValidation = [f];
|
||||
}
|
||||
else if (Array.isArray(routeOpts.preValidation)) {
|
||||
routeOpts.preValidation.push(f);
|
||||
}
|
||||
else {
|
||||
routeOpts.preValidation = [routeOpts.preValidation, f];
|
||||
(routeOpts.schema.response as { [k: string]: TSchema })['401'] = schema;
|
||||
if (!bOnlySchema) {
|
||||
const f: preValidationAsyncHookHandler = async function(req, res) {
|
||||
try {
|
||||
if (isNullish(req.cookies.token)) {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.noCookie');
|
||||
}
|
||||
const tok = this.jwt.verify<JwtType>(req.cookies.token);
|
||||
if (tok.kind != 'auth') {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.invalidKind');
|
||||
}
|
||||
const user = this.db.getUser(tok.who);
|
||||
if (isNullish(user)) {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.noUser');
|
||||
}
|
||||
req.authUser = { id: user.id, name: tok.who };
|
||||
}
|
||||
catch {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.invalid');
|
||||
}
|
||||
};
|
||||
if (!routeOpts.preValidation) {
|
||||
routeOpts.preValidation = [f];
|
||||
}
|
||||
else if (Array.isArray(routeOpts.preValidation)) {
|
||||
routeOpts.preValidation.push(f);
|
||||
}
|
||||
else {
|
||||
routeOpts.preValidation = [routeOpts.preValidation, f];
|
||||
}
|
||||
}
|
||||
|
||||
routeOpts[kRouteAuthDone] = true;
|
||||
|
|
|
|||
26
src/@shared/src/swagger/index.ts
Normal file
26
src/@shared/src/swagger/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUi from '@fastify/swagger-ui';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
export const useSwagger = fp(async (fastify, opts: { service: string }) => {
|
||||
await fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
openapi: '3.1.0',
|
||||
servers: [
|
||||
{
|
||||
url: 'https://local.maix.me:8888',
|
||||
description: 'direct from docker',
|
||||
},
|
||||
{
|
||||
url: 'https://local.maix.me:8000',
|
||||
description: 'using fnginx',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await fastify.register(fastifySwaggerUi, {
|
||||
routePrefix: `/api/${opts.service}/documentation`,
|
||||
});
|
||||
});
|
||||
|
||||
export default useSwagger;
|
||||
|
|
@ -1,37 +1,60 @@
|
|||
import { TObject, TProperties, Type } from '@sinclair/typebox';
|
||||
import {
|
||||
Parameters,
|
||||
Static,
|
||||
TEnum,
|
||||
TSchema,
|
||||
Type,
|
||||
TProperties,
|
||||
TObject,
|
||||
} from 'typebox';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
/**
|
||||
* @description Represent a message key
|
||||
* Used for translation of text, taken from a prebuilt dictionary
|
||||
* Format: `category.sub.desc`
|
||||
*
|
||||
* @example `login.failure.invalid`
|
||||
* @example `login.failure.missingPassword`
|
||||
* @example `login.failure.missingUser`
|
||||
* @example `signin.success`
|
||||
* @example `pong.you.lost`
|
||||
*/
|
||||
export type MessageKey = string;
|
||||
export type ResponseBase<T = object> = {
|
||||
const kMakeResponseSym = Symbol('make-response-sym');
|
||||
declare module 'fastify' {
|
||||
export interface RouteOptions {
|
||||
[kMakeResponseSym]: boolean;
|
||||
}
|
||||
}
|
||||
export const useMakeResponse = fp(async (fastify, opts) => {
|
||||
void opts;
|
||||
|
||||
fastify.decorateReply('makeResponse', makeResponse);
|
||||
});
|
||||
|
||||
export type MakeStaticResponse<T extends { [k: string]: TSchema }> = {
|
||||
[k in keyof T]: Static<T[k]>;
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
/**
|
||||
* @description Builds a response from a `kind`, `key` and an arbitrary payload
|
||||
*
|
||||
* * USE THIS FUNCTION TO ALLOW GREPING :) *
|
||||
*
|
||||
* @example makeResponse("failure", "login.failure.invalid")
|
||||
* @example makeResponse("success", "login.success", { token: "supersecrettoken" })
|
||||
*/
|
||||
makeResponse<T extends object>(
|
||||
status: Parameters<FastifyReply['code']>[0],
|
||||
kind: string,
|
||||
key: string,
|
||||
payload?: T,
|
||||
): ReturnType<FastifyReply['send']>;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse<T extends object>(
|
||||
this: FastifyReply,
|
||||
status: Parameters<FastifyReply['code']>[0],
|
||||
kind: string,
|
||||
msg: MessageKey,
|
||||
key: string,
|
||||
payload?: T,
|
||||
): ReturnType<FastifyReply['send']> {
|
||||
return this.code(status).send({ kind, msg: key, payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds a response from a `kind`, `key` and an arbitrary payload
|
||||
*
|
||||
* * USE THIS FUNCTION TO ALLOW GREPING :) *
|
||||
*
|
||||
* @example makeResponse("failure", "login.failure.invalid")
|
||||
* @example makeResponse("success", "login.success", { token: "supersecrettoken" })
|
||||
*/
|
||||
export function makeResponse<T = object>(kind: string, key: MessageKey, payload?: T): ResponseBase<T> {
|
||||
console.log(`making response {kind: ${JSON.stringify(kind)}; key: ${JSON.stringify(key)}}`);
|
||||
return { kind, msg: key, payload };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @description Create a typebox Type for a response.
|
||||
*
|
||||
|
|
@ -39,20 +62,60 @@ export function makeResponse<T = object>(kind: string, key: MessageKey, payload?
|
|||
* @example typeResponse("otpRequired", "login.otpRequired", { token: Type.String() })
|
||||
* @example typeResponse("success", "login.success", { token: Type.String() })
|
||||
*/
|
||||
export function typeResponse(kind: string, key: MessageKey | MessageKey[], payload?: TProperties): TObject<TProperties> {
|
||||
let tKey;
|
||||
if (key instanceof Array) {
|
||||
tKey = Type.Union(key.map(l => Type.Const(l)));
|
||||
}
|
||||
else {
|
||||
tKey = Type.Const(key);
|
||||
}
|
||||
export function typeResponse<K extends string, M extends string>(
|
||||
kind: K,
|
||||
key: M,
|
||||
): TObject<{
|
||||
kind: TEnum<[K]>;
|
||||
msg: TEnum<M[]>;
|
||||
}>;
|
||||
export function typeResponse<K extends string, M extends string[]>(
|
||||
kind: K,
|
||||
key: [...M],
|
||||
): TObject<{
|
||||
kind: TEnum<K[]>;
|
||||
msg: TEnum<M>;
|
||||
}>;
|
||||
export function typeResponse<
|
||||
K extends string,
|
||||
M extends string,
|
||||
T extends TProperties,
|
||||
>(
|
||||
kind: K,
|
||||
key: M,
|
||||
payload: T,
|
||||
): TObject<{
|
||||
kind: TEnum<[K]>;
|
||||
msg: TEnum<M[]>;
|
||||
payload: TObject<T>;
|
||||
}>;
|
||||
export function typeResponse<
|
||||
K extends string,
|
||||
M extends string[],
|
||||
T extends TProperties,
|
||||
>(
|
||||
kind: K,
|
||||
key: [...M],
|
||||
payload: T,
|
||||
): TObject<{
|
||||
kind: TEnum<[K]>;
|
||||
msg: TEnum<M>;
|
||||
payload: TObject<T>;
|
||||
}>;
|
||||
export function typeResponse<K extends string, T extends TProperties>(
|
||||
kind: K,
|
||||
key: unknown,
|
||||
payload?: T,
|
||||
): unknown {
|
||||
const tKey = Type.Enum(Array.isArray(key) ? key : [key]);
|
||||
|
||||
const Ty = {
|
||||
kind: Type.Const(kind),
|
||||
kind: Type.Enum([kind]),
|
||||
msg: tKey,
|
||||
};
|
||||
if (payload !== undefined) {Object.assign(Ty, { payload: Type.Object(payload) });}
|
||||
if (payload !== undefined) {
|
||||
Object.assign(Ty, { payload: Type.Object(payload) });
|
||||
}
|
||||
|
||||
return Type.Object(Ty);
|
||||
}
|
||||
|
|
@ -68,6 +131,6 @@ export function typeResponse(kind: string, key: MessageKey | MessageKey[], paylo
|
|||
* @example assert_equal(isNullish({}), false);
|
||||
* @example assert_equal(isNullish(false), false);
|
||||
*/
|
||||
export function isNullish<T>(v: T | undefined | null): v is (null | undefined) {
|
||||
export function isNullish<T>(v: T | undefined | null): v is null | undefined {
|
||||
return v === null || v === undefined;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue