Skip to content

Commit 1de696e

Browse files
committed
feat: Support app-level tags and security apply to all app routes
1 parent c5c9978 commit 1de696e

2 files changed

Lines changed: 185 additions & 9 deletions

File tree

src/__tests__/app.test.ts

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { describe, it, expect, mock } from "bun:test";
2-
import { createApp } from "../app";
3-
import { z } from "zod/v4";
4-
import { createTestAppClient } from "../testing";
1+
import { describe, expect, it, mock } from "bun:test";
52
import { expectTypeOf } from "expect-type";
6-
import type { AnyDef, GetAppData } from "../types";
3+
import type { OpenAPI } from "openapi-types";
4+
import { z } from "zod/v4";
75
import { zodSchemaAdapter } from "../adapters/zod-schema-adapter";
6+
import { createApp } from "../app";
87
import { HttpStatus } from "../status";
8+
import { createTestAppClient } from "../testing";
9+
import type { AnyDef, GetAppData } from "../types";
910

1011
// Silence console.error logs
1112
globalThis.console.error = mock();
@@ -448,4 +449,119 @@ describe("App", () => {
448449
expect(actual).toMatchObject({ a: "A" });
449450
});
450451
});
452+
453+
describe("app-level OpenAPI options", () => {
454+
describe("tags", () => {
455+
it("should apply app-level tags to all routes", () => {
456+
const app = createApp({
457+
schemaAdapter: zodSchemaAdapter,
458+
tags: ["Users"],
459+
})
460+
.get("/", { responses: z.string() }, () => "")
461+
.post("/", { responses: z.string() }, () => "");
462+
463+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
464+
465+
expect((spec.paths!["/"] as any).get.tags).toEqual(["Users"]);
466+
expect((spec.paths!["/"] as any).post.tags).toEqual(["Users"]);
467+
});
468+
469+
it("should allow route-level tags to override app-level tags", () => {
470+
const app = createApp({
471+
schemaAdapter: zodSchemaAdapter,
472+
tags: ["Users"],
473+
}).get("/", { tags: ["Admin"], responses: z.string() }, () => "");
474+
475+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
476+
477+
expect((spec.paths!["/"] as any).get.tags).toEqual(["Admin"]);
478+
});
479+
480+
it("should preserve app-level tags when nested via use()", () => {
481+
const usersApp = createApp({
482+
prefix: "/users",
483+
schemaAdapter: zodSchemaAdapter,
484+
tags: ["Users"],
485+
}).get("/", { responses: z.string() }, () => "");
486+
487+
const app = createApp({ schemaAdapter: zodSchemaAdapter }).use(
488+
usersApp,
489+
);
490+
491+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
492+
493+
expect((spec.paths!["/users"] as any).get.tags).toEqual(["Users"]);
494+
});
495+
});
496+
497+
describe("security", () => {
498+
it("should apply app-level security to all routes", () => {
499+
const app = createApp({
500+
schemaAdapter: zodSchemaAdapter,
501+
security: [{ bearerAuth: [] }],
502+
})
503+
.get("/", { responses: z.string() }, () => "")
504+
.post("/", { responses: z.string() }, () => "");
505+
506+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
507+
508+
expect((spec.paths!["/"] as any).get.security).toEqual([
509+
{ bearerAuth: [] },
510+
]);
511+
expect((spec.paths!["/"] as any).post.security).toEqual([
512+
{ bearerAuth: [] },
513+
]);
514+
});
515+
516+
it("should allow route-level security to override app-level security", () => {
517+
const app = createApp({
518+
schemaAdapter: zodSchemaAdapter,
519+
security: [{ bearerAuth: [] }],
520+
}).get(
521+
"/admin",
522+
{ security: [{ adminKey: [] }], responses: z.string() },
523+
() => "",
524+
);
525+
526+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
527+
528+
expect((spec.paths!["/admin"] as any).get.security).toEqual([
529+
{ adminKey: [] },
530+
]);
531+
});
532+
533+
it("should preserve app-level security when nested via use()", () => {
534+
const authApp = createApp({
535+
prefix: "/auth",
536+
schemaAdapter: zodSchemaAdapter,
537+
security: [{ bearerAuth: [] }],
538+
}).get("/profile", { responses: z.string() }, () => "");
539+
540+
const app = createApp({ schemaAdapter: zodSchemaAdapter }).use(authApp);
541+
542+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
543+
544+
expect((spec.paths!["/auth/profile"] as any).get.security).toEqual([
545+
{ bearerAuth: [] },
546+
]);
547+
});
548+
});
549+
550+
describe("tags and security combined", () => {
551+
it("should apply both tags and security to routes", () => {
552+
const app = createApp({
553+
schemaAdapter: zodSchemaAdapter,
554+
tags: ["Auth"],
555+
security: [{ bearerAuth: [] }],
556+
}).get("/profile", { responses: z.string() }, () => "");
557+
558+
const spec = app.getOpenApiSpec() as OpenAPI.Document;
559+
560+
expect((spec.paths!["/profile"] as any).get.tags).toEqual(["Auth"]);
561+
expect((spec.paths!["/profile"] as any).get.security).toEqual([
562+
{ bearerAuth: [] },
563+
]);
564+
});
565+
});
566+
});
451567
});

