@tatara/http 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/index.d.mts +171 -0
- package/dist/index.mjs +270 -0
- package/package.json +49 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammed Bageri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tatara/http
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of [tatara](https://github.com/mohammed-bageri/tatara-cli).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Hook, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import { CoreLogger } from "@tatara/core";
|
|
3
|
+
import { Context, MiddlewareHandler } from "hono";
|
|
4
|
+
import { ContentfulStatusCode } from "hono/utils/http-status";
|
|
5
|
+
//#region src/errors.d.ts
|
|
6
|
+
export declare class AppError extends Error {
|
|
7
|
+
readonly status: ContentfulStatusCode;
|
|
8
|
+
readonly code: string;
|
|
9
|
+
readonly details?: unknown;
|
|
10
|
+
/** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
|
|
11
|
+
readonly translatable: boolean;
|
|
12
|
+
constructor(status: ContentfulStatusCode, code: string, message: string, details?: unknown, translatable?: boolean);
|
|
13
|
+
}
|
|
14
|
+
export declare class ValidationError extends AppError {
|
|
15
|
+
constructor(message?: string, details?: unknown);
|
|
16
|
+
}
|
|
17
|
+
export declare class BusinessRuleError extends AppError {
|
|
18
|
+
constructor(code: string, message: string, details?: unknown);
|
|
19
|
+
}
|
|
20
|
+
export declare class UnauthorizedError extends AppError {
|
|
21
|
+
constructor(message?: string);
|
|
22
|
+
}
|
|
23
|
+
export declare class ForbiddenError extends AppError {
|
|
24
|
+
constructor(message?: string);
|
|
25
|
+
}
|
|
26
|
+
export declare class NotFoundError extends AppError {
|
|
27
|
+
constructor(resource?: string);
|
|
28
|
+
}
|
|
29
|
+
export declare class ConflictError extends AppError {
|
|
30
|
+
constructor(message?: string, details?: unknown);
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/app.d.ts
|
|
34
|
+
/** Variables every tatara app has; apps extend them with their own (principal, language, …). */
|
|
35
|
+
interface HttpEnv {
|
|
36
|
+
Variables: {
|
|
37
|
+
requestId: string;
|
|
38
|
+
logger: CoreLogger;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
type Defaults<E extends HttpEnv> = Partial<Omit<E["Variables"], "requestId" | "logger">>;
|
|
42
|
+
/** Sets requestId, a request-scoped logger and default variables; logs one line per request. */
|
|
43
|
+
export declare function requestContext<E extends HttpEnv>(logger: CoreLogger, defaults?: Defaults<E>): MiddlewareHandler<E, string, {}, Response>;
|
|
44
|
+
interface CreateAppOptions<E extends HttpEnv> {
|
|
45
|
+
logger: CoreLogger;
|
|
46
|
+
cors: {
|
|
47
|
+
origins: string[];
|
|
48
|
+
exposeHeaders?: string[];
|
|
49
|
+
allowHeaders?: string[];
|
|
50
|
+
};
|
|
51
|
+
bodyLimitBytes: number;
|
|
52
|
+
/** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
|
|
53
|
+
defaults?: Defaults<E>;
|
|
54
|
+
}
|
|
55
|
+
/** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
|
|
56
|
+
export declare function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E>;
|
|
57
|
+
interface RouteDefinition<A extends string = string, E extends HttpEnv = HttpEnv> {
|
|
58
|
+
/** which HTTP app serves these routes */
|
|
59
|
+
app: A;
|
|
60
|
+
/** mounted under the prefix + basePath, e.g. "/notes" */
|
|
61
|
+
basePath: string;
|
|
62
|
+
router: OpenAPIHono<E>;
|
|
63
|
+
}
|
|
64
|
+
/** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
|
|
65
|
+
export declare function defineRoutes<D extends RouteDefinition<string, any>>(definition: D): D;
|
|
66
|
+
/** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
|
|
67
|
+
export declare function createRouter<E extends HttpEnv>(): OpenAPIHono<E>;
|
|
68
|
+
/** Mounts the discovered routes that belong to `app` under `prefix`. */
|
|
69
|
+
export declare function mountRoutes<E extends HttpEnv>(app: OpenAPIHono<E>, routes: readonly RouteDefinition<string, any>[], options: {
|
|
70
|
+
app: string;
|
|
71
|
+
prefix: string;
|
|
72
|
+
}): void;
|
|
73
|
+
type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
|
|
74
|
+
/** `/openapi.json` (built per request so `extend` can add to it) and the Scalar UI at `/docs`. */
|
|
75
|
+
export declare function serveOpenApi<E extends HttpEnv>(app: OpenAPIHono<E>, options: {
|
|
76
|
+
title: string;
|
|
77
|
+
version?: string;
|
|
78
|
+
extend?: (document: OpenApiDocument) => void | Promise<void>;
|
|
79
|
+
}): void;
|
|
80
|
+
/** The error envelope for every error, plus a 404 for unknown routes. */
|
|
81
|
+
export declare function handleErrors<E extends HttpEnv>(app: OpenAPIHono<E>, options?: {
|
|
82
|
+
message?: (err: AppError, c: Context<E>) => string;
|
|
83
|
+
}): void;
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/response.d.ts
|
|
86
|
+
export declare const errorSchema: z.ZodObject<{
|
|
87
|
+
success: z.ZodLiteral<false>;
|
|
88
|
+
error: z.ZodString;
|
|
89
|
+
code: z.ZodString;
|
|
90
|
+
details: z.ZodOptional<z.ZodUnknown>;
|
|
91
|
+
}, z.core.$strip>;
|
|
92
|
+
export declare const paginationMetaSchema: z.ZodObject<{
|
|
93
|
+
page: z.ZodNumber;
|
|
94
|
+
pageSize: z.ZodNumber;
|
|
95
|
+
total: z.ZodNumber;
|
|
96
|
+
totalPages: z.ZodNumber;
|
|
97
|
+
}, z.core.$strip>;
|
|
98
|
+
export declare const paginationQuerySchema: z.ZodObject<{
|
|
99
|
+
page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
100
|
+
pageSize: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
101
|
+
}, z.core.$strip>;
|
|
102
|
+
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
|
103
|
+
export declare function successSchema<T extends z.ZodType>(data: T): z.ZodObject<{
|
|
104
|
+
success: z.ZodLiteral<true>;
|
|
105
|
+
data: T;
|
|
106
|
+
}, z.core.$strip>;
|
|
107
|
+
export declare function paginatedSchema<T extends z.ZodType>(item: T): z.ZodObject<{
|
|
108
|
+
success: z.ZodLiteral<true>;
|
|
109
|
+
data: z.ZodArray<T>;
|
|
110
|
+
meta: z.ZodObject<{
|
|
111
|
+
page: z.ZodNumber;
|
|
112
|
+
pageSize: z.ZodNumber;
|
|
113
|
+
total: z.ZodNumber;
|
|
114
|
+
totalPages: z.ZodNumber;
|
|
115
|
+
}, z.core.$strip>;
|
|
116
|
+
}, z.core.$strip>;
|
|
117
|
+
export declare function success<T>(data: T): {
|
|
118
|
+
success: true;
|
|
119
|
+
data: T;
|
|
120
|
+
};
|
|
121
|
+
export declare function paginated<T>(data: T[], page: number, pageSize: number, total: number): {
|
|
122
|
+
success: true;
|
|
123
|
+
data: T[];
|
|
124
|
+
meta: {
|
|
125
|
+
page: number;
|
|
126
|
+
pageSize: number;
|
|
127
|
+
total: number;
|
|
128
|
+
totalPages: number;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
export declare function jsonContent<T extends z.ZodType>(schema: T, description: string): {
|
|
132
|
+
content: {
|
|
133
|
+
"application/json": {
|
|
134
|
+
schema: T;
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
description: string;
|
|
138
|
+
};
|
|
139
|
+
/** A required JSON request body. Without `required`, a request with no body skips validation. */
|
|
140
|
+
export declare function jsonBody<T extends z.ZodType>(schema: T, description: string): {
|
|
141
|
+
content: {
|
|
142
|
+
"application/json": {
|
|
143
|
+
schema: T;
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
description: string;
|
|
147
|
+
required: boolean;
|
|
148
|
+
};
|
|
149
|
+
declare const ERROR_DESCRIPTIONS: {
|
|
150
|
+
readonly 400: "Invalid request";
|
|
151
|
+
readonly 401: "Authentication required";
|
|
152
|
+
readonly 403: "Forbidden";
|
|
153
|
+
readonly 404: "Not found";
|
|
154
|
+
readonly 409: "Conflict";
|
|
155
|
+
};
|
|
156
|
+
type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
|
|
157
|
+
/** OpenAPI error responses for the given statuses, all using the shared error envelope. */
|
|
158
|
+
export declare function errorResponses<const S extends ErrorStatus[]>(...statuses: S): { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>>; };
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/server.d.ts
|
|
161
|
+
/** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
|
|
162
|
+
export declare function startServer<E extends HttpEnv>(app: OpenAPIHono<E>, options: {
|
|
163
|
+
port: number;
|
|
164
|
+
name: string;
|
|
165
|
+
logger: CoreLogger;
|
|
166
|
+
}): import("@hono/node-server").ServerType;
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/validation.d.ts
|
|
169
|
+
export declare const validationHook: Hook<any, any, any, any>;
|
|
170
|
+
//#endregion
|
|
171
|
+
export type { CreateAppOptions, HttpEnv, RouteDefinition };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import { Scalar } from "@scalar/hono-api-reference";
|
|
3
|
+
import { handleSignals, onShutdown, runReadinessChecks } from "@tatara/core";
|
|
4
|
+
import { bodyLimit } from "hono/body-limit";
|
|
5
|
+
import { cors } from "hono/cors";
|
|
6
|
+
import { createMiddleware } from "hono/factory";
|
|
7
|
+
import { HTTPException } from "hono/http-exception";
|
|
8
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
9
|
+
import { serve } from "@hono/node-server";
|
|
10
|
+
//#region src/errors.ts
|
|
11
|
+
var AppError = class extends Error {
|
|
12
|
+
status;
|
|
13
|
+
code;
|
|
14
|
+
details;
|
|
15
|
+
/** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
|
|
16
|
+
translatable;
|
|
17
|
+
constructor(status, code, message, details, translatable = false) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = new.target.name;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.details = details;
|
|
23
|
+
this.translatable = translatable;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var ValidationError = class extends AppError {
|
|
27
|
+
constructor(message, details) {
|
|
28
|
+
super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === void 0);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var BusinessRuleError = class extends AppError {
|
|
32
|
+
constructor(code, message, details) {
|
|
33
|
+
super(400, code, message, details, true);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var UnauthorizedError = class extends AppError {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(401, "UNAUTHORIZED", message ?? "Authentication required", void 0, message === void 0);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var ForbiddenError = class extends AppError {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(403, "FORBIDDEN", message ?? "You do not have permission to do this", void 0, message === void 0);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var NotFoundError = class extends AppError {
|
|
47
|
+
constructor(resource) {
|
|
48
|
+
super(404, "NOT_FOUND", `${resource ?? "Resource"} not found`, void 0, resource === void 0);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var ConflictError = class extends AppError {
|
|
52
|
+
constructor(message, details) {
|
|
53
|
+
super(409, "CONFLICT", message ?? "Resource already exists", details, message === void 0);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/validation.ts
|
|
58
|
+
const validationHook = (result) => {
|
|
59
|
+
if (!result.success) throw new ValidationError(void 0, result.error.issues.map((issue) => ({
|
|
60
|
+
path: issue.path.join("."),
|
|
61
|
+
message: issue.message,
|
|
62
|
+
code: issue.code
|
|
63
|
+
})));
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/app.ts
|
|
67
|
+
const REQUEST_ID = /^[\w-]{8,128}$/;
|
|
68
|
+
/** Sets requestId, a request-scoped logger and default variables; logs one line per request. */
|
|
69
|
+
function requestContext(logger, defaults = {}) {
|
|
70
|
+
return createMiddleware(async (c, next) => {
|
|
71
|
+
const incoming = c.req.header("x-request-id");
|
|
72
|
+
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
73
|
+
const log = logger.child({ requestId });
|
|
74
|
+
const set = c.set;
|
|
75
|
+
set("requestId", requestId);
|
|
76
|
+
set("logger", log);
|
|
77
|
+
for (const [key, value] of Object.entries(defaults)) set(key, value);
|
|
78
|
+
c.header("x-request-id", requestId);
|
|
79
|
+
const start = performance.now();
|
|
80
|
+
await next();
|
|
81
|
+
const ms = Math.round(performance.now() - start);
|
|
82
|
+
const status = c.res.status;
|
|
83
|
+
const entry = {
|
|
84
|
+
method: c.req.method,
|
|
85
|
+
path: c.req.path,
|
|
86
|
+
status,
|
|
87
|
+
ms
|
|
88
|
+
};
|
|
89
|
+
if (status >= 500) log.error(entry, "request failed");
|
|
90
|
+
else log.info(entry, "request");
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
|
|
94
|
+
function createApp(options) {
|
|
95
|
+
const app = new OpenAPIHono({ defaultHook: validationHook });
|
|
96
|
+
app.use(requestContext(options.logger, options.defaults));
|
|
97
|
+
app.use(secureHeaders());
|
|
98
|
+
app.use(cors({
|
|
99
|
+
origin: options.cors.origins,
|
|
100
|
+
credentials: true,
|
|
101
|
+
allowHeaders: options.cors.allowHeaders ?? [
|
|
102
|
+
"Content-Type",
|
|
103
|
+
"Authorization",
|
|
104
|
+
"X-Request-Id",
|
|
105
|
+
"Accept-Language"
|
|
106
|
+
],
|
|
107
|
+
exposeHeaders: options.cors.exposeHeaders ?? ["X-Request-Id"]
|
|
108
|
+
}));
|
|
109
|
+
app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
|
|
110
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
111
|
+
app.get("/ready", async (c) => {
|
|
112
|
+
const result = await runReadinessChecks();
|
|
113
|
+
return c.json(result, result.ready ? 200 : 503);
|
|
114
|
+
});
|
|
115
|
+
return app;
|
|
116
|
+
}
|
|
117
|
+
/** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
|
|
118
|
+
function defineRoutes(definition) {
|
|
119
|
+
return definition;
|
|
120
|
+
}
|
|
121
|
+
/** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
|
|
122
|
+
function createRouter() {
|
|
123
|
+
return new OpenAPIHono({ defaultHook: validationHook });
|
|
124
|
+
}
|
|
125
|
+
/** Mounts the discovered routes that belong to `app` under `prefix`. */
|
|
126
|
+
function mountRoutes(app, routes, options) {
|
|
127
|
+
for (const definition of routes) if (definition.app === options.app) app.route(`${options.prefix}${definition.basePath}`, definition.router);
|
|
128
|
+
}
|
|
129
|
+
/** `/openapi.json` (built per request so `extend` can add to it) and the Scalar UI at `/docs`. */
|
|
130
|
+
function serveOpenApi(app, options) {
|
|
131
|
+
const config = {
|
|
132
|
+
openapi: "3.1.0",
|
|
133
|
+
info: {
|
|
134
|
+
title: options.title,
|
|
135
|
+
version: options.version ?? "1.0.0"
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
app.get("/openapi.json", async (c) => {
|
|
139
|
+
const document = app.getOpenAPI31Document(config);
|
|
140
|
+
await options.extend?.(document);
|
|
141
|
+
return c.json(document);
|
|
142
|
+
});
|
|
143
|
+
app.get("/docs", Scalar({
|
|
144
|
+
url: "/openapi.json",
|
|
145
|
+
pageTitle: options.title
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
/** The error envelope for every error, plus a 404 for unknown routes. */
|
|
149
|
+
function handleErrors(app, options = {}) {
|
|
150
|
+
app.onError((err, c) => {
|
|
151
|
+
if (err instanceof AppError) {
|
|
152
|
+
const message = options.message ? options.message(err, c) : err.message;
|
|
153
|
+
return c.json({
|
|
154
|
+
success: false,
|
|
155
|
+
error: message,
|
|
156
|
+
code: err.code,
|
|
157
|
+
...err.details === void 0 ? {} : { details: err.details }
|
|
158
|
+
}, err.status);
|
|
159
|
+
}
|
|
160
|
+
if (err instanceof HTTPException) return c.json({
|
|
161
|
+
success: false,
|
|
162
|
+
error: err.message || "Request failed",
|
|
163
|
+
code: "HTTP_ERROR"
|
|
164
|
+
}, err.status);
|
|
165
|
+
c.get("logger").error({ err }, "unhandled error");
|
|
166
|
+
return c.json({
|
|
167
|
+
success: false,
|
|
168
|
+
error: "Something went wrong",
|
|
169
|
+
code: "INTERNAL_ERROR"
|
|
170
|
+
}, 500);
|
|
171
|
+
});
|
|
172
|
+
app.notFound((c) => c.json({
|
|
173
|
+
success: false,
|
|
174
|
+
error: "Route not found",
|
|
175
|
+
code: "NOT_FOUND"
|
|
176
|
+
}, 404));
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/response.ts
|
|
180
|
+
const errorSchema = z.object({
|
|
181
|
+
success: z.literal(false),
|
|
182
|
+
error: z.string(),
|
|
183
|
+
code: z.string(),
|
|
184
|
+
details: z.unknown().optional()
|
|
185
|
+
}).openapi("Error");
|
|
186
|
+
const paginationMetaSchema = z.object({
|
|
187
|
+
page: z.number().int(),
|
|
188
|
+
pageSize: z.number().int(),
|
|
189
|
+
total: z.number().int(),
|
|
190
|
+
totalPages: z.number().int()
|
|
191
|
+
}).openapi("PaginationMeta");
|
|
192
|
+
const paginationQuerySchema = z.object({
|
|
193
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
194
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(20)
|
|
195
|
+
});
|
|
196
|
+
function successSchema(data) {
|
|
197
|
+
return z.object({
|
|
198
|
+
success: z.literal(true),
|
|
199
|
+
data
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function paginatedSchema(item) {
|
|
203
|
+
return z.object({
|
|
204
|
+
success: z.literal(true),
|
|
205
|
+
data: z.array(item),
|
|
206
|
+
meta: paginationMetaSchema
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function success(data) {
|
|
210
|
+
return {
|
|
211
|
+
success: true,
|
|
212
|
+
data
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function paginated(data, page, pageSize, total) {
|
|
216
|
+
return {
|
|
217
|
+
success: true,
|
|
218
|
+
data,
|
|
219
|
+
meta: {
|
|
220
|
+
page,
|
|
221
|
+
pageSize,
|
|
222
|
+
total,
|
|
223
|
+
totalPages: Math.max(1, Math.ceil(total / pageSize))
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function jsonContent(schema, description) {
|
|
228
|
+
return {
|
|
229
|
+
content: { "application/json": { schema } },
|
|
230
|
+
description
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** A required JSON request body. Without `required`, a request with no body skips validation. */
|
|
234
|
+
function jsonBody(schema, description) {
|
|
235
|
+
return {
|
|
236
|
+
...jsonContent(schema, description),
|
|
237
|
+
required: true
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const ERROR_DESCRIPTIONS = {
|
|
241
|
+
400: "Invalid request",
|
|
242
|
+
401: "Authentication required",
|
|
243
|
+
403: "Forbidden",
|
|
244
|
+
404: "Not found",
|
|
245
|
+
409: "Conflict"
|
|
246
|
+
};
|
|
247
|
+
/** OpenAPI error responses for the given statuses, all using the shared error envelope. */
|
|
248
|
+
function errorResponses(...statuses) {
|
|
249
|
+
return Object.fromEntries(statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]));
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/server.ts
|
|
253
|
+
/** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
|
|
254
|
+
function startServer(app, options) {
|
|
255
|
+
const { port, name, logger } = options;
|
|
256
|
+
const server = serve({
|
|
257
|
+
fetch: app.fetch,
|
|
258
|
+
port
|
|
259
|
+
}, (info) => {
|
|
260
|
+
logger.info({
|
|
261
|
+
port: info.port,
|
|
262
|
+
app: name
|
|
263
|
+
}, `${name} listening on http://localhost:${info.port}`);
|
|
264
|
+
});
|
|
265
|
+
onShutdown(`${name}-http`, () => new Promise((resolve) => server.close(() => resolve())));
|
|
266
|
+
handleSignals(logger);
|
|
267
|
+
return server;
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
export { AppError, BusinessRuleError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError, createApp, createRouter, defineRoutes, errorResponses, errorSchema, handleErrors, jsonBody, jsonContent, mountRoutes, paginated, paginatedSchema, paginationMetaSchema, paginationQuerySchema, requestContext, serveOpenApi, startServer, success, successSchema, validationHook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tatara/http",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "tatara HTTP: Hono app setup, errors, envelope, OpenAPI docs and route mounting",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"import": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"hono": "^4.13.13",
|
|
22
|
+
"@hono/zod-openapi": "^1.6.3",
|
|
23
|
+
"@hono/node-server": "^2.1.5",
|
|
24
|
+
"@scalar/hono-api-reference": "^0.12.12",
|
|
25
|
+
"@tatara/core": "^0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"hono": "^4.13.13",
|
|
29
|
+
"@hono/zod-openapi": "^1.6.3",
|
|
30
|
+
"@hono/node-server": "^2.1.5",
|
|
31
|
+
"@scalar/hono-api-reference": "^0.12.12",
|
|
32
|
+
"@tatara/core": "^0.1.0",
|
|
33
|
+
"pino": "^10.4.0",
|
|
34
|
+
"zod": "^4.6.7"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/mohammed-bageri/tatara-cli.git",
|
|
42
|
+
"directory": "packages/http"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/mohammed-bageri/tatara-cli#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/mohammed-bageri/tatara-cli/issues"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsdown",
|
|
50
|
+
"typecheck": "tsc --noEmit"
|
|
51
|
+
}
|
|
6
52
|
}
|