UNPKG

@backan/core

Version:

All in one web API builder. Create endpoints with type validations and OpenApi documentation, safely and quickly.

568 lines (567 loc) 15.2 kB
import { swaggerUI } from "@hono/swagger-ui"; import { cache } from "hono/cache"; import { cors } from "hono/cors"; import "hono/client"; import { trimTrailingSlash, appendTrailingSlash } from "hono/trailing-slash"; import "hono/adapter"; import "hono/secure-headers"; import { ipRestriction } from "hono/ip-restriction"; import "hono/compress"; import "hono/timeout"; import "hono/body-limit"; import { prettyJSON } from "hono/pretty-json"; import "hono/combine"; import "hono/context-storage"; import "hono/cookie"; import { z, OpenAPIHono } from "@hono/zod-openapi"; import "hono/factory"; import { poweredBy } from "hono/powered-by"; import "hono/accepts"; import "hono/proxy"; import "hono/jwt"; import "hono/jwk"; import "hono/csrf"; import "hono/etag"; import "hono/logger"; import "hono/language"; import "hono/method-override"; import "hono/http-exception"; const defaultHOpts = { error400: "Error in health check.", summary: "Check API health status", description: "Check if your API goes into trouble.", additionalResponseValues: {} }; const setHealthRoute = (app, path, opts = {}) => { const { error400, additionalResponseValues, summary, description } = { ...defaultHOpts, ...opts }; if (error400) app.RESPONSE_MESSAGES.ERROR_400 = error400; const getValidationSuccessParams = () => { const defaultValue = { ok: app.validation.literal(true) }; if (!additionalResponseValues) return defaultValue; const res = defaultValue; for (const key in additionalResponseValues) { if (Object.prototype.hasOwnProperty.call(additionalResponseValues, key)) { const value = additionalResponseValues[key]; res[key] = app.validation.literal(value); } } return res; }; app.add( { method: "get", path, summary, description, responses: { 200: app.response.responseJSONSuccess(app.validation.object(getValidationSuccessParams())), 400: app.response.responseJSONError400, 500: app.response.responseJSONError500 }, tags: ["health"] }, async (c) => { try { const response2 = { ok: true, ...additionalResponseValues }; return app.response.addSuccessResponse(c, response2); } catch (e) { return app.response.add500Error(c, e); } } ); return app; }; const RESPONSE_MESSAGES = { /** * Message for a 500 Internal Server Error. */ ERROR_500: "Internal Server error", /** * Message for a 404 Page Not Found error. */ ERROR_404: "Page not found", /** * Message for a 400 Bad Request error. */ ERROR_400: "Bad request", /** * Message indicating no data error. */ NO_DATA_ERROR: "No data error", /** * Help message for a 500 Internal Server Error. */ HELP_500: "Contact the developers to report the issue or wait until the issue is resolved", /** * Help message for a 400 Bad Request error. */ HELP_400: "Please read the documentation for a successful response", /** * Message indicating successful data fetch. */ SUCCESS_FETCH: "Successfully fetched data", /** * Message indicating a 404 Page Not Found error. */ ERROR_PAGE_NOT_FOUND: "Page not found", /** * Message indicating a validation error. */ ERROR_VALIDATION: "Error in validation request" }; const ERROR_ID = { /** * ID for a 404 Page Not Found error. */ PAGE_NOT_FOUND: "PAGE-NOT-FOUND", /** * ID for a 400 Bad Request error. */ BAD_REQUEST: "BAD-REQUEST", /** * ID for a 500 Internal Server Error during server fetch. */ SERVER_FETCH: "SERVER-FETCH", /** * ID for validation errors. */ VALIDATION: "VALIDATION", /** * ID indicating no documentation was provided. */ NO_DOCS_PROVIDED: "NO-DOCS-PROVIDED" }; const add500Error = (c, data) => { const res = add500ErrorObject(data); return c.json(res, res.status); }; const add500ErrorObject = (data) => { const status = 500; return { status, ...data, message: RESPONSE_MESSAGES.ERROR_500, error: getErrorResponse(data.error), help: RESPONSE_MESSAGES.HELP_500 }; }; const getErrorResponse = (e) => { if (!e) return RESPONSE_MESSAGES.NO_DATA_ERROR; if (e instanceof Error) return e.stack ? e.stack.split("\n").map((line) => line.trim()) : ""; else if (typeof e === "object" && Object.keys(e).length <= 0) return RESPONSE_MESSAGES.NO_DATA_ERROR; else return e; }; const add400Error = (c, data) => { const res = add400ErrorObject(data); return c.json(res, res.status); }; const add400ErrorObject = (data) => { const status = 400; return { status, ...data, error: getErrorResponse(data.error), help: data.help || RESPONSE_MESSAGES.HELP_400 }; }; const add404Error = (c, data) => { const status = 404; return c.json({ status, ...data }, status); }; const addSuccessResponse = (c, data) => { return c.json( data, 200 ); }; const addSuccess201Response = (c, data) => { return c.json( data, 201 ); }; const addSuccess202Response = (c, data) => { return c.json( data, 202 ); }; const error = (status) => { return z.object({ status: z.literal(status), id: z.string(), message: z.string(), error: z.object({}), help: z.string() }); }; const schemaError500 = error(500); const schemaError404 = error(404); const schemaError400 = error(400); const responseJSONSuccess = (schema, more) => ({ description: RESPONSE_MESSAGES.SUCCESS_FETCH, content: { "application/json": { schema } }, ...more ? more : {} }); const responseStreamSuccess = (schema, more) => ({ description: RESPONSE_MESSAGES.SUCCESS_FETCH, content: { "text/plain": { schema } }, ...more ? more : {} }); const responseJSONError500 = { description: RESPONSE_MESSAGES.ERROR_500, content: { "application/json": { schema: schemaError500 } } }; const responseJSONError404 = { description: RESPONSE_MESSAGES.ERROR_404, content: { "application/json": { schema: schemaError404 } } }; const responseJSONError400 = { description: RESPONSE_MESSAGES.ERROR_400, content: { "application/json": { schema: schemaError400 } } }; const response = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, add400Error, add400ErrorObject, add404Error, add500Error, add500ErrorObject, addSuccess201Response, addSuccess202Response, addSuccessResponse, responseJSONError400, responseJSONError404, responseJSONError500, responseJSONSuccess, responseStreamSuccess, schemaError400, schemaError404, schemaError500 }, Symbol.toStringTag, { value: "Module" })); class AppSuper { /** * Method to add route with OpenAPI configuration. */ add; constructor() { this.add = this.app.openapi; } /** * Validation option works with zod library. * @see https://zod.dev/ * @example const stringSchema = validation.string() */ validation = z; RESPONSE_MESSAGES = RESPONSE_MESSAGES; /** * Predefined error IDs used for consistent error identification in responses. * These are used throughout the application to ensure uniform error handling. * */ ERROR_ID = ERROR_ID; /** * Contains methods to generate and handle responses, including error handling. * * This object allows adding success responses, 500 errors, and 400 errors with predefined structures. */ response = { ...response, addSuccessResponse, add500ErrorObject, add500Error: (c, e) => { const data = { id: this.ERROR_ID.SERVER_FETCH, error: e }; this.logger(JSON.stringify(add500ErrorObject(data))); return add500Error(c, data); }, add400ErrorObject: (e) => { return add400ErrorObject({ id: e && typeof e == "object" && "message" in e ? e.message : this.ERROR_ID.BAD_REQUEST, message: this.RESPONSE_MESSAGES.ERROR_400, error: e }); }, add400Error: (c, e) => { const res = this.response.add400ErrorObject(e); this.logger(JSON.stringify(res)); return add400Error(c, res); } }; app = new OpenAPIHono({ defaultHook: (res, c) => { if (res.success) return res; return this.response.add400Error(c, { id: this.ERROR_ID.VALIDATION, message: this.RESPONSE_MESSAGES.ERROR_400, error: res.error.issues }); } }); /** * Hono app instace for add hono custom routes or middlewars or etc */ honoInstance = this.app; /** * Logs a string of data, determining if it's JSON and formatting accordingly. * This method can be overridden to customize how logging is handled within the application. * By default, it logs data to the console, parsing JSON strings if necessary. * @param {string} data - The string to log. If it's JSON, it will be parsed and logged as an object. * @example * * // Customizing logger * app.logger = (data: string) => { * // Custom logging logic, e.g., writing to a file * fs.appendFileSync('app.log', data + '\n'); * }; */ logger = (data) => { const isJsonString = (str) => { try { JSON.parse(str); return true; } catch (_e) { return false; } }; const isJSON = isJsonString(data); const timestamp = (/* @__PURE__ */ new Date()).toISOString(); console.log( isJSON ? { ...JSON.parse(data), time: timestamp } : data ); }; /** * Adds a route to the BACKAN application instance. * @param {Route} app - The route to add, containing the path and the associated app. * @param route * @deprecated */ addRoute(route) { this.app.route(route.path, route.app); } /** * Adds a route to the BACKAN application instance. * @param {AppSuper} app - The route to add, containing the path and the associated app. * @param path * @param route */ route(path, route) { this.app.route(path, route.app); } // eslint-disable-next-line jsdoc/require-param /** * Registers an OpenAPI component within the app's OpenAPI registry. * @returns {void} */ addComponent(type, name, component) { return this.app.openAPIRegistry.registerComponent(type, name, component); } /** * Retrieves a list of unique paths from the application's routes. * @returns {string[]} - An array of unique paths as strings. */ getPaths() { const uniquePaths = new Set( this.app.all().routes.filter((d) => d.path && !d.path.endsWith("*")).map((d) => d.path) ); return Array.from(uniquePaths); } } class App extends AppSuper { /** * The version of the application. */ version; /** * The title of the application. */ title; /** * A brief description of the application. */ description; /** * Contact information for the application. */ contact; #jsonPretty; #docs = { path: "/docs", active: true }; #health = { path: "/health", active: true, opts: void 0 }; #openApiConfig; #setNotFound() { this.app.notFound((c) => { const url = this.getDocUrl(c); return add404Error(c, { id: this.ERROR_ID.PAGE_NOT_FOUND, message: "Page not found.", help: url ? `Go to: ${url}` : this.ERROR_ID.NO_DOCS_PROVIDED }); }); } #setDocs() { if (!this.#docs.path || !this.#docs.active) return; const optsJson = `${this.#docs.path}.json`; this.app.doc(optsJson, this.#openApiConfig); this.app.get(this.#docs.path, swaggerUI({ url: optsJson })); } #setHealthPath() { if (!this.#health.path || !this.#health.active) return; setHealthRoute( this, this.#health.path, this.#health.opts ); } #setJSONPrettify() { if (typeof this.#jsonPretty === "string") this.app.use(prettyJSON({ space: 4, query: this.#jsonPretty })); else this.app.use(async (c, next) => { await next(); if (c.res.headers.get("Content-Type")?.startsWith("application/json")) { const obj = await c.res.json(); c.res = new Response(JSON.stringify(obj, null, 2), c.res); } }); } constructor(data) { const { jsonPretty = true, cors: cors$1, version, title, description, docs, health, contact, cache: cache$1, trailingSlash, hook, poweredBy: poweredBy$1 } = data || {}; const isRoute = version && title && description; super(); this.#jsonPretty = jsonPretty; this.version = version || ""; this.title = title || ""; this.description = description || ""; if (contact) this.contact = contact; if (hook) hook.beforeAll?.(this.app); if (poweredBy$1 !== false) this.app.use( "*", poweredBy({ serverName: poweredBy$1 || "backan" }) ); if (trailingSlash) this.app.use( "*", trailingSlash === "trim" ? trimTrailingSlash() : appendTrailingSlash() ); if (this.#jsonPretty) this.#setJSONPrettify(); if (cors$1) this.app.use("*", cors(cors$1)); if (docs?.active || docs?.active == false) this.#docs.active = docs.active; if (docs?.path) this.#docs.path = docs.path; if (health?.active || health?.active == false) this.#health.active = health.active; if (health?.path) this.#health.path = health.path; if (health?.opts) this.#health.opts = { ...this.#health.opts, ...health.opts }; this.#openApiConfig = { openapi: "3.0.0", info: { version: this.version, title: this.title, description: this.description, contact: this.contact } }; if (cache$1) this.app.use("*", cache(cache$1)); if (isRoute) { this.#setDocs(); this.#setNotFound(); } else { if (health?.active === true) this.#setHealthPath(); } this.fetch = this.app.fetch; } setIpRestriction({ pattern = "*", getIP, rules, onError }) { this.app.use( pattern, ipRestriction(getIP, rules || {}, onError) ); } /** * The fetch method for the application. * * Will be entry point of your application.. * @type {Function} */ fetch; /** * Generates the full URL for the OpenAPI documentation endpoint. * @param {Context} c - The Hono context object. * @returns {string | undefined} - The full URL for the OpenAPI documentation, or undefined if not configured. */ getDocUrl(c) { const url = new URL(c.req.url); const protocol = url.protocol ? `${url.protocol}//` : ""; const port = url.port ? `:${url.port}` : ""; return this.#docs.path && this.#docs.active ? `${protocol}${url.hostname}${port}${this.#docs.path}` : void 0; } /** * Retrieves the OpenAPI configuration object. * @returns {object} - The OpenAPI document object. */ getOpenApiObject() { return this.app.getOpenAPIDocument(this.#openApiConfig); } } class Route extends AppSuper { /** * The path of the route. */ path; /** * Method to add OpenAPI configuration to the route. */ add; constructor(params) { super(); this.path = params.path; this.add = this.app.openapi; } } export { App, Route };