Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generate InternalFetch signature #1532

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5fb99e2
feat: generate `InternalFetch` signature
danielroe Aug 4, 2023
44e000a
feat: generate `InternalFetch` signatures
danielroe Aug 4, 2023
f3c5c77
style: lint
danielroe Aug 4, 2023
b63b567
style: lint more
danielroe Aug 4, 2023
8e3f58f
Merge remote-tracking branch 'origin/main' into feat/typed-inputs
danielroe Aug 7, 2023
7d58869
fix: require options when using non-default method
danielroe Aug 7, 2023
a755fb7
Merge remote-tracking branch 'origin/main' into feat/typed-inputs
danielroe Aug 7, 2023
1f87d09
fix: restore generic defaults for `$Fetch`
danielroe Aug 7, 2023
b4d2b00
fix: request should extend url/requestinfo
danielroe Aug 7, 2023
4fd5be5
chore: apply automated lint fixes
autofix-ci[bot] Aug 7, 2023
46751d7
fix: revert changes to generics
danielroe Aug 7, 2023
e2f769a
test: add failing tests
danielroe Aug 7, 2023
c501836
fix: refine extends and skip private routes
danielroe Aug 8, 2023
8b124b4
fix: disallow unmatched urls
danielroe Aug 8, 2023
18350be
Merge remote-tracking branch 'origin/main' into feat/typed-inputs
danielroe Aug 8, 2023
cf64788
fix: correctly type `$fetch.{raw/create}`
danielroe Aug 9, 2023
7b3ac30
test: update test with TODO comments for unions
danielroe Aug 9, 2023
cd6f2d1
Merge remote-tracking branch 'origin/main' into feat/typed-inputs
danielroe Aug 9, 2023
3a9ba18
chore: apply automated lint fixes
autofix-ci[bot] Aug 9, 2023
357e1e4
fix: add back string types for `NitroFetchRequest`
danielroe Aug 9, 2023
7290307
test: add additional case
danielroe Aug 9, 2023
17ba1b3
fix: reuse type
danielroe Aug 9, 2023
fd7d1fc
fix: improve type safety with optional options param
danielroe Aug 10, 2023
854a5e3
fix: improve error message when no route is matched
danielroe Aug 10, 2023
2cc871f
chore: apply automated lint fixes
autofix-ci[bot] Aug 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,29 +77,79 @@ export async function build(nitro: Nitro) {
: _build(nitro, rollupConfig);
}

const DYNAMIC_PARAM_RE = /(\*\*)(?!:)|((\*\*)?:[^/]+)/g;