src/app.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { OpenAPIV3_1 } from "openapi-types";
1+
import type { OpenAPI, OpenAPIV3_1 } from "openapi-types";
22
import { addRoute, createRouter } from "rou3";
33
import { compileRouter } from "rou3/compiler";
44
import { compileFetchFunction } from "./internal/compile-fetch-function";
@@ -243,10 +243,15 @@ export function createApp<TPrefix extends BasePrefix = "">(
243243
app.method.apply(app, [Method.Any, ...args] as any) as any,
244244

245245
method(method: string, path: BasePath, ...args: any[]) {
246-
const def: RouteDef = args.length === 2 ? args[0] : undefined;
246+
const routeDef: RouteDef | undefined =
247+
args.length === 2 ? args[0] : undefined;
247248
const handler = args[1] ?? args[0];
248249
const route = `${prefix}${path}`;
249250
const hooks = cloneHooks();
251+
252+
// Merge app-level tags and security into route definition
253+
const def: RouteDef | undefined = mergeAppDefaults(routeDef, options);
254+
250255
const compiledHandler = compileRouteHandler({
251256
schemaAdapter: options?.schemaAdapter,
252257
def,
@@ -267,7 +272,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
267272

268273
mount(...args: any[]) {
269274
let path = "";
270-
let def = {};
275+
let routeDef: RouteDef = {};
271276
let fetch: ServerSideFetch;
272277

273278
if (args.length === 1) {
@@ -277,12 +282,16 @@ export function createApp<TPrefix extends BasePrefix = "">(
277282
fetch = args[1];
278283
} else {
279284
path = args[0];
280-
def = args[1];
285+
routeDef = args[1];
281286
fetch = args[2];
282287
}
283288

284289
const route = `${prefix}${path}/**`;
285290
const hooks = cloneHooks();
291+
292+
// Merge app-level tags and security into route definition
293+
const def = mergeAppDefaults(routeDef, options);
294+
286295
const compiledHandler = compileRouteHandler({
287296
schemaAdapter: options?.schemaAdapter,
288297
hooks,
@@ -403,13 +412,64 @@ export type CreateAppOptions<TPrefix extends BasePrefix = ""> = {
403412

404413
/** Configure how your application's OpenAPI docs are generated. */
405414
openApi?: Partial<OpenAPIV3_1.Document> & {};
415+
416+
/**
417+
* OpenAPI tags to apply to all routes in this app. Route-level tags will
418+
* override these app-level tags.
419+
*
420+
* @example
421+
* ```ts
422+
* const usersApp = createApp({
423+
* prefix: "/users",
424+
* tags: ["Users"],
425+
* })
426+
* .get("/", {}, () => [...]) // Will have ["Users"] tag
427+
* .get("/:id", { tags: ["Admin"] }, () => {...}) // Will have ["Admin"] tag (overrides app-level)
428+
* ```
429+
*/
430+
tags?: string[];
431+
432+
/**
433+
* OpenAPI security requirements to apply to all routes in this app.
434+
* Route-level security will override these app-level security requirements.
435+
*
436+
* @example
437+
* ```ts
438+
* const authApp = createApp({
439+
* prefix: "/auth",
440+
* security: [{ bearerAuth: [] }],
441+
* })
442+
* .get("/profile", {}, () => {...}) // Will require bearerAuth
443+
* .get("/admin", { security: [{ adminKey: [] }] }, () => {...}) // Will require adminKey (overrides app-level)
444+
* ```
445+
*/
446+
security?: OpenAPI.Document["security"];
406447
/**
407448
* Configure [Scalar](https://scalar.com/) UI docs.
408449
* @see https://github.com/scalar/scalar/blob/main/documentation/configuration.md#list-of-all-attributes
409450
*/
410451
scalar?: any;
411452
};
412453

454+
/**
455+
* Apply app-level defaults (tags, security) to a route definition.
456+
* Route-level values override app-level defaults.
457+
*/
458+
function mergeAppDefaults(
459+
routeDef: RouteDef | undefined,
460+
options: CreateAppOptions<any> | undefined,
461+
): RouteDef | undefined {
462+
if (!options?.tags?.length && !options?.security?.length) {
463+
return routeDef;
464+
}
465+
466+
return {
467+
tags: options.tags,
468+
security: options.security,
469+
...routeDef,
470+
};
471+
}
472+
413473
enum Method {
414474
Get = "GET",
415475
Post = "POST",

0 commit comments

Comments
 (0)