diff --git a/AGENTS.md b/AGENTS.md index 1ede6892..90cfe3f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,8 @@ the implementation rather than preserving the mismatch. uses `routes` for nesting. 3. Publish API routes only with `src/apis/**/api.*` or `src/apis/**/http.*`, and keep exactly one supported anchor file in each route directory. `api.*` exports - uppercase HTTP methods; `http.*` exports named `{ method, path?, handler }` domain - operations with a directory URL prefix. Put global middleware in `src/middlewares/middleware.*`, + uppercase HTTP methods; `http.*` exports named `http.get/post/...` bindings + (or existing `{ method, path?, handler }` domain operations) with a directory URL prefix. Put global middleware in `src/middlewares/middleware.*`, default-exporting one handler or an ordered non-empty array. Compose HTTP method policies with `withMiddlewares(handler, middlewares)` from `@evjs/ev/api`; share chains through ordinary imports. `RouteHandlerFn` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4b693af..6ed95775 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,8 @@ Release automation replaces them with the release version before publishing. 1. Create the URL directory below `src/apis`. 2. Add one `api.*` or `http.*` anchor with a `ts`, `tsx`, `js`, or `jsx` extension. -3. Export uppercase HTTP method handlers. +3. In `api.*`, export uppercase HTTP method handlers; in `http.*`, export named + business operations bound with `http.get/post/...` from `@evjs/ev/api`. 4. Put helpers and shared middleware chains in ordinary colocated modules. 5. Compose individual methods with `withMiddlewares(handler, middlewares)` from `@evjs/ev/api`. diff --git a/docs/docs/project-structure.md b/docs/docs/project-structure.md index eda9c65d..7daaf4ce 100644 --- a/docs/docs/project-structure.md +++ b/docs/docs/project-structure.md @@ -89,7 +89,7 @@ Paths are relative to the project root unless stated otherwise. | `/index.html` | HTML template for one MPA page | Does not create a page or client entry. | | `index.html` or `routing.html` | Shared application HTML template | `index.html` is the default. | | Imported module starting with `"use server";` | Server-function module | Named callable exports only; no required directory. | -| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | Public HTTP route | Exactly one anchor per directory. `api.*` exports uppercase handlers; `http.*` exports named `{ method, path?, handler }` operations. Compose policies with `withMiddlewares(handler, middlewares)`. | +| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | Public HTTP route | Exactly one anchor per directory. `api.*` exports uppercase handlers; `http.*` exports named `http.get/post/...` bindings or `{ method, path?, handler }` operations. Compose policies with `withMiddlewares(handler, middlewares)`. | | Other files inside an API route directory | Route-owned source | Helpers and `index.*` do not create endpoints. | | `src/middlewares/middleware.*` | Global middleware composition | Default-export one middleware or an explicitly ordered non-empty list. Computed global chains may resolve to `[]` when disabled. | | Other files in `src/middlewares` | Middleware implementation modules | Imported explicitly; filenames do not define order. | @@ -256,4 +256,4 @@ export const GET = { }; ``` -`src/apis/**/http.*` groups named `{ method, path?, handler }` operation descriptors. Export names express business operations, such as `listOrders` or `createOrder`; uppercase HTTP verbs such as `GET` and `POST` are rejected as export names. Its directory supplies a URL prefix, and each operation supplies a path suffix (default `/`). Multiple operations may share one file and one URL when their HTTP methods differ. Middleware and runtime execution use the same chain as `api.*`. Keep exactly one `api.*` or `http.*` anchor per route directory. Both feed the same development server, route types, HTTP client metadata, and production build graph. Framework plugins may use the filename to distinguish deployment policies. +`src/apis/**/http.*` groups named `http.get/post/...` bindings from `@evjs/ev/api`; existing `{ method, path?, handler }` operation descriptors are also supported. Export names express business operations, such as `listOrders` or `createOrder`; uppercase HTTP verbs such as `GET` and `POST` are rejected as export names. Its directory supplies a URL prefix, and each operation supplies a path suffix (default `/`). Multiple operations may share one file and one URL when their HTTP methods differ. Middleware and runtime execution use the same chain as `api.*`. Keep exactly one `api.*` or `http.*` anchor per route directory. Both feed the same development server, route types, HTTP client metadata, and production build graph. Framework plugins may use the filename to distinguish deployment policies. diff --git a/docs/docs/server-routes.md b/docs/docs/server-routes.md index bd20c628..83f442ba 100644 --- a/docs/docs/server-routes.md +++ b/docs/docs/server-routes.md @@ -312,27 +312,22 @@ short-circuit derived `HEAD`, whose final response is always bodyless. ## Domain HTTP operations -`http.*` groups named operation descriptors in one domain module. Export names express business operations, such as `listOrders` or `createOrder`; uppercase HTTP verbs such as `GET` and `POST` are rejected as export names. Each export declares an uppercase `method` and an optional `path` suffix; the directory supplies the URL prefix. A missing suffix maps to the domain root. Handler functions, middleware, streaming, HEAD and OPTIONS use the same HTTP runtime as `api.*`. +`http.*` groups named operations in one domain module. Use business export names such as `listOrders` or `createOrder`, and select the HTTP method with `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head`, or `http.options` from `@evjs/ev/api`. The directory supplies the URL prefix; an optional static `path` adds a suffix. ```ts // src/apis/orders/http.ts -import type { HttpOperation } from "@evjs/ev/api"; - -export const listOrders = { - method: "GET", - handler: () => Response.json([]), -} satisfies HttpOperation; -export const createOrder = { - method: "POST", - async handler(request: Request) { - return Response.json(await request.json()); - }, -} satisfies HttpOperation; -export const getOrder = { - method: "GET", - path: "/:id", - handler: (_request, ctx) => Response.json({ id: ctx.req.param("id") }), -} satisfies HttpOperation<"/:id">; +import { http } from "@evjs/ev/api"; + +export const listOrders = http.get(() => Response.json([])); +export const createOrder = http.post(async (request) => { + return Response.json(await request.json()); +}); +export const getOrder = http.get( + (_request, ctx) => Response.json({ id: ctx.req.param("id") }), + { path: "/:id" }, +); ``` -These exports publish GET /orders, POST /orders, and GET /orders/:id. Method and path metadata must be static string literals; duplicate methods at the same URL are rejected. Keep one api.* or http.* anchor per directory. Framework plugins own deployment metadata; source-file grouping does not prescribe a deployment resource boundary. +These exports publish GET /orders, POST /orders, and GET /orders/:id. Helpers return the existing handler descriptor and preserve the standard Request/Response and middleware runtime, including streaming. Method metadata comes from the helper and must not be repeated in options. Named and namespace import aliases are supported; routing options must be a static object with a literal path. + +Existing `{ method, path?, handler }` declarations remain supported. Duplicate methods at the same URL are rejected. Keep one api.* or http.* anchor per directory. Framework plugins own deployment metadata; source-file grouping does not prescribe a deployment resource boundary. diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/project-structure.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/project-structure.md index ca321565..de211320 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/project-structure.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/project-structure.md @@ -83,7 +83,7 @@ src/pages/orders/$orderId/ | `<页面>/index.html` | 单个 MPA 页面的 HTML 模板 | 不创建页面或客户端入口。 | | `index.html` 或 `routing.html` | 共享应用 HTML 模板 | 默认使用 `index.html`。 | | 以 `"use server";` 开头且被应用导入的模块 | 服务端函数模块 | 只能命名导出可调用值,不要求固定目录。 | -| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | 公共 HTTP 路由 | 每个目录一个锚点;`api.*` 导出大写方法,`http.*` 导出具名 `{ method, path?, handler }` 操作;策略使用 `withMiddlewares(handler, middlewares)`。 | +| `src/apis/**/{api,http}.{ts,tsx,js,jsx}` | 公共 HTTP 路由 | 每个目录一个锚点;`api.*` 导出大写方法,`http.*` 导出具名 `http.get/post/...` 绑定或 `{ method, path?, handler }` 操作;策略使用 `withMiddlewares(handler, middlewares)`。 | | API 路由目录中的其他文件 | 路由拥有的源码 | 辅助文件和 `index.*` 不创建端点。 | | `src/middlewares/middleware.*` | 全局中间件组合 | 默认导出一个中间件或显式排序的非空列表;动态计算的全局链可以在禁用时返回 `[]`。 | | `src/middlewares` 中的其他文件 | 中间件实现模块 | 显式导入,文件名不决定顺序。 | @@ -230,4 +230,4 @@ export const GET = { }; ``` -`src/apis/**/http.*` 用多个具名 `{ method, path?, handler }` 描述对象组织领域操作。目录提供 URL 前缀,每个操作声明路径后缀(默认 `/`);同一文件可发布多个路径,同一路径可声明不同 HTTP 动词。中间件和运行时复用 `api.*` 的执行链。每个路由目录只保留一个 `api.*` 或 `http.*` 锚点。两者进入同一开发服务器、路由类型、HTTP 客户端元信息及生产构建图,框架插件可以根据文件名区分部署策略。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受 `GET`、`POST` 等大写 HTTP 动词作为导出名。 +`src/apis/**/http.*` 用来自 `@evjs/ev/api` 的具名 `http.get/post/...` 绑定组织领域操作,也支持已有 `{ method, path?, handler }` 描述对象。目录提供 URL 前缀,每个操作声明路径后缀(默认 `/`);同一文件可发布多个路径,同一路径可声明不同 HTTP 动词。中间件和运行时复用 `api.*` 的执行链。每个路由目录只保留一个 `api.*` 或 `http.*` 锚点。两者进入同一开发服务器、路由类型、HTTP 客户端元信息及生产构建图,框架插件可以根据文件名区分部署策略。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受 `GET`、`POST` 等大写 HTTP 动词作为导出名。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/server-routes.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/server-routes.md index 913cd165..bbf0e19c 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/server-routes.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/server-routes.md @@ -273,12 +273,23 @@ Hono 的错误处理行为:异常转成错误响应,中间件退出时可通 ## HTTP 路由锚点别名 -可以使用 `http.ts`、`http.tsx`、`http.js` 或 `http.jsx`,它们与 `api.*` 复用同一编译器和运行时,目录提供 URL 前缀,http.* 的具名操作使用 method/path 声明各自的路由。每个路由目录只能保留其中一个锚点,同时声明两者会报重复路由错误。其他同目录源码仍为普通私有模块。 +可以使用 `http.ts`、`http.tsx`、`http.js` 或 `http.jsx`,它们与 `api.*` 复用同一编译器和运行时,目录提供 URL 前缀,http.* 的具名操作通过 HTTP helper 和可选 path 声明各自的路由。每个路由目录只能保留其中一个锚点,同时声明两者会报重复路由错误。其他同目录源码仍为普通私有模块。 ## 领域 HTTP 操作 -`http.*` 在同一领域文件中导出多个具名描述对象,每个导出显式声明大写 `method`,可选 `path` 为目录路径下的后缀。省略 path 时对应领域根路径。例如 orders/http.ts 中 listOrders 的 GET 对应 /orders,createOrder 的 POST 也对应 /orders,getOrder 的 GET 和 path: '/:id' 对应 /orders/:id。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受 `GET`、`POST` 等大写 HTTP 动词作为导出名。 +`http.*` 在同一领域文件中导出多个具名操作。导出名使用 `listOrders`、`createOrder` 等业务名称,不接受大写 HTTP 动词。使用 `@evjs/ev/api` 的 `http.get/post/put/patch/delete/head/options` 选择方法,目录提供 URL 前缀,可选 `path` 指定后缀。 -`satisfies HttpOperation`(从 `@evjs/ev/api` 导入)提供编写提示;带动态后缀时使用 `HttpOperation<"/:id">`。 +```ts +import { http } from "@evjs/ev/api"; + +export const listOrders = http.get(() => Response.json([])); +export const createOrder = http.post(async (request) => Response.json(await request.json())); +export const getOrder = http.get( + (_request, ctx) => Response.json({ id: ctx.req.param("id") }), + { path: "/:id" }, +); +``` + +helper 只返回现有 handler 描述对象,不包装 Request/Response,也不改变 middleware、SSE、HEAD 和 OPTIONS 的执行。HTTP 方法来自 helper,options 中不再声明 method。支持具名导入别名和 namespace 访问;options 必须是静态对象,path 必须是字符串字面量。 -method/path 必须是静态字符串,不通过业务函数体推断。相同 URL 下可声明不同动词,重复动词会报错。handler、中间件、SSE、HEAD 和 OPTIONS 复用既有 HTTP 执行链。每个目录保留一个 api.* 或 http.* 锚点,部署策略和资源拆分由框架插件管理。 +已有 `{ method, path?, handler }` 声明仍可使用。相同 URL 下重复动词会报错,每个目录保留一个 api.* 或 http.* 锚点,部署策略和资源拆分仍由框架插件管理。 diff --git a/packages/ev/src/_internal/build/analysis/http-operations.ts b/packages/ev/src/_internal/build/analysis/http-operations.ts index bad69487..8bd5a408 100644 --- a/packages/ev/src/_internal/build/analysis/http-operations.ts +++ b/packages/ev/src/_internal/build/analysis/http-operations.ts @@ -1,5 +1,5 @@ import { HTTP_METHODS } from "@evjs/shared"; -import type { Module } from "@swc/types"; +import type { Expression, Module, ObjectExpression } from "@swc/types"; import { resolveHttpOperationPath } from "../conventions/http-operation-path.js"; import type { DiscoveredServerRouteNode, @@ -29,6 +29,8 @@ export function analyzeHttpOperations( >(); const exports = collectModuleExportNames(ast.body); const definitions = new Set(); + const helperBindings = collectHttpBindings(ast); + const helperHandlers = new Map(); const fail = (message: string) => diagnostics.push({ level: "error", file, message }); if (hasDefaultExport(ast)) fail("http.* requires named operation exports."); @@ -49,18 +51,41 @@ export function analyzeHttpOperations( ); continue; } - const object = unwrapExpression(declaration.init); - if (object.type !== "ObjectExpression") { + const value = unwrapExpression(declaration.init); + const helper = readHttpBinding(value, helperBindings); + let object: ObjectExpression | undefined; + let method: string | undefined; + if (helper) { + const args = helper.call.arguments; + const handler = args[0]; + if (!handler || args.length > 2 || args.some((arg) => arg.spread)) { + fail( + `HTTP operation "${name}" requires a handler and optional static options.`, + ); + continue; + } + helperHandlers.set(name, unwrapExpression(handler.expression)); + method = helper.method; + if (args[1]) { + const options = unwrapExpression(args[1].expression); + if (options.type !== "ObjectExpression") { + fail(`HTTP operation "${name}" requires a static options object.`); + continue; + } + object = options; + } + } else if (value.type === "ObjectExpression") { + object = value; + } else { fail( - `HTTP operation "${name}" requires a static { method, path?, handler } object.`, + `HTTP operation "${name}" requires http.get/post/put/patch/delete/head/options(handler, options?) from @evjs/ev/api or a static { method, path?, handler } object.`, ); continue; } - let method: string | undefined; let suffix = "/"; const fields = new Set(); const errorsBefore = diagnostics.length; - for (const property of object.properties) { + for (const property of object?.properties ?? []) { if (property.type === "SpreadElement") { fail(`HTTP operation "${name}" cannot spread routing metadata.`); continue; @@ -71,6 +96,12 @@ export function analyzeHttpOperations( continue; } if (key.value !== "method" && key.value !== "path") continue; + if (helper && key.value === "method") { + fail( + `HTTP operation "${name}" gets its method from http.${helper.method.toLowerCase()}(); omit method from options.`, + ); + continue; + } if (fields.has(key.value)) fail(`HTTP operation "${name}" repeats "${key.value}".`); fields.add(key.value); @@ -133,9 +164,11 @@ export function analyzeHttpOperations( } if (!definitions.size) fail("http.* requires at least one named operation descriptor."); - for (const message of validateServerRouteMethodExports(ast.body, [ - ...definitions, - ])) + for (const message of validateServerRouteMethodExports( + ast.body, + [...definitions], + helperHandlers, + )) fail(message); for (const route of routes.values()) { route.methods = HTTP_METHODS.filter((method) => @@ -145,3 +178,58 @@ export function analyzeHttpOperations( } return { routes: [...routes.values()], diagnostics }; } + +interface HttpBindings { + named: Set; + namespaces: Set; +} + +function collectHttpBindings(ast: Module): HttpBindings { + const bindings: HttpBindings = { named: new Set(), namespaces: new Set() }; + for (const item of ast.body) { + if ( + item.type !== "ImportDeclaration" || + item.typeOnly || + !["@evjs/ev/api", "@alipay/evjs/api"].includes(item.source.value) + ) + continue; + for (const specifier of item.specifiers) { + if (specifier.type === "ImportNamespaceSpecifier") { + bindings.namespaces.add(specifier.local.value); + } else if ( + specifier.type === "ImportSpecifier" && + !specifier.isTypeOnly && + (specifier.imported?.value ?? specifier.local.value) === "http" + ) { + bindings.named.add(specifier.local.value); + } + } + } + return bindings; +} + +function readHttpBinding(value: Expression, bindings: HttpBindings) { + if ( + value.type !== "CallExpression" || + value.callee.type !== "MemberExpression" + ) + return; + const callee = value.callee; + if (callee.property.type !== "Identifier") return; + const method = callee.property.value.toUpperCase(); + if ( + callee.property.value !== method.toLowerCase() || + !HTTP_METHODS.some((candidate) => candidate === method) + ) + return; + const receiver = unwrapExpression(callee.object); + const known = + receiver.type === "Identifier" + ? bindings.named.has(receiver.value) + : receiver.type === "MemberExpression" && + receiver.property.type === "Identifier" && + receiver.property.value === "http" && + receiver.object.type === "Identifier" && + bindings.namespaces.has(receiver.object.value); + if (known) return { method, call: value }; +} diff --git a/packages/ev/src/_internal/build/analysis/server-route-exports.ts b/packages/ev/src/_internal/build/analysis/server-route-exports.ts index 3ddb889e..f6a63631 100644 --- a/packages/ev/src/_internal/build/analysis/server-route-exports.ts +++ b/packages/ev/src/_internal/build/analysis/server-route-exports.ts @@ -28,6 +28,7 @@ type MethodExportTarget = export function validateServerRouteMethodExports( body: ModuleItem[], methods: readonly string[], + handlers?: ReadonlyMap, ): string[] { const methodSet = new Set(methods); const targets = collectMethodExportTargets(body, methodSet); @@ -44,8 +45,10 @@ export function validateServerRouteMethodExports( } const [target] = methodTargets; - const resolution = - target?.kind === "local" + const handler = handlers?.get(method); + const resolution = handler + ? resolveExpression(handler, locals, new Set(), false) + : target?.kind === "local" ? resolveLocalBinding(target.localName, locals, new Set()) : "unknown"; const diagnostic = formatCallableDiagnostic(method, resolution); diff --git a/packages/ev/src/api/http.ts b/packages/ev/src/api/http.ts new file mode 100644 index 00000000..fc8b98ac --- /dev/null +++ b/packages/ev/src/api/http.ts @@ -0,0 +1,25 @@ +import type { RouteHandlerFn } from "@evjs/server"; +import type { HttpMethod } from "@evjs/shared"; +import type { Env, Input } from "hono"; +import type { BlankInput } from "hono/types"; + +function bindHttpMethod(method: Method) { + return function bind< + Path extends string = string, + E extends Env = Env, + I extends Input = BlankInput, + >(handler: RouteHandlerFn, options?: { path?: Path }) { + return { ...options, method, handler }; + }; +} + +/** Bind a named HTTP operation without wrapping its standard Request/Response handler. */ +export const http = { + get: bindHttpMethod("GET"), + post: bindHttpMethod("POST"), + put: bindHttpMethod("PUT"), + patch: bindHttpMethod("PATCH"), + delete: bindHttpMethod("DELETE"), + head: bindHttpMethod("HEAD"), + options: bindHttpMethod("OPTIONS"), +}; diff --git a/packages/ev/src/api/index.ts b/packages/ev/src/api/index.ts index 4a376b20..ee385c4f 100644 --- a/packages/ev/src/api/index.ts +++ b/packages/ev/src/api/index.ts @@ -16,3 +16,5 @@ export interface HttpRouteMetadata { /** Typed HTTP operation declared by one named export in a domain module. */ export type HttpOperation = RouteHandlerDescriptor & HttpRouteMetadata; + +export { http } from "./http.js"; diff --git a/packages/ev/tests/build-tools-server-routes.test.ts b/packages/ev/tests/build-tools-server-routes.test.ts index 5b8c8f84..423f776f 100644 --- a/packages/ev/tests/build-tools-server-routes.test.ts +++ b/packages/ev/tests/build-tools-server-routes.test.ts @@ -18,6 +18,125 @@ afterEach(async () => { }); describe("discoverServerRoutes", () => { + it.each([ + ["@evjs/ev/api", "import { http }", "http"], + ["@evjs/ev/api", "import { http as endpoint }", "endpoint"], + ["@evjs/ev/api", "import * as api", "api.http"], + ["@alipay/evjs/api", "import { http }", "http"], + ["@alipay/evjs/api", "import { http as endpoint }", "endpoint"], + ["@alipay/evjs/api", "import * as api", "api.http"], + ])("discovers HTTP helper operations from %s with %s", async (provider, declaration, helper) => { + const cwd = await createFixture({ + "src/apis/orders/http.ts": ` + ${declaration} from '${provider}'; + const handler = () => new Response('ok'); + export const list = ${helper}.get(handler); + export const create = ${helper}.post(handler); + export const read = ${helper}.get(handler, { path: '/:id', timeout: 1000 }); + `, + }); + const result = await discoverServerRoutes(cwd, { dir: "src/apis" }); + expect(result.diagnostics).toEqual([]); + expect(result.routes).toMatchObject([ + { + path: "/orders", + methods: ["GET", "POST"], + handlerExports: { GET: "list", POST: "create" }, + }, + { + path: "/orders/:id", + methods: ["GET"], + handlerExports: { GET: "read" }, + }, + ]); + }); + + it("discovers all helper methods and ignores erased contract type arguments", async () => { + const methods = [ + "get", + "post", + "put", + "patch", + "delete", + "head", + "options", + ]; + const cwd = await createFixture({ + "src/apis/orders/http.ts": ` + import { http } from '@alipay/evjs/api'; + ${methods.map((method) => `export const ${method}Order = http.${method}<{response: void}>(() => new Response());`).join("\n")} + `, + }); + const result = await discoverServerRoutes(cwd, { dir: "src/apis" }); + expect(result.diagnostics).toEqual([]); + expect(result.routes).toHaveLength(1); + expect(result.routes[0]?.methods).toEqual( + expect.arrayContaining(methods.map((method) => method.toUpperCase())), + ); + expect(Object.keys(result.routes[0]?.handlerExports ?? {})).toHaveLength(7); + }); + + it.each([ + ["export const read = http.get();", "requires a handler"], + [ + "export const read = http.get(() => new Response(), {}, {});", + "requires a handler", + ], + [ + "const options = {path: '/:id'}; export const read = http.get(() => new Response(), options);", + "static options object", + ], + [ + "const path = '/:id'; export const read = http.get(() => new Response(), {path});", + "static path string", + ], + [ + "export const read = http.get(() => new Response(), {path: (() => { throw Error('evaluated'); })()});", + "static path string", + ], + [ + "export const read = http.get(() => new Response(), {path: '/../escape'});", + "Invalid HTTP operation path", + ], + [ + "export const read = http.get(() => new Response(), {method: 'POST'});", + "gets its method from http.get", + ], + ["export const read = http.get(42);", "must resolve to a function"], + [ + "export const read = http.get({handler: () => new Response()});", + "must resolve to a function", + ], + ["export const read = http.get(function* () {});", "cannot be a generator"], + [ + "export const one = http.get(() => new Response()); export const two = http.get(() => new Response());", + "Duplicate HTTP operation GET /orders", + ], + ])("validates HTTP helper declarations without evaluating handlers: %s", async (source, message) => { + const cwd = await createFixture({ + "src/apis/orders/http.ts": `import { http } from '@evjs/ev/api'; ${source}`, + }); + const result = await discoverServerRoutes(cwd, { dir: "src/apis" }); + expect( + result.diagnostics.some((item) => item.message.includes(message)), + ).toBe(true); + }); + + it.each([ + "import { http } from './application-api';", + "import type { http } from '@evjs/ev/api';", + ])("does not treat unrelated or type-only imports as HTTP bindings: %s", async (declaration) => { + const cwd = await createFixture({ + "src/apis/orders/http.ts": `${declaration} export const read = http.get(() => new Response());`, + }); + const result = await discoverServerRoutes(cwd, { dir: "src/apis" }); + expect(result.routes).toEqual([]); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("requires http.get/post"), + }), + ); + }); it("expands a domain HTTP module into paths and explicit handler exports", async () => { const cwd = await createFixture({ "src/apis/orders/http.ts": ` diff --git a/packages/ev/tests/package-surface.test.ts b/packages/ev/tests/package-surface.test.ts index 5ff33602..e7e4ca0f 100644 --- a/packages/ev/tests/package-surface.test.ts +++ b/packages/ev/tests/package-surface.test.ts @@ -897,7 +897,10 @@ describe("workspace package surface", () => { }); it("separates HTTP handlers, middleware, and request context exports", () => { - expect(Object.keys(apiAuthoring)).toEqual(["withMiddlewares"]); + expect(Object.keys(apiAuthoring).sort()).toEqual([ + "http", + "withMiddlewares", + ]); expect(apiAuthoring.withMiddlewares).toBe(serverRuntime.withMiddlewares); expectTypeOf().toEqualTypeOf(); diff --git a/packages/ev/tests/server-middleware-runtime.test.ts b/packages/ev/tests/server-middleware-runtime.test.ts index 3ff60e20..1b71cd2a 100644 --- a/packages/ev/tests/server-middleware-runtime.test.ts +++ b/packages/ev/tests/server-middleware-runtime.test.ts @@ -68,11 +68,11 @@ describe("generated server middleware", () => { const server = await loadServer({ "src/middlewares/middleware.ts": `export default async (ctx, next) => { await next(); ctx.header('x-global', 'yes'); };`, "src/apis/orders/http.ts": ` - import { withMiddlewares } from '@evjs/ev/api'; + import { http, withMiddlewares } from '@evjs/ev/api'; export const list = { method: 'GET', handler: () => Response.json({ operation: 'list' }) }; - export const create = { method: 'POST', async handler(req) { return Response.json({ operation: 'create', body: await req.json() }); } }; - export const read = { method: 'GET', path: '/:id', handler: withMiddlewares((_req, ctx) => Response.json({ id: ctx.req.param('id') }), async (ctx, next) => { await next(); ctx.header('x-method', 'read'); }) }; - export const stream = { method: 'GET', path: '/stream', handler: () => new Response('data: ready\\n\\n', { headers: { 'content-type': 'text/event-stream' } }) }; + export const create = http.post(async req => Response.json({ operation: 'create', body: await req.json() })); + export const read = http.get(withMiddlewares((_req, ctx) => Response.json({ id: ctx.req.param('id') }), async (ctx, next) => { await next(); ctx.header('x-method', 'read'); }), { path: '/:id' }); + export const stream = http.get(() => new Response('data: ready\\n\\n', { headers: { 'content-type': 'text/event-stream' } }), { path: '/stream' }); `, }); const read = await server.fetch(new Request("http://app.test/orders/42"));