diff --git a/.changeset/orange-yaks-wave.md b/.changeset/orange-yaks-wave.md new file mode 100644 index 0000000..f50d42d --- /dev/null +++ b/.changeset/orange-yaks-wave.md @@ -0,0 +1,7 @@ +--- +"@next-fetch/core-plugin": patch +"@next-fetch/react-query": patch +"@next-fetch/swr": patch +--- + +Support optional input in `query` and `mutation` diff --git a/packages/@next-fetch/core-plugin/src/client.ts b/packages/@next-fetch/core-plugin/src/client.ts index d1fefd0..c87e644 100644 --- a/packages/@next-fetch/core-plugin/src/client.ts +++ b/packages/@next-fetch/core-plugin/src/client.ts @@ -31,10 +31,10 @@ export async function queryFetcher(url: string) { export function buildUrlSearchParams( handler: string, - object: Record + object?: Record ): URLSearchParams { const sp = new URLSearchParams(); - for (const [key, value] of Object.entries(object)) { + for (const [key, value] of Object.entries(object ?? {})) { if (Array.isArray(value)) { for (const v of value) { sp.append(key, v); diff --git a/packages/@next-fetch/core-plugin/src/parseEndpointFile.ts b/packages/@next-fetch/core-plugin/src/parseEndpointFile.ts index aae329d..2064d2a 100644 --- a/packages/@next-fetch/core-plugin/src/parseEndpointFile.ts +++ b/packages/@next-fetch/core-plugin/src/parseEndpointFile.ts @@ -3,7 +3,7 @@ import { simple } from "acorn-walk"; type ParsedQuery = { parserCode: string; - callbackCode: string; + callbackCode?: string; optionsCode?: string; }; export type Queries = { [name: string]: ParsedQuery }; @@ -31,7 +31,9 @@ export function parseEndpointFile(content: string): { "query" === declaration.init.callee.name ? queries : mutations; bag[content.slice(name.start, name.end)] = { parserCode: content.slice(parser.start, parser.end), - callbackCode: content.slice(callback.start, callback.end), + ...(callback && { + callbackCode: content.slice(callback.start, callback.end), + }), ...(options && { optionsCode: content.slice(options.start, options.end), }), diff --git a/packages/@next-fetch/core-plugin/src/server.ts b/packages/@next-fetch/core-plugin/src/server.ts index 45d1b9d..bb96ae0 100644 --- a/packages/@next-fetch/core-plugin/src/server.ts +++ b/packages/@next-fetch/core-plugin/src/server.ts @@ -11,16 +11,41 @@ export type HandlerCallback = ( input: Input ) => Promise; -type Handler = { +type NormalizedHandler = { parser: Parser; callback: HandlerCallback; options: Partial>; }; +type Handler = + | NormalizedHandler + | { + parser: HandlerCallback; + callback: Partial>; + options: undefined; + }; + type Handlers = Map>; const API_CONTENT_TYPE = "application/json+api"; +function normalize( + handler: Handler +): NormalizedHandler { + // @ts-expect-error This fails for some reason + return typeof handler.parser === "function" + ? { + parser: undefined, + callback: handler.parser, + options: handler.callback, + } + : { + parser: handler.parser, + callback: handler.callback, + options: handler.options, + }; +} + export async function handleEdgeFunction({ queries, mutations, @@ -44,24 +69,26 @@ export async function handleEdgeFunction({ return new Response("unknown handler", { status: 400 }); } - const { parser, callback } = handler; + const { parser, callback, options } = normalize(handler); let data: unknown; - try { - data = await parse( - parser, - bag === mutations - ? await getRequestBody(request) - : fromSearchParamToObject(url.searchParams) - ); - } catch (e: any) { - return new Response(e?.message || "Malformed input", { status: 400 }); + if (parser) { + try { + data = await parse( + parser, + bag === mutations + ? await getRequestBody(request) + : fromSearchParamToObject(url.searchParams) + ); + } catch (e: any) { + return new Response(e?.message || "Malformed input", { status: 400 }); + } } const response = await callback.call({ request }, data); - if (handler.options?.hookResponse && !forceJsonResponse) { - return handler.options.hookResponse.call({ request }, response); + if (options?.hookResponse && !forceJsonResponse) { + return options.hookResponse.call({ request }, response); } return NextResponse.json(response); @@ -100,12 +127,14 @@ export async function handleNodejsFunction({ .send(getUnknownHandlerError(String(req.query.__handler), bag)); } - const { parser, callback } = handler; + const { parser, callback, options } = normalize(handler); let data; - try { - data = await parse(parser, bag === mutations ? req.body : req.query); - } catch (e: any) { - return res.status(400).send(e?.message || "Malformed input"); + if (parser) { + try { + data = await parse(parser, bag === mutations ? req.body : req.query); + } catch (e: any) { + return res.status(400).send(e?.message || "Malformed input"); + } } /** @@ -123,8 +152,8 @@ export async function handleNodejsFunction({ const response = await callback.call(context, data); - if (handler.options?.hookResponse && !forceJsonResponse) { - const manipulatedResponse = await handler.options.hookResponse.call( + if (options?.hookResponse && !forceJsonResponse) { + const manipulatedResponse = await options.hookResponse.call( context, response ); diff --git a/packages/@next-fetch/react-query/src/index.ts b/packages/@next-fetch/react-query/src/index.ts index 85c9422..af246a3 100644 --- a/packages/@next-fetch/react-query/src/index.ts +++ b/packages/@next-fetch/react-query/src/index.ts @@ -1,21 +1,36 @@ import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query"; import type { Parser } from "@next-fetch/core-plugin/parser"; -import type { HandlerCallback } from "@next-fetch/core-plugin/server"; +import type { + HandlerCallback, + HookIntoResponse, +} from "@next-fetch/core-plugin/server"; import type { NextConfig } from "next"; import type { HookMetadata } from "@next-fetch/core-plugin/client"; import { createPlugin } from "@next-fetch/core-plugin"; +export function query( + callback: HandlerCallback, + options?: Partial> +): () => UseQueryResult; export function query( parser: Parser, - callback: HandlerCallback -): (v: Input) => UseQueryResult { + callback: HandlerCallback, + options?: Partial> +): (v: Input) => UseQueryResult; +export function query(): unknown { throw new Error("This code path should not be reached"); } +export function mutation( + callback: HandlerCallback, + options?: Partial> +): () => UseMutationResult & { meta: HookMetadata }; export function mutation( parser: Parser, - callback: HandlerCallback -): () => UseMutationResult & { meta: HookMetadata } { + callback: HandlerCallback, + options?: Partial> +): () => UseMutationResult & { meta: HookMetadata }; +export function mutation(): unknown { throw new Error("This code path should not be reached"); } diff --git a/packages/@next-fetch/swr/src/index.ts b/packages/@next-fetch/swr/src/index.ts index 1fb6df9..a1668f3 100644 --- a/packages/@next-fetch/swr/src/index.ts +++ b/packages/@next-fetch/swr/src/index.ts @@ -9,20 +9,38 @@ import type { import { createPlugin } from "@next-fetch/core-plugin"; import type { NextConfig } from "next"; +export function query( + callback: HandlerCallback, + options?: Partial> +): (() => SWRResponse) & { meta: HookMetadata }; export function query( parser: Parser, callback: HandlerCallback, options?: Partial> +): ((v: Input) => SWRResponse) & { meta: HookMetadata }; +export function query( + parser: unknown, + callback: unknown, + options?: unknown ): ((v: Input) => SWRResponse) & { meta: HookMetadata } { throw new Error("This code path should not be reached"); } export type MutationOptions = HookIntoResponse; +export function mutation( + callback: HandlerCallback, + options?: Partial> +): () => SWRMutationResponse & { meta: HookMetadata }; export function mutation( parser: Parser, callback: HandlerCallback, options?: Partial> +): () => SWRMutationResponse & { meta: HookMetadata }; +export function mutation( + parser: unknown, + callback: unknown, + options?: unknown ): (() => SWRMutationResponse & { meta: HookMetadata }) & { meta: HookMetadata; } { diff --git a/packages/example-app/pages/api/edge.swr.ts b/packages/example-app/pages/api/edge.swr.ts index 7354e76..d0edca0 100644 --- a/packages/example-app/pages/api/edge.swr.ts +++ b/packages/example-app/pages/api/edge.swr.ts @@ -33,3 +33,15 @@ export const useRuntimeInfoMutation = mutation( }, } ); + +export const useNoArgs = query(async () => `${EdgeRuntime}`); +export const useNoArgsMutation = query( + async function () { + return this.request.method; + }, + { + hookResponse(data) { + return new Response(`response is: ${JSON.stringify(data)}`); + }, + } +); diff --git a/packages/example-app/pages/api/people.swr.ts b/packages/example-app/pages/api/people.swr.ts index cfb1767..23dec1d 100644 --- a/packages/example-app/pages/api/people.swr.ts +++ b/packages/example-app/pages/api/people.swr.ts @@ -3,6 +3,19 @@ import { query, mutation } from "@next-fetch/swr"; import { userAgent } from "next/server"; export const useAllPeople = query( + async () => { + return `Many people are here!`; + }, + { + hookResponse(text) { + return new Response(text, { + headers: { "x-direct-request": "true" }, + }); + }, + } +); + +export const usePerson = query( z.object({ name: z.string() }), async (user) => { return `Hello, ${user.name} :D`; diff --git a/packages/example-app/pages/api/rq/edge.rq.ts b/packages/example-app/pages/api/rq/edge.rq.ts new file mode 100644 index 0000000..c743f5b --- /dev/null +++ b/packages/example-app/pages/api/rq/edge.rq.ts @@ -0,0 +1,47 @@ +import z from "zod"; +import { mutation, query } from "@next-fetch/react-query"; +import { userAgent } from "next/server"; + +export const config = { runtime: "experimental-edge" }; + +export const useRuntimeInfo = query( + z.object({ name: z.string() }), + async ({ name }) => { + return `${name}, EdgeRuntime = ${EdgeRuntime}`; + }, + { + hookResponse(text) { + return new Response(text, { + headers: { "x-direct-request": "true" }, + }); + }, + } +); + +export const useRuntimeInfoMutation = mutation( + z.object({ name: z.string() }), + async function ({ name }) { + return [ + `runtime: ${EdgeRuntime}`, + `input: ${name}`, + `request browser: ${userAgent(this.request).browser?.name}`, + ]; + }, + { + hookResponse(data) { + return new Response(`response is: ${JSON.stringify(data)}`); + }, + } +); + +export const useNoArgs = query(async () => `${EdgeRuntime}`); +export const useNoArgsMutation = query( + async function () { + return this.request.method; + }, + { + hookResponse(data) { + return new Response(`response is: ${JSON.stringify(data)}`); + }, + } +); diff --git a/packages/example-app/pages/api/rq/people.rq.ts b/packages/example-app/pages/api/rq/people.rq.ts index e838816..2f4c067 100644 --- a/packages/example-app/pages/api/rq/people.rq.ts +++ b/packages/example-app/pages/api/rq/people.rq.ts @@ -3,9 +3,29 @@ import { query, mutation } from "@next-fetch/react-query"; import { userAgent } from "next/server"; export const useAllPeople = query( + async () => { + return `Many people are here!`; + }, + { + hookResponse(text) { + return new Response(text, { + headers: { "x-direct-request": "true" }, + }); + }, + } +); + +export const usePerson = query( z.object({ name: z.string() }), async (user) => { return `Hello, ${user.name} :D`; + }, + { + hookResponse(text) { + return new Response(text, { + headers: { "x-direct-request": "true" }, + }); + }, } ); @@ -21,5 +41,10 @@ export const useListPeopleWith = mutation( "Alice", name.trim(), ]; + }, + { + hookResponse(data) { + return new Response(`response is: ${JSON.stringify(data)}`); + }, } ); diff --git a/packages/example-app/pages/edge.tsx b/packages/example-app/pages/edge.tsx index 58af537..717be14 100644 --- a/packages/example-app/pages/edge.tsx +++ b/packages/example-app/pages/edge.tsx @@ -1,17 +1,25 @@ -import { useRuntimeInfo } from "./api/edge.swr"; +import { useRuntimeInfo, useNoArgs } from "./api/edge.swr"; export const config = { runtime: "experimental-edge" }; export default function Home(props: { runtime: string }) { - const result = useRuntimeInfo({ name: "gal" }); + const runtimeInfo = useRuntimeInfo({ name: "gal" }); + const noArgs = useNoArgs(); return (
-
- {result.data - ? result.data - : result.error - ? String(result.error) +
+ {runtimeInfo.data + ? runtimeInfo.data + : runtimeInfo.error + ? String(runtimeInfo.error) + : "loading..."} +
+
+ {noArgs.data + ? noArgs.data + : noArgs.error + ? String(noArgs.error) : "loading..."}

Rendered on {props.runtime}

diff --git a/packages/example-app/pages/index.tsx b/packages/example-app/pages/index.tsx index 36c91fd..7b843c4 100644 --- a/packages/example-app/pages/index.tsx +++ b/packages/example-app/pages/index.tsx @@ -1,15 +1,23 @@ -import { useAllPeople } from "./api/people.swr"; +import { useAllPeople, usePerson } from "./api/people.swr"; export default function Home(props: { runtime: string }) { - const result = useAllPeople({ name: "gal" }); + const allPeople = useAllPeople(); + const singlePerson = usePerson({ name: "gal" }); return (
-
- {result.data - ? result.data - : result.error - ? String(result.error) +
+ {allPeople.data + ? allPeople.data + : allPeople.error + ? String(allPeople.error) + : "loading..."} +
+
+ {singlePerson.data + ? singlePerson.data + : singlePerson.error + ? String(singlePerson.error) : "loading..."}

Rendered on {props.runtime}

diff --git a/packages/example-app/pages/rq/form-edge.tsx b/packages/example-app/pages/rq/form-edge.tsx new file mode 100644 index 0000000..076f2a6 --- /dev/null +++ b/packages/example-app/pages/rq/form-edge.tsx @@ -0,0 +1,41 @@ +import { useRuntimeInfoMutation } from "../api/rq/edge.rq"; +import { Form } from "@next-fetch/react-query/form"; + +export const config = { runtime: "experimental-edge" }; + +export default function Page(props: { runtime: string }) { + const listPeopleWith = useRuntimeInfoMutation(); + + return ( +
+
+ + +
+ {!listPeopleWith.data ? ( +

+ No data, {listPeopleWith.isLoading ? "mutating" : "idle"}. +

+ ) : ( +
    + {listPeopleWith.data.map((name) => { + return
  • {name}
  • ; + })} +
+ )} +

Rendered on {props.runtime}

+
+ ); +} + +export const getServerSideProps = async () => { + return { + props: { + runtime: String(EdgeRuntime), + }, + }; +}; diff --git a/packages/example-app/pages/rq/index.tsx b/packages/example-app/pages/rq/index.tsx index 2ff2fb0..480ca1e 100644 --- a/packages/example-app/pages/rq/index.tsx +++ b/packages/example-app/pages/rq/index.tsx @@ -1,18 +1,26 @@ -import { useAllPeople } from "../api/rq/people.rq"; +import { useAllPeople, usePerson } from "../api/rq/people.rq"; export default function Home(props: { runtime: string }) { - const result = useAllPeople({ name: "gal" }); + const allPeople = useAllPeople(); + const singlePerson = usePerson({ name: "gal" }); return (
-
- {result.data - ? result.data - : result.error - ? String(result.error) +
+ {singlePerson.data + ? singlePerson.data + : singlePerson.error + ? String(singlePerson.error) : "loading..."}
-
{JSON.stringify(result.status)}
+
+ {allPeople.data + ? allPeople.data + : allPeople.error + ? String(allPeople.error) + : "loading..."} +
+
{JSON.stringify(singlePerson.status)}

Rendered on {props.runtime}

); diff --git a/packages/example-app/tests/e2e/mutation.spec.ts b/packages/example-app/tests/e2e/mutation.spec.ts index 350135e..b45b0c3 100644 --- a/packages/example-app/tests/e2e/mutation.spec.ts +++ b/packages/example-app/tests/e2e/mutation.spec.ts @@ -48,37 +48,75 @@ test.describe("without javascript enabled", () => { javaScriptEnabled: false, }); - test("nodejs runtime", async ({ page }) => { - await page.goto("/form"); - const result = page.locator("#result"); - await expect(result).toHaveText("No data, idle."); - - const input = page.locator(`input[type="text"]`); - await input.type("Gal"); - await input.press("Enter"); - - await expect(page.locator("body")).toHaveText( - "response is: " + - JSON.stringify(["Chrome", "John", "Jane", "Bob", "Alice", "Gal"]) - ); + test.describe("swr", () => { + test("nodejs runtime", async ({ page }) => { + await page.goto(`/form`); + const result = page.locator("#result"); + await expect(result).toHaveText("No data, idle."); + + const input = page.locator(`input[type="text"]`); + await input.type("Gal"); + await input.press("Enter"); + + await expect(page.locator("body")).toHaveText( + "response is: " + + JSON.stringify(["Chrome", "John", "Jane", "Bob", "Alice", "Gal"]) + ); + }); + + test("edge runtime", async ({ page }) => { + await page.goto(`/form-edge`); + const result = page.locator("#result"); + await expect(result).toHaveText("No data, idle."); + + const input = page.locator(`input[type="text"]`); + await input.type("Gal"); + await input.press("Enter"); + + await expect(page.locator("body")).toHaveText( + "response is: " + + JSON.stringify([ + "runtime: edge-runtime", + "input: Gal", + "request browser: Chrome", + ]) + ); + }); }); - test("edge runtime", async ({ page }) => { - await page.goto("/form-edge"); - const result = page.locator("#result"); - await expect(result).toHaveText("No data, idle."); - - const input = page.locator(`input[type="text"]`); - await input.type("Gal"); - await input.press("Enter"); - - await expect(page.locator("body")).toHaveText( - "response is: " + - JSON.stringify([ - "runtime: edge-runtime", - "input: Gal", - "request browser: Chrome", - ]) - ); + test.describe("react-query", () => { + test("nodejs runtime", async ({ page }) => { + await page.goto(`/rq/form`); + const result = page.locator("#result"); + await expect(result).toHaveText("No data, idle."); + + const input = page.locator(`input[type="text"]`); + await input.type("Gal"); + await input.press("Enter"); + + await expect(page.locator("body")).toHaveText( + "response is: " + + JSON.stringify(["Chrome", "John", "Jane", "Bob", "Alice", "Gal"]) + ); + }); + + test("edge runtime", async ({ page }) => { + await page.goto(`/rq/form-edge`); + const result = page.locator("#result"); + await expect(result).toHaveText("No data, idle."); + + const input = page.locator(`input[type="text"]`); + await input.type("Gal"); + await input.press("Enter"); + + await expect(page.locator("body")).toHaveText( + "response is: " + + JSON.stringify([ + "runtime: edge-runtime", + "input: Gal", + "request browser: Chrome", + ]) + ); + }); }); }); diff --git a/packages/example-app/tests/e2e/query.spec.ts b/packages/example-app/tests/e2e/query.spec.ts index 68094f2..429555f 100644 --- a/packages/example-app/tests/e2e/query.spec.ts +++ b/packages/example-app/tests/e2e/query.spec.ts @@ -2,14 +2,12 @@ import { test, expect } from "@playwright/test"; test("basic test", async ({ page }) => { await page.goto("/"); - const title = page.locator("#result"); - await expect(title).toHaveText("Hello, gal :D"); + await expect(page.locator("#singlePerson")).toHaveText("Hello, gal :D"); + await expect(page.locator("#allPeople")).toHaveText("Many people are here!"); }); test("direct request to node.js", async ({ page }) => { - const response = await page.goto( - "/api/people?__handler=useAllPeople&name=Gal" - ); + const response = await page.goto("/api/people?__handler=usePerson&name=Gal"); const text = await response?.text(); expect(text).toEqual("Hello, Gal :D"); await expect(response?.headerValue("x-direct-request")).resolves.toEqual( @@ -19,14 +17,16 @@ test("direct request to node.js", async ({ page }) => { test("edge runtime", async ({ page }) => { await page.goto("/edge"); - const title = page.locator("#result"); - await expect(title).toHaveText("gal, EdgeRuntime = edge-runtime"); + await expect(page.locator("#runtimeInfo")).toHaveText( + "gal, EdgeRuntime = edge-runtime" + ); + await expect(page.locator("#noArgs")).toHaveText("edge-runtime"); }); test("react query", async ({ page }) => { await page.goto("/rq"); - const title = page.locator("#result"); - await expect(title).toHaveText("Hello, gal :D"); + await expect(page.locator("#singlePerson")).toHaveText("Hello, gal :D"); + await expect(page.locator("#allPeople")).toHaveText("Many people are here!"); }); test("direct request to edge", async ({ page }) => {