diff --git a/package.json b/package.json index a3ce9b2..7c8f48c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,9 @@ "vitest": "^0.9.4" }, "dependencies": { - "fastify": "^3.27.0" + "@theoryofnekomata/uuid-buffer": "^0.1.0", + "fastify": "^3.27.0", + "fastify-plugin": "^3.0.1" }, "scripts": { "prepublishOnly": "pridepack clean && pridepack build", diff --git a/src/modules/git/Git.controller.ts b/src/modules/git/Git.controller.ts new file mode 100644 index 0000000..7d77b78 --- /dev/null +++ b/src/modules/git/Git.controller.ts @@ -0,0 +1,34 @@ +import {GitService, GitServiceImpl} from './Git.service'; +import {RouteHandlerMethod} from 'fastify'; +import {constants} from 'http2'; +import * as codeCore from '@modal/code-core'; +import {HttpResponse} from '../../packages/fastify-send-data'; +import Controller from '../../utils/types'; +import {Uuid} from '@theoryofnekomata/uuid-buffer'; + +export interface GitController extends Controller< + 'createRepo' + | 'deleteRepo' +> {} + +class RepoCreatedResponse extends HttpResponse(constants.HTTP_STATUS_CREATED, 'Repo Created') {} +class RepoDeletedResponse extends HttpResponse(constants.HTTP_STATUS_NO_CONTENT, 'Repo Deleted') {} + +export class GitControllerImpl implements GitController { + private readonly gitService: GitService; + + constructor() { + this.gitService = new GitServiceImpl() + } + + readonly createRepo: RouteHandlerMethod = async (request, reply) => { + const repo = await this.gitService.createRepo(request.body as codeCore.git.CreateRepoData, request.user); + reply.sendData(new RepoCreatedResponse(repo)); + } + + readonly deleteRepo: RouteHandlerMethod = async (request, reply) => { + const params = request.params as { repoId: string } + await this.gitService.deleteRepo(Uuid.from(params.repoId), request.user) + reply.sendData(new RepoDeletedResponse()); + } +} diff --git a/src/modules/git/Git.service.ts b/src/modules/git/Git.service.ts new file mode 100644 index 0000000..0cbceb3 --- /dev/null +++ b/src/modules/git/Git.service.ts @@ -0,0 +1,28 @@ +import * as codeCore from '@modal/code-core'; + +export interface GitService { + createRepo(options: codeCore.git.CreateRepoData, user?: codeCore.common.User): Promise; + deleteRepo(repoId: codeCore.git.Repo['id'], user?: codeCore.common.User): Promise +} + +export class GitServiceImpl implements GitService { + private readonly coreGitService: codeCore.git.GitService; + + constructor() { + this.coreGitService = new codeCore.git.GitServiceImpl(); + } + + async createRepo(options: codeCore.git.CreateRepoData, user?: codeCore.common.User): Promise { + if (user) { + return this.coreGitService.create(options, user); + } + throw new Error('Unauthorized'); + } + + async deleteRepo(repoId: codeCore.git.Repo['id'], user?: codeCore.common.User): Promise { + if (user) { + await this.coreGitService.delete(repoId, user); + } + throw new Error('Unauthorized'); + } +} diff --git a/src/modules/git/index.ts b/src/modules/git/index.ts new file mode 100644 index 0000000..f18cf7d --- /dev/null +++ b/src/modules/git/index.ts @@ -0,0 +1,3 @@ +export * from './Git.service' +export * from './Git.controller' +export * from './models' diff --git a/src/modules/git/models.ts b/src/modules/git/models.ts new file mode 100644 index 0000000..f16b5a1 --- /dev/null +++ b/src/modules/git/models.ts @@ -0,0 +1,3 @@ +import * as codeCore from '@modal/code-core' + +export type CreateRepoOptions = codeCore.git.CreateOptions diff --git a/src/packages/fastify-compliant-http-errors/index.ts b/src/packages/fastify-compliant-http-errors/index.ts new file mode 100644 index 0000000..1a69a91 --- /dev/null +++ b/src/packages/fastify-compliant-http-errors/index.ts @@ -0,0 +1,68 @@ +import { FastifyReply, FastifyRequest } from 'fastify' + +type AppErrorOptions = { + cause?: Error, +} + +export class AppError extends Error { + public readonly cause?: Error + + constructor(message?: string, options?: AppErrorOptions) { + super(message) + if (typeof options === 'object') { + this.cause = options.cause + } + } +} + +const httpErrorClassFactory = (statusCode: number, statusMessage?: string) => class HttpError extends AppError { + readonly statusCode = statusCode + readonly statusMessage = statusMessage +} + +interface CustomFastifyError extends Error { + statusMessage?: string; + validation: { + keyword: string, + instancePath: string, + params: Record, + message: string, + }[]; + statusCode?: number; + errors: Record[]; + detail?: string; +} + +export const fastifyErrorHandler = (error: CustomFastifyError, _request: FastifyRequest, reply: FastifyReply) => { + reply.headers({ + 'Content-Type': 'application/problem+json', + }) + + if (error.validation) { + reply.status(400) + reply.raw.statusMessage = 'Invalid Request' + reply.send({ + title: 'There are errors in the request body.', + 'invalid-params': error.validation.map(v => ({ + name: ( + v.keyword === 'required' + ? `${v.instancePath}/${v.params.missingProperty}` + : v.instancePath + ).replaceAll('/', '.'), + reason: v.message, + })), + }) + return + } + + if (Number.isNaN(reply.raw.statusCode)) { + reply.status(error?.statusCode ?? 500) + } + reply.raw.statusMessage = error.statusMessage ?? 'Unknown Error' + reply.send({ + title: reply.raw.statusMessage, + detail: error.message, + }) +} + +export { httpErrorClassFactory as HttpError } diff --git a/src/packages/fastify-integration-tests/index.ts b/src/packages/fastify-integration-tests/index.ts new file mode 100644 index 0000000..978b312 --- /dev/null +++ b/src/packages/fastify-integration-tests/index.ts @@ -0,0 +1,23 @@ +import { FastifyInstance, InjectOptions, LightMyRequestResponse } from 'fastify' + +export type MockResponse = LightMyRequestResponse + +export type MockResponseThunk = () => Promise + +export type MockClient = (opts: InjectOptions) => Promise + +export const makeJsonRequestClient = (server: FastifyInstance): MockClient => (opts) => { + const inject = { + ...opts, + headers: { + ...(opts.headers ?? {}), + 'accept': 'applicaton/json', + } as Record, + } + if (opts.payload) { + inject.headers['content-type'] = 'application/json' + inject.payload = JSON.stringify(opts.payload) + } + + return server.inject(inject) +} diff --git a/src/packages/fastify-send-data/index.ts b/src/packages/fastify-send-data/index.ts new file mode 100644 index 0000000..43428e7 --- /dev/null +++ b/src/packages/fastify-send-data/index.ts @@ -0,0 +1,53 @@ +import { FastifyInstance, FastifyReply } from 'fastify' +import fp from 'fastify-plugin' +import { constants } from 'http2' + +export interface ResponseDataInterface { + statusCode: number + statusMessage?: string + body?: T extends undefined ? undefined : { data: T } +} + +const fastifySendData = async (app: FastifyInstance) => { + const sendDataKey = 'sendData' as const + app.decorateReply(sendDataKey, function reply(this: FastifyReply, data: T) { + this.status(data.statusCode) + if (data.statusMessage) { + this.raw.statusMessage = data.statusMessage + } + if (data.statusCode !== constants.HTTP_STATUS_NO_CONTENT) { + this.send(data.body) + return + } + this.removeHeader('content-type') + this.removeHeader('content-length') + this.send() + }) +} + +type SendData = ResponseDataInterface> = (data: T) => void + +declare module 'fastify' { + interface FastifyReply { + sendData: SendData + } +} + +export const HttpResponse = ( + statusCode = constants.HTTP_STATUS_OK, + statusMessage?: string, +) => class HttpResponseData { + readonly statusCode = statusCode + readonly statusMessage = statusMessage + readonly body?: { data: T } + + constructor(data?: T) { + if (data && statusCode !== constants.HTTP_STATUS_NO_CONTENT) { + this.body = { + data, + } + } + } +} + +export default fp(fastifySendData) diff --git a/src/packages/fastify-service-session/index.ts b/src/packages/fastify-service-session/index.ts new file mode 100644 index 0000000..5b45448 --- /dev/null +++ b/src/packages/fastify-service-session/index.ts @@ -0,0 +1,26 @@ +import fp from 'fastify-plugin' +import { FastifyInstance, FastifyRequest } from 'fastify' + +export interface FastifySessionOpts { + sessionRequestKey?: string, + extractSessionId: (request: RequestType) => SessionId | null | undefined, + isSessionValid: (sessionId: SessionId) => Promise, + getSession: (sessionId: SessionId) => Promise +} + +const fastifySession = async (app: FastifyInstance, opts: FastifySessionOpts) => { + const { sessionRequestKey = 'session' } = opts + app.decorateRequest(sessionRequestKey, null) + app.addHook('onRequest', async (request: FastifyRequest) => { + const sessionId = opts.extractSessionId(request) + if (typeof sessionId === 'string') { + const isSessionValid = await opts.isSessionValid(sessionId) + if (isSessionValid) { + const mutableRequest = (request as unknown) as Record + mutableRequest[sessionRequestKey] = await opts.getSession(sessionId) + } + } + }) +} + +export default fp(fastifySession) diff --git a/src/routes.ts b/src/routes.ts index 384d587..66fe13b 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,7 +1,21 @@ import { FastifyInstance } from 'fastify'; +import * as git from './modules/git' -export const addRoutes = (SERVER: FastifyInstance) => { - SERVER.get('/', async (_, reply) => { +export const addRoutes = (server: FastifyInstance) => { + server.get('/', async (_, reply) => { reply.send({hello: 'world'}) }); + + const gitController = new git.GitControllerImpl() + server.route({ + method: 'POST', + url: '/repos', + handler: gitController.createRepo, + }) + + server.route({ + method: 'DELETE', + url: '/repos/:id', + handler: gitController.deleteRepo, + }) } diff --git a/src/server.ts b/src/server.ts index 07304dd..aca8ffb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,20 @@ -import fastify from 'fastify'; +import fastify, {FastifyServerOptions} from 'fastify'; +import * as codeCore from '@modal/code-core'; +import {fastifyErrorHandler} from './packages/fastify-compliant-http-errors'; +import fastifySendData from './packages/fastify-send-data'; -export const createServer = () => { - const server = fastify({ - logger: true, - }) +declare module 'fastify' { + interface FastifyRequest { + user?: codeCore.common.User, + } +} + +export const createServer = (opts?: FastifyServerOptions) => { + const server = fastify(opts) + + server.setErrorHandler(fastifyErrorHandler) + + server.register(fastifySendData) return server; } diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..3542d68 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,7 @@ +import { RouteHandlerMethod } from 'fastify' + +type Controller = { + [key in T]: RouteHandlerMethod; +}; + +export default Controller diff --git a/yarn.lock b/yarn.lock index 67d334c..5a28a7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -359,6 +359,14 @@ resolved "https://registry.yarnpkg.com/@ovyerus/licenses/-/licenses-6.4.4.tgz#596e3ace46ab7c70bcf0e2b17f259796a4bedf9f" integrity sha512-IHjc31WXciQT3hfvdY+M59jBkQp70Fpr04tNDVO5rez2PNv4u8tE6w//CkU+GeBoO9k2ahneSqzjzvlgjyjkGw== +"@theoryofnekomata/uuid-buffer@^0.1.0": + version "0.1.0" + resolved "https://js.pack.modal.sh/@theoryofnekomata%2fuuid-buffer/-/uuid-buffer-0.1.0.tgz#0917314e8230ce1a2047172b3277512bc0fd73a3" + integrity sha512-DUKQE2UmS9vq+5kNp1f50U+XLdmgTEKWxRgeCgasXCipL7JNVQoYqwwcuDlCb+yNdqQ2/fNbAEWHKs3kRfa6+w== + dependencies: + "@types/uuid" "^8.3.4" + uuid "^8.3.2" + "@types/chai-subset@^1.3.3": version "1.3.3" resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94" @@ -386,6 +394,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.35.tgz#635b7586086d51fb40de0a2ec9d1014a5283ba4a" integrity sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg== +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@typescript-eslint/eslint-plugin@^5.9.0": version "5.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.25.0.tgz#e8ce050990e4d36cc200f2de71ca0d3eb5e77a31" @@ -1531,6 +1544,11 @@ fast-safe-stringify@^2.0.8: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== +fastify-plugin@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-3.0.1.tgz#79e84c29f401020f38b524f59f2402103fd21ed2" + integrity sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA== + fastify@^3.27.0: version "3.29.0" resolved "https://registry.yarnpkg.com/fastify/-/fastify-3.29.0.tgz#b840107f4fd40cc999b886548bfcda8062e38168" @@ -3058,6 +3076,11 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"