export async function writeTypes(nitro: Nitro) {
const routeTypes: Record<
string,
Partial<Record<RouterMethod | "default", string[]>>
> = {};

const fetchSignatures = [] as Array<[route: string, type: string]>;
const eventHandlerImports = new Set<string>();

const typesDir = resolve(nitro.options.buildDir, "types");

const middleware = [...nitro.scannedHandlers, ...nitro.options.handlers];

let i = 1;
for (const mw of middleware) {
if (typeof mw.handler !== "string" || !mw.route) {
continue;
}
const relativePath = relative(typesDir, mw.handler).replace(
/\.[a-z]+$/,
""
);
const relativePath = relative(
typesDir,
resolveAlias(mw.handler, nitro.options.alias)
).replace(/\.[a-z]+$/, "");

if (!routeTypes[mw.route]) {
routeTypes[mw.route] = {};
}

const eventHandlerType = `EventHandler${i++}`;
eventHandlerImports.add(
`type ${eventHandlerType} = typeof import('${relativePath}').default`
);

// const isOptionsOptional
const isMethodOptional = !mw.method || mw.method.toUpperCase() === "GET";
const excludedMethods = middleware
.filter(
(other) => other.method && other.route === mw.route && other !== mw
)
.flatMap((m) =>
[m.method.toUpperCase(), m.method.toLowerCase()].map((m) => `'${m}'`)
)
.join(" | ");

const defaultMethod = excludedMethods
? `Exclude<DefaultMethod, ${excludedMethods}>`
: "DefaultMethod";

const methodType = mw.method
? [mw.method.toUpperCase(), mw.method.toLowerCase()]
.filter((m) => m !== "patch")
.map((m) => `'${m}'`)
.join(" | ")
: defaultMethod;

// TODO: 1. Fine-tune matching algorithm?
// TODO: merge return types
// TODO: 2. infer returns when we provide typed input
danielroe marked this conversation as resolved.
Show resolved Hide resolved
// TODO: 3. require options object when we provide typed input

const routeType = DYNAMIC_PARAM_RE.test(mw.route)
? `\`${mw.route.replace(DYNAMIC_PARAM_RE, `\${string}`)}\``
: `'${mw.route}' | \`${mw.route}?$\{string}\``;

fetchSignatures.push([
mw.route,
` <T = ${eventHandlerType} extends EventHandler<any, infer Output> ? Simplify<Serialize<Awaited<Output>>> : unknown>(url: ${routeType}, options?: BaseFetchOptions & { method${
isMethodOptional ? "?" : ""
}: ${methodType} } & (${eventHandlerType} extends EventHandler<infer Input> ? Input : {})): Promise<T>
`,
]);

const method = mw.method || "default";
if (!routeTypes[mw.route][method]) {
routeTypes[mw.route][method] = [];
Expand Down Expand Up @@ -135,6 +185,11 @@ export async function writeTypes(nitro: Nitro) {
const routes = [
"// Generated by nitro",
"import type { Serialize, Simplify } from 'nitropack'",
"import type { EventHandler, HTTPMethod } from 'h3'",
"import type { FetchOptions } from 'ofetch'",
"type DefaultMethod = HTTPMethod | Lowercase<Exclude<HTTPMethod, 'PATCH'>>",
pi0 marked this conversation as resolved.
Show resolved Hide resolved
"type BaseFetchOptions = Omit<FetchOptions, 'method' | 'body' | 'query'>",
...eventHandlerImports,
"declare module 'nitropack' {",
" type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T",
" interface InternalApi {",
Expand All @@ -148,6 +203,16 @@ export async function writeTypes(nitro: Nitro) {
].join("\n")
),
" }",
" interface InternalFetch {",
...fetchSignatures
.sort(([a], [b]) => {
return b
.replace(DYNAMIC_PARAM_RE, "____")
.localeCompare(a.replace(DYNAMIC_PARAM_RE, "____"));
})
.map(([route, type]) => type),
" }",
"",
"}",
// Makes this a module for augmentation purposes
"export {}",
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function createNitroApp(): NitroApp {
event.$fetch = ((req, init) =>
fetchWithEvent(event, req, init as RequestInit, {
fetch: $fetch,
})) as $Fetch<unknown, NitroFetchRequest>;
})) as $Fetch;

// https://github.com/unjs/nitro/issues/1420
event.waitUntil = (promise) => {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { $fetch } from "ofetch";
import { $Fetch, NitroFetchRequest } from "../types";

if (!globalThis.$fetch) {
globalThis.$fetch = $fetch as $Fetch<unknown, NitroFetchRequest>;
globalThis.$fetch = $fetch as $Fetch;
}
36 changes: 11 additions & 25 deletions src/types/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import type { MatchedRoutes } from "./utils";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface InternalApi {}

export interface InternalFetch {
<T = unknown>(
// eslint-disable-next-line @typescript-eslint/ban-types
request: Exclude<FetchRequest, string> | (string & {}),
opts?: FetchOptions
): Promise<T>;
}

export type NitroFetchRequest =
| Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`>
| Exclude<FetchRequest, string>
Expand Down Expand Up @@ -68,31 +76,9 @@ export type ExtractedRouteMethod<
? Lowercase<O["method"]>
: "get";

export interface $Fetch<
DefaultT = unknown,
DefaultR extends NitroFetchRequest = NitroFetchRequest,
> {
<
T = DefaultT,
R extends NitroFetchRequest = DefaultR,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>(
request: R,
opts?: O
): Promise<TypedInternalResponse<R, T, ExtractedRouteMethod<R, O>>>;
raw<
T = DefaultT,
R extends NitroFetchRequest = DefaultR,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>(
request: R,
opts?: O
): Promise<
FetchResponse<TypedInternalResponse<R, T, ExtractedRouteMethod<R, O>>>
>;
create<T = DefaultT, R extends NitroFetchRequest = DefaultR>(
defaults: FetchOptions
): $Fetch<T, R>;
export interface $Fetch extends InternalFetch {
raw: InternalFetch;
create(defaults: FetchOptions): InternalFetch;
}

declare global {
Expand Down
2 changes: 1 addition & 1 deletion src/types/h3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type H3EventFetch = (
init?: RequestInit
) => Promise<Response>;

export type H3Event$Fetch = $Fetch<unknown, NitroFetchRequest>;
export type H3Event$Fetch = $Fetch;

declare module "h3" {
interface H3Event {
Expand Down
12 changes: 3 additions & 9 deletions test/fixture/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,12 @@ describe("API routes", () => {
});

it("generates types for routes matching prefix", () => {
expectTypeOf($fetch("/api/hey/**")).toEqualTypeOf<Promise<string>>();
expectTypeOf($fetch("/api/param/{id}/**")).toEqualTypeOf<Promise<string>>();
expectTypeOf(
$fetch("/api/typed/user/{someUserId}/post/{somePostId}/**")
).toEqualTypeOf<
Promise<{ internalApiKey: "/api/typed/user/:userId/post/:postId" }>
>();
expectTypeOf($fetch("/api/typed/user/john/post/coffee/**")).toEqualTypeOf<
Promise<{ internalApiKey: "/api/typed/user/john/post/coffee" }>
>();
expectTypeOf(
$fetch(`/api/typed/user/${dynamicString}/post/${dynamicString}/**`)
).toEqualTypeOf<
Expand Down Expand Up @@ -144,16 +140,14 @@ describe("API routes", () => {
$fetch("/api/typed/todos/firstTodo/comments/foo")
).toEqualTypeOf<
Promise<
| { internalApiKey: "/api/typed/todos/**" }
| { internalApiKey: "/api/typed/todos/:todoId/comments/**:commentId" }
{ internalApiKey: "/api/typed/todos/:todoId/comments/**:commentId" }
>
>();
expectTypeOf(
$fetch(`/api/typed/todos/firstTodo/comments/${dynamicString}`)
).toEqualTypeOf<
Promise<
| { internalApiKey: "/api/typed/todos/**" }
| { internalApiKey: "/api/typed/todos/:todoId/comments/**:commentId" }
{ internalApiKey: "/api/typed/todos/:todoId/comments/**:commentId" }
>
>();
expectTypeOf(
Expand Down Expand Up @@ -212,7 +206,7 @@ describe("API routes", () => {
>();

expectTypeOf($fetch("/api/serialized/void")).toEqualTypeOf<
Promise<unknown>
Promise<never>
>();

expectTypeOf($fetch("/api/serialized/null")).toEqualTypeOf<Promise<any>>();
Expand Down
Loading