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 all 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
122 changes: 112 additions & 10 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,35 +82,105 @@ 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) {
if (
typeof mw.handler !== "string" ||
!mw.route ||
/^(\/_|\/api\/_)/.test(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`
);
eventHandlerImports.add(
`type ${eventHandlerType}Output = ${eventHandlerType} extends EventHandler<any, infer Output> ? Simplify<Serialize<Awaited<Output>>> : unknown`
);
eventHandlerImports.add(
`type ${eventHandlerType}Input = ${eventHandlerType} extends EventHandler<infer Input> ? Input : EventHandlerRequest`
);

const Output = `${eventHandlerType}Output`;
const Input = `${eventHandlerType}Input`;

// 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;

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 = ${Output}>(
url: ${routeType},
options: BaseFetchOptions & { method${
isMethodOptional ? "?" : ""
}: ${methodType} } & ${Input}
): true extends Raw ? Promise<FetchResponse<T>> : Promise<T>
${
isMethodOptional
? `
<T = ${Output}>(
url: IsOptional<${Input}> extends true ? ${routeType} : never,
options?: BaseFetchOptions & { method${
isMethodOptional ? "?" : ""
}: ${methodType} } & ${Input}
): true extends Raw ? Promise<FetchResponse<T>> : Promise<T>
`
: ""
}`,
]);

const method = mw.method || "default";
if (!routeTypes[mw.route][method]) {
routeTypes[mw.route][method] = [];
}
routeTypes[mw.route][method] ||= [];
routeTypes[mw.route][method].push(
`Simplify<Serialize<Awaited<ReturnType<typeof import('${relativePath}').default>>>>`
`Simplify<Serialize<Awaited<ReturnType<${eventHandlerType}>>>>`
);
}

Expand Down Expand Up @@ -169,7 +239,26 @@ export async function writeTypes(nitro: Nitro) {

const routes = [
"// Generated by nitro",
"import type { Serialize, Simplify } from 'nitropack'",
"import type { MatchedRoutes, Serialize, Simplify } from 'nitropack'",
"import type { EventHandler, EventHandlerRequest, HTTPMethod } from 'h3'",
"import type { FetchOptions, FetchResponse } from 'ofetch'",
"type DefaultMethod = HTTPMethod | Lowercase<Exclude<HTTPMethod, 'PATCH'>>",
pi0 marked this conversation as resolved.
Show resolved Hide resolved
`type IsOptional<T extends Record<string, any>> =
'body' extends keyof T
? 'query' extends keyof T
? undefined extends T['body'] & T['query']
? true
: false
: T['body'] extends undefined
? true
: false
: 'query' extends keyof T
? T['query'] extends undefined
? true
: false
: true`,
"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 @@ -183,6 +272,19 @@ export async function writeTypes(nitro: Nitro) {
].join("\n")
),
" }",
" interface InternalFetch<DefaultResponse, DefaultFetchRequest, Raw> {",
...fetchSignatures
.sort(([a], [b]) => {
return b
.replace(DYNAMIC_PARAM_RE, "____")
.localeCompare(a.replace(DYNAMIC_PARAM_RE, "____"));
})
.map(([route, type]) => type),
`
(message: 'Could not match any route because of missing or incorrect method, query or body options for that route.', _?: any): never
`,
" }",
"",
"}",
// Makes this a module for augmentation purposes
"export {}",
Expand Down
72 changes: 46 additions & 26 deletions src/types/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,50 @@ import type { MatchedRoutes } from "./utils";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface InternalApi {}

export interface InternalFetch<
DefaultResponse = unknown,
DefaultFetchRequest extends string | Request | URL =
| Exclude<FetchRequest, string>
| URL,
Raw extends boolean = false,
> {
<T = DefaultResponse, R extends string = string>(
url: MatchedRoutes<R> extends never ? R : never,
options?: FetchOptions
): true extends Raw ? Promise<FetchResponse<T>> : Promise<T>;

<T = DefaultResponse, R extends Request | URL = Request | URL>(
url: R,
options?: FetchOptions
): true extends Raw ? Promise<FetchResponse<T>> : Promise<T>;
}

export interface ExternalFetch<
DefaultResponse = unknown,
DefaultFetchRequest extends string | Request | URL = FetchRequest | URL,
Raw extends boolean = false,
> {
<T = DefaultResponse, R extends string | Request | URL = DefaultFetchRequest>(
url: R
): true extends Raw ? Promise<FetchResponse<T>> : Promise<T>;
}

export type NitroFetchRequest =
| Exclude<keyof InternalApi, `/_${string}` | `/api/_${string}`>
| keyof InternalApi
| Exclude<FetchRequest, string>
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});
| (string & {})
| URL;

/** @deprecated */
export type MiddlewareOf<
Route extends string,
Method extends RouterMethod | "default",
> = Method extends keyof InternalApi[MatchedRoutes<Route>]
? Exclude<InternalApi[MatchedRoutes<Route>][Method], Error | void>
: never;

/** @deprecated */
export type TypedInternalResponse<
Route,
Default = unknown,
Expand All @@ -37,6 +68,7 @@ export type TypedInternalResponse<

// Extracts the available http methods based on the route.
// Defaults to all methods if there aren't any methods available or if there is a catch-all route.
/** @deprecated */
export type AvailableRouterMethod<R extends NitroFetchRequest> =
R extends string
? keyof InternalApi[MatchedRoutes<R>] extends undefined
Expand All @@ -51,6 +83,7 @@ export type AvailableRouterMethod<R extends NitroFetchRequest> =

// Argumented fetch options to include the correct request methods.
// This overrides the default, which is only narrowed to a string.
/** @deprecated */
export interface NitroFetchOptions<
R extends NitroFetchRequest,
M extends AvailableRouterMethod<R> = AvailableRouterMethod<R>,
Expand All @@ -59,6 +92,7 @@ export interface NitroFetchOptions<
}

// Extract the route method from options which might be undefined or without a method parameter.
/** @deprecated */
export type ExtractedRouteMethod<
R extends NitroFetchRequest,
O extends NitroFetchOptions<R>,
Expand All @@ -69,30 +103,16 @@ export type ExtractedRouteMethod<
: "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>;
DefaultResponse = unknown,
DefaultFetchRequest extends string | Request | URL =
| Exclude<FetchRequest, string>
| URL,
> extends InternalFetch<DefaultResponse, DefaultFetchRequest> {
raw: InternalFetch<DefaultResponse, DefaultFetchRequest, true>;
create(
defaults: Omit<FetchOptions, "baseURL">
): InternalFetch<DefaultResponse>;
create(defaults: FetchOptions): ExternalFetch;
}

declare global {
Expand Down
1 change: 1 addition & 0 deletions test/fixture/routes/typed-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default eventHandler<{ query: { id: string } }, string>(() => 'foo');
Loading