@grace-js/grace
Version:
An opinionated API framework
335 lines • 12.5 kB
JavaScript
import { APIError } from "./errors/error.js";
import { convertStatusCode } from "./routes/response.js";
import { globSync } from "glob";
import { getPath, getQuery } from "./utils/url.js";
import { ZodError } from "zod";
import fastQueryString from "fast-querystring";
import { TrieRouter } from "./routers/trie.js";
import { NodeAdapter } from "./runtime/node/adapter.js";
import { graceToOpenAPISpec } from "./utils/openapi.js";
import * as fs from "node:fs";
export class Grace {
constructor(router, adapter, verbose = false) {
this.routes = [];
this.before = [];
this.after = [];
this.error = [];
this.router = router;
this.adapter = adapter;
this.verbose = verbose;
}
registerPlugin(plugin) {
plugin(this);
return this;
}
registerBefore(before) {
this.before.push(before);
return this;
}
registerAfter(after) {
this.after.push(after);
return this;
}
registerError(error) {
this.error.push(error);
return this;
}
registerRoute(route) {
this.router.addRoute(route);
this.routes.push(route);
return this;
}
registerRoutes(path) {
this.registerRoutesAsync(path); // workaround
return this;
}
exportOpenAPI(path) {
const openapi = graceToOpenAPISpec(this);
fs.writeFileSync(path, JSON.stringify(openapi, null, 2));
return this;
}
async fetch(request) {
const response = await this.handleInternally(request);
if (!response) {
throw new Error('No response was returned');
}
if (convertStatusCode(response.code) === 204) {
return new Response(undefined, {
status: convertStatusCode(response.code),
headers: response.headers
});
}
if (typeof response.body === 'object') {
if (response.body instanceof Blob) {
return new Response(response.body, {
status: convertStatusCode(response.code),
headers: response.headers
});
}
return new Response(JSON.stringify(response.body), {
status: convertStatusCode(response.code),
headers: {
'Content-Type': 'application/json',
...response.headers
}
});
}
return new Response(response.body, {
status: convertStatusCode(response.code),
headers: {
'Content-Type': 'text/plain',
...response.headers
}
});
}
listen(port) {
this.adapter.listen(this, port);
}
close() {
this.adapter.close();
}
async handleInternally(request) {
try {
let headers = {};
let beforeResponse = null;
for (const handler of this.before) {
const result = await handler(request);
if (result?.headers) {
headers = {
...headers,
...result.headers
};
}
if (typeof result === 'object' && 'code' in result) {
beforeResponse = result;
}
}
if (beforeResponse) {
beforeResponse.headers = {
...beforeResponse.headers,
...headers
};
for (const handler of this.after) {
await handler(request, beforeResponse);
}
return beforeResponse;
}
const pathname = getPath(request.url);
const matched = this.router.match(pathname, request.method);
if (!matched) {
throw new APIError(404, { message: 'Not Found' });
}
const route = matched.route;
const rawParameters = matched.params;
let parameters = rawParameters;
if (route.schema?.params) {
try {
parameters = await route.schema.params.parseAsync(rawParameters);
}
catch (e) {
if (e instanceof ZodError) {
throw new APIError(400, { message: 'Bad Request: ' + e.message }, e);
}
else {
throw new APIError(500, { message: 'Internal Server Error' }, e);
}
}
}
let body;
const hasBody = route.schema?.body != null;
if (request.method !== 'GET') {
if (request.headers.get('content-type')?.includes('multipart/form-data')) {
const formData = await request.clone().formData();
const rawBody = {};
formData.forEach((value, key) => {
rawBody[key] = value;
});
if (hasBody) {
try {
body = await route.schema.body.parseAsync(rawBody);
}
catch (e) {
if (e instanceof ZodError) {
throw new APIError(400, { message: 'Bad Request: ' + e.message }, e);
}
else {
throw new APIError(500, { message: 'Internal Server Error' }, e);
}
}
}
else {
body = rawBody;
}
}
else {
let rawBody = null;
try {
rawBody = await request.clone().json();
}
catch (e) {
if (request.headers.get('content-type')?.includes('application/json')) {
throw new APIError(400, { message: 'Bad Request: Request body is not JSON:\n' + await request.clone().text() }, e);
}
}
console.log('Raw Body', rawBody);
if (hasBody) {
try {
body = await route.schema.body.parseAsync(rawBody);
console.log('Body', body);
}
catch (e) {
if (e instanceof ZodError) {
throw new APIError(400, { message: 'Bad Request: ' + e.message }, e);
}
else {
throw new APIError(500, { message: 'Internal Server Error' }, e);
}
}
}
else {
body = rawBody;
}
}
}
const rawQuery = fastQueryString.parse(getQuery(request.url));
let query = rawQuery;
if (route.schema?.query) {
try {
query = await route.schema.query.parseAsync(rawQuery);
}
catch (e) {
if (e instanceof ZodError) {
throw new APIError(400, { message: 'Bad Request: ' + e.message }, e);
}
else {
throw new APIError(500, { message: 'Internal Server Error' }, e);
}
}
}
const rawHeaders = {};
request.headers.forEach((value, key) => {
rawHeaders[key] = value;
});
let ctxHeaders = rawHeaders;
if (route.schema?.headers) {
try {
ctxHeaders = await route.schema.headers.parseAsync(rawHeaders);
}
catch (e) {
if (e instanceof ZodError) {
throw new APIError(400, { message: 'Bad Request: ' + e.message }, e);
}
else {
throw new APIError(500, { message: 'Internal Server Error' }, e);
}
}
}
const context = {
request,
body,
query,
params: parameters,
headers: ctxHeaders,
extras: {},
app: this
};
try {
let response;
for (const before of route.before ?? []) {
if (!response) {
response = await before(context);
}
}
if (!response) {
response = await route.handler(context);
}
if (response.headers) {
headers = {
...headers,
...response.headers
};
}
for (const after of route.after ?? []) {
await after(context, response);
}
for (const handler of this.after) {
await handler(request, response);
}
response.headers = {
...response.headers,
...headers
};
return response;
}
catch (e) {
throw new APIError(500, { message: 'Internal Server Error' }, e);
}
}
catch (e) {
if (e instanceof APIError) {
return await this.handleError(request, e);
}
return await this.handleError(request, new APIError(500, { message: 'Internal Server Error' }, e));
}
}
async handleError(request, error) {
if (this.error.length < 1) {
console.error('There was an error while handling a request, but no error handlers were registered!');
console.error(error.error ?? error);
}
for (const handler of this.error) {
const response = await handler(request, error);
if (response) {
return response;
}
}
return {
code: (error.code ?? 500),
body: {
message: error.message ?? 'Internal Server Error'
}
};
}
async registerRoutesAsync(path) {
const pathWithoutGlob = path.replace(/\*\.?\w*\*?/g, '').replace('//', '/');
for (const pathname of globSync(path)) {
if (!pathname.endsWith('.js') && !pathname.endsWith('.ts')) {
continue;
}
const route = await import(pathname);
if (route.default && route.default.handler) {
if (!route.default.path || !route.default.method) {
const split = pathname.replace(pathWithoutGlob, '').split('.')[0].split('/');
const method = split.pop()?.toUpperCase();
const remaining = split.reverse();
let name = '/';
while (split.length > 0) {
const part = remaining.pop();
if (!part) {
continue;
}
if (name === '/') {
name += part;
continue;
}
name += '/' + part;
}
if (!method || !name) {
throw new Error('Invalid route path' + pathname.replace(pathWithoutGlob, ''));
}
route.default.path = name;
route.default.method = method;
}
this.registerRoute(route.default);
}
}
}
}
export function createGrace({ router = new TrieRouter(), adapter = new NodeAdapter(), verbose = false } = {
router: new TrieRouter(),
adapter: new NodeAdapter(),
verbose: false
}) {
return new Grace(router, adapter, verbose);
}
//# sourceMappingURL=grace.js.map