From 5000746b59ec10b0b5cd97cfb91fdb1e61fd2fcb Mon Sep 17 00:00:00 2001 From: Eser Ozvataf Date: Mon, 26 Sep 2022 03:42:34 +0300 Subject: [PATCH] added data and service components. --- README.md | 3 + src/data/deps.ts | 1 + src/data/mod.ts | 2 + src/data/mongo.ts | 50 ++++++++ src/data/repository.ts | 14 +++ src/mod.ts | 1 + src/options/options.ts | 59 ++++++---- src/service/deps.ts | 5 + src/service/http-types.ts | 51 +++++++++ src/service/middlewares/timer.ts | 15 +++ src/service/mod.ts | 3 + src/service/options.ts | 30 +++++ src/service/service.ts | 191 +++++++++++++++++++++++++++++++ src/web/deps.ts | 1 + src/web/url-resolver.ts | 6 +- 15 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 src/data/deps.ts create mode 100644 src/data/mod.ts create mode 100644 src/data/mongo.ts create mode 100644 src/data/repository.ts create mode 100644 src/service/deps.ts create mode 100644 src/service/http-types.ts create mode 100644 src/service/middlewares/timer.ts create mode 100644 src/service/mod.ts create mode 100644 src/service/options.ts create mode 100644 src/service/service.ts diff --git a/README.md b/README.md index 86b17acf..e20a54e3 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,14 @@ cloud-function runtimes and web apis. | [Directives](src/directives/) | Rules | | | [Standards](src/standards/) | Abstraction | | | [FP](src/fp/) | Functions Library | Tools for functional programming | +| [Data](src/data/) | Objects Library | Data Objects and Patterns | | [Environment](src/environment/) | Objects Library | Environment adapters | | [Formatters](src/formatters/) | Objects Library | Object serializers/deserializers | +| [Options](src/options/) | Manager | Configuration library | | [DI](src/di/) | Manager | Dependency injection library | | [I18N](src/i18n/) | Manager | Internationalization library | | [Functions](src/functions/) | Manager | Functions runtime | +| [Service](src/service/) | Framework | A micro http framework | | [Web](src/web/) | Framework | A web framework implementation | diff --git a/src/data/deps.ts b/src/data/deps.ts new file mode 100644 index 00000000..da286626 --- /dev/null +++ b/src/data/deps.ts @@ -0,0 +1 @@ +export * as mongo from "https://deno.land/x/mongo@v0.31.1/mod.ts"; diff --git a/src/data/mod.ts b/src/data/mod.ts new file mode 100644 index 00000000..f8215ecf --- /dev/null +++ b/src/data/mod.ts @@ -0,0 +1,2 @@ +export * from "./mongo.ts"; +export * from "./repository.ts"; diff --git a/src/data/mongo.ts b/src/data/mongo.ts new file mode 100644 index 00000000..1bd1c296 --- /dev/null +++ b/src/data/mongo.ts @@ -0,0 +1,50 @@ +import { mongo } from "./deps.ts"; +import { type Repository } from "./repository.ts"; + +class MongoRepository implements Repository { + collection: mongo.Collection; + + constructor(collection: mongo.Collection) { + this.collection = collection; + } + + get(id: string): Promise { + return this.collection.findOne({ + _id: new mongo.ObjectId(id), + }); + } + + getAll(): Promise { + return this.collection.find().toArray(); + } + + async add(data: Omit): Promise { + const id = await this.collection.insertOne(data); + + return String(id); + } + + async update(id: string, data: Partial): Promise { + await this.collection.updateOne( + { _id: new mongo.ObjectId(id) }, + // @ts-ignore a bug in type definition + { $set: data }, + ); + } + + async replace(id: string, data: Omit): Promise { + await this.collection.replaceOne( + { _id: new mongo.ObjectId(id) }, + // @ts-ignore a bug in type definition + data, + ); + } + + async remove(id: string): Promise { + await this.collection.deleteOne( + { _id: new mongo.ObjectId(id) }, + ); + } +} + +export { MongoRepository }; diff --git a/src/data/repository.ts b/src/data/repository.ts new file mode 100644 index 00000000..3b6446f4 --- /dev/null +++ b/src/data/repository.ts @@ -0,0 +1,14 @@ +// deno-lint-ignore no-explicit-any +interface Repository { + get(id: string): Promise; + getAll(): Promise; + + add(data: Omit): Promise; + update(id: string, data: Partial): Promise; + replace(id: string, data: Omit): Promise; + remove(id: string): Promise; + + // TODO getCursor, bulkInsert, upsert, count, aggregate, etc. +} + +export { type Repository }; diff --git a/src/mod.ts b/src/mod.ts index f0e7a6f7..5751c1a7 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,3 +1,4 @@ +export * as data from "./data/mod.ts"; export * as di from "./di/mod.ts"; export * as environment from "./environment/mod.ts"; export * as formatters from "./formatters/mod.ts"; diff --git a/src/options/options.ts b/src/options/options.ts index 5ced7bd4..d6de96ad 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -8,60 +8,75 @@ interface BaseOptions { type Options = BaseOptions & Partial; interface WrappedEnv { - readString: (key: string, defaultValue?: string) => string | undefined; - readEnum: ( + readString: ( key: string, - values: string[], - defaultValue?: string, - ) => string | undefined; - readInt: (key: string, defaultValue?: number) => number | undefined; - readBool: (key: string, defaultValue?: boolean) => boolean | undefined; + defaultValue?: T, + ) => T | undefined; + readEnum: ( + key: string, + values: T[], + defaultValue?: T, + ) => T | undefined; + readInt: (key: string, defaultValue?: T) => T | undefined; + readBool: (key: string, defaultValue?: T) => T | undefined; } // public functions const wrapEnv = (env: LoadEnvResult): WrappedEnv => { return { - readString: (key: string, defaultValue?: string): string | undefined => { - return env[key] ?? defaultValue; + readString: ( + key: string, + defaultValue?: T, + ): T | undefined => { + return env[key] as T ?? defaultValue; }, - readEnum: ( + readEnum: ( key: string, - values: string[], - defaultValue?: string, - ): string | undefined => { + values: T[], + defaultValue?: T, + ): T | undefined => { if (env[key] === undefined) { return defaultValue; } - if (values.includes(env[key])) { - return env[key]; + if (values.includes(env[key] as T)) { + return env[key] as T; } return defaultValue; }, - readInt: (key: string, defaultValue?: number): number | undefined => { + readInt: ( + key: string, + defaultValue?: T, + ): T | undefined => { if (env[key] === undefined) { return defaultValue; } - return parseInt(env[key], 10); + return parseInt(env[key], 10) as T; }, - readBool: (key: string, defaultValue?: boolean): boolean | undefined => { + readBool: ( + key: string, + defaultValue?: T, + ): T | undefined => { if (env[key] === undefined) { return defaultValue; } if (["1", "true", true].includes(env[key].trim().toLowerCase())) { - return true; + return true as T; } - return false; + return false as T; }, }; }; const loadOptions = async ( - loader: (wrappedEnv: WrappedEnv, options: Options) => Options, + loader: ( + wrappedEnv: WrappedEnv, + options: Options, + ) => Promise | void> | Options | void, options?: LoadEnvOptions, ): Promise> => { const env = await loadEnv(options); @@ -72,7 +87,7 @@ const loadOptions = async ( envName: env.name, }; - const result = loader(wrappedEnv, newOptions); + const result = await loader(wrappedEnv, newOptions); return result ?? newOptions; }; diff --git a/src/service/deps.ts b/src/service/deps.ts new file mode 100644 index 00000000..9f3f7d2d --- /dev/null +++ b/src/service/deps.ts @@ -0,0 +1,5 @@ +export * as asserts from "https://deno.land/std@0.157.0/testing/asserts.ts"; +export * as djwt from "https://deno.land/x/djwt@v2.7/mod.ts"; +export * as log from "https://deno.land/std@0.157.0/log/mod.ts"; +export * as logLevels from "https://deno.land/std@0.157.0/log/levels.ts"; +export * as oak from "https://deno.land/x/oak@v11.1.0/mod.ts"; diff --git a/src/service/http-types.ts b/src/service/http-types.ts new file mode 100644 index 00000000..df492a53 --- /dev/null +++ b/src/service/http-types.ts @@ -0,0 +1,51 @@ +import { oak } from "./deps.ts"; + +type Application = oak.Application; +type Middleware = oak.Middleware; +type Router = oak.Router; +type State = oak.State; +type Context = oak.Context & { + params: Record; +}; +type RouteParams = oak.RouteParams; +type Route< + R extends string, + P extends RouteParams = RouteParams, + // deno-lint-ignore no-explicit-any + S extends State = Record, +> = oak.Route; +type RouterMiddleware< + R extends string, + P extends RouteParams = RouteParams, + // deno-lint-ignore no-explicit-any + S extends State = Record, +> = oak.RouterMiddleware; +type RouterContext< + R extends string, + P extends RouteParams = RouteParams, + // deno-lint-ignore no-explicit-any + S extends State = Record, +> = oak.RouterContext; + +type HttpMethods = + | "all" + | "get" + | "post" + | "patch" + | "put" + | "delete" + | "head" + | "options"; + +export { + type Application, + type Context, + type HttpMethods, + type Middleware, + type Route, + type RouteParams, + type Router, + type RouterContext, + type RouterMiddleware, + type State, +}; diff --git a/src/service/middlewares/timer.ts b/src/service/middlewares/timer.ts new file mode 100644 index 00000000..04ca2ea3 --- /dev/null +++ b/src/service/middlewares/timer.ts @@ -0,0 +1,15 @@ +import { log } from "../deps.ts"; + +// deno-lint-ignore no-explicit-any +const timerMiddleware = async (ctx: any, next: any) => { + const start = Date.now(); + + await next(); + + const ms = Date.now() - start; + + // ctx.response.headers.set("X-Response-Time", `${ms}ms`); + log.info(`${ctx.request.method} ${ctx.request.url} - ${ms}ms`); +}; + +export { timerMiddleware, timerMiddleware as default }; diff --git a/src/service/mod.ts b/src/service/mod.ts new file mode 100644 index 00000000..3ce54cb1 --- /dev/null +++ b/src/service/mod.ts @@ -0,0 +1,3 @@ +export * from "./http-types.ts"; +export * from "./options.ts"; +export * from "./service.ts"; diff --git a/src/service/options.ts b/src/service/options.ts new file mode 100644 index 00000000..ef6819e9 --- /dev/null +++ b/src/service/options.ts @@ -0,0 +1,30 @@ +import { logLevels } from "./deps.ts"; +import * as options from "../options/mod.ts"; + +// interface definitions +interface ServiceOptionsValues { + port: number; + logs: logLevels.LevelName; +} + +type ServiceOptions = options.Options; + +// public functions +const loadServiceOptions = async (): Promise => { + const serviceOptions = await options.loadOptions( + (env, opts) => { + opts.port = env.readInt("PORT", 3000); + opts.logs = env.readEnum("LOGS", [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL", + ], "INFO"); + }, + ); + + return serviceOptions; +}; + +export { loadServiceOptions, type ServiceOptions }; diff --git a/src/service/service.ts b/src/service/service.ts new file mode 100644 index 00000000..02d93ec7 --- /dev/null +++ b/src/service/service.ts @@ -0,0 +1,191 @@ +import { log, oak } from "./deps.ts"; +import { + type Application, + type Context, + type HttpMethods, + type Middleware, + type RouteParams, + type Router, + type RouterContext, + type RouterMiddleware, + type State, +} from "./http-types.ts"; +import { loadServiceOptions, ServiceOptions } from "./options.ts"; + +interface Service { + internalApp: Application; + router: Router; + options: ServiceOptions; + + addMiddleware: (middleware: Middleware) => void; + addHealthCheck: (path: string) => void; + addRoute: < + R extends string, + P extends RouteParams = RouteParams, + // deno-lint-ignore no-explicit-any + S extends State = Record, + >( + method: HttpMethods, + path: R, + ...middlewares: [ + ...RouterMiddleware[], + (ctx: RouterContext | Context) => unknown, + ] | [ + // FIXME sorry, it's mandatory hack for typescript + (ctx: Context) => unknown, + ] + ) => void; + + start: () => Promise; +} + +// public functions +const start = async (service: Service): Promise => { + // boot application server + await service.internalApp.listen({ port: service.options.port }); +}; + +const init = async (customOptions?: ServiceOptions): Promise => { + // determine options + const options_ = customOptions ?? await loadServiceOptions(); + + // initialize oak application + const app = new oak.Application(); + + app.addEventListener( + "listen", + (e) => { + const protocol = (e.secure) ? "https://" : "http://"; + const hostname = (e.hostname === "0.0.0.0") ? "localhost" : e.hostname; + const uri = `${protocol}${hostname}:${e.port}/`; + + log.info(`Application is starting on ${uri}`); + log.debug(JSON.stringify(options_, null, 2)); + }, + ); + + // define routes + const router = new oak.Router(); + + // init logger + await log.setup({ + handlers: { + console: new log.handlers.ConsoleHandler(options_.logs ?? "INFO"), + }, + loggers: { + default: { + level: "DEBUG", + handlers: ["console"], + }, + }, + }); + + // construct service object + const serviceObject: Service = { + internalApp: app, + router: router, + options: options_, + + addMiddleware: (middleware: Middleware): void => { + app.use(middleware); + }, + + addHealthCheck: (path: string): void => { + router.get(path, (ctx) => { + ctx.response.body = ""; + }); + }, + + addRoute: < + R extends string, + P extends RouteParams = RouteParams, + // deno-lint-ignore no-explicit-any + S extends State = Record, + >( + method: HttpMethods, + path: R, + ...middlewares: [ + ...RouterMiddleware[], + (ctx: RouterContext | Context) => unknown, + ] | [ + // FIXME sorry, it's mandatory hack for typescript + (ctx: Context) => unknown, + ] + ): void => { + const fn = middlewares.slice(-1)[0] as + | ((ctx: RouterContext) => unknown) + | undefined; + + const middlewares_ = middlewares.slice(0, -1) as RouterMiddleware< + R, + P, + S + >[]; + middlewares_.push((ctx: RouterContext) => { + const result = fn?.(ctx); + + if (result === undefined || result === null) { + ctx.response.body = ""; + } else { + ctx.response.body = JSON.stringify(result); + } + }); + + // @ts-ignore you know nothing typescript + router[method](path, ...middlewares_); + }, + + start: () => start(serviceObject), + }; + + return serviceObject; +}; + +const fixErrorObjectResult = (err: Error) => { + const serialized = JSON.stringify(err, Object.getOwnPropertyNames(err)); + + return JSON.parse(serialized); +}; + +const run = async (initializer: (s: Service) => void | Promise) => { + try { + const service = await init(); + + // deno-lint-ignore no-explicit-any + service.internalApp.use(async (ctx: any, next: any) => { + try { + await next(); + } catch (err) { + log.error(err); + + if (oak.isHttpError(err)) { + ctx.response.status = err.status; + } else { + ctx.response.status = 500; + } + + if (service.options.envName === "production") { + ctx.response.body = { error: err.message }; + } else { + ctx.response.body = { + error: err.message, + details: fixErrorObjectResult(err), + }; + } + ctx.response.type = "json"; + } + }); + + await initializer(service); + + // insert these as last 2 middlewares + service.internalApp.use(service.router.routes()); + service.internalApp.use(service.router.allowedMethods()); + + await service.start(); + } catch (error) { + log.error(error); + } +}; + +export { run, run as default }; diff --git a/src/web/deps.ts b/src/web/deps.ts index ff1f7e7d..149ce300 100644 --- a/src/web/deps.ts +++ b/src/web/deps.ts @@ -1,2 +1,3 @@ +export * as asserts from "https://deno.land/std@0.157.0/_util/assert.ts"; export * as fsWalk from "https://deno.land/std@0.157.0/fs/walk.ts"; export * as pathPosix from "https://deno.land/std@0.157.0/path/posix.ts"; diff --git a/src/web/url-resolver.ts b/src/web/url-resolver.ts index aa3d9167..a43069f3 100644 --- a/src/web/url-resolver.ts +++ b/src/web/url-resolver.ts @@ -1,4 +1,4 @@ -import { assert } from "https://deno.land/std@0.157.0/_util/assert.ts"; +import { asserts } from "./deps.ts"; import { type Config } from "./config.ts"; const applyPattern = function applyPattern( @@ -55,7 +55,7 @@ const routesToRegExp = function routesToRegExp(route: string) { const routeParts = route.match(splitterRegExp); - assert(routeParts !== null, `unable to parse route: ${route}`); + asserts.assert(routeParts !== null, `unable to parse route: ${route}`); const splittedRegExp = routeParts.map( (routePart) => { @@ -115,7 +115,7 @@ const urlResolver = function urlResolver( ) { const urlStructure = config.urls?.structure!; - assert( + asserts.assert( urlStructure.startsWith("/"), `config.urls.structure value must start with /. example: /[lang]/[...path]