diff --git a/examples/app-router/app/methods/get/dynamic-segments/[slug]/route.ts b/examples/app-router/app/methods/get/dynamic-segments/[slug]/route.ts new file mode 100644 index 000000000..bc1104d45 --- /dev/null +++ b/examples/app-router/app/methods/get/dynamic-segments/[slug]/route.ts @@ -0,0 +1,7 @@ +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + return Response.json({ slug }); +} diff --git a/examples/app-router/app/methods/get/query/route.ts b/examples/app-router/app/methods/get/query/route.ts new file mode 100644 index 000000000..6e9292beb --- /dev/null +++ b/examples/app-router/app/methods/get/query/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from "next/server"; + +export function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("query"); + if (query === "OpenNext is awesome!") { + return Response.json({ query }); + } + return new Response("Internal Server Error", { status: 500 }); +} diff --git a/examples/app-router/app/methods/get/redirect/route.ts b/examples/app-router/app/methods/get/redirect/route.ts new file mode 100644 index 000000000..3781f3374 --- /dev/null +++ b/examples/app-router/app/methods/get/redirect/route.ts @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export async function GET(request: Request) { + redirect("https://nextjs.org/"); +} diff --git a/examples/app-router/app/methods/get/revalidate/route.ts b/examples/app-router/app/methods/get/revalidate/route.ts new file mode 100644 index 000000000..833cf90ab --- /dev/null +++ b/examples/app-router/app/methods/get/revalidate/route.ts @@ -0,0 +1,10 @@ +export const revalidate = 5; + +async function getTime() { + return new Date().toISOString(); +} + +export async function GET() { + const time = await getTime(); + return Response.json({ time }); +} diff --git a/examples/app-router/app/methods/get/static/route.ts b/examples/app-router/app/methods/get/static/route.ts new file mode 100644 index 000000000..b3bfa223c --- /dev/null +++ b/examples/app-router/app/methods/get/static/route.ts @@ -0,0 +1,10 @@ +export const dynamic = "force-static"; + +async function getTime() { + return new Date().toISOString(); +} + +export async function GET() { + const time = await getTime(); + return Response.json({ time }); +} diff --git a/examples/app-router/app/methods/post/cookies/route.ts b/examples/app-router/app/methods/post/cookies/route.ts new file mode 100644 index 000000000..9d469dbb7 --- /dev/null +++ b/examples/app-router/app/methods/post/cookies/route.ts @@ -0,0 +1,19 @@ +import { cookies } from "next/headers"; + +export async function POST(request: Request) { + const formData = await request.formData(); + const username = formData.get("username"); + const password = formData.get("password"); + if (username === "hakuna" && password === "matata") { + (await cookies()).set("auth_session", "SUPER_SECRET_SESSION_ID_1234"); + return Response.json( + { + message: "ok", + }, + { + status: 202, + }, + ); + } + return Response.json({ message: "you must login" }, { status: 401 }); +} diff --git a/examples/app-router/app/methods/post/formdata/route.ts b/examples/app-router/app/methods/post/formdata/route.ts new file mode 100644 index 000000000..6db5dd97a --- /dev/null +++ b/examples/app-router/app/methods/post/formdata/route.ts @@ -0,0 +1,16 @@ +export async function POST(request: Request) { + const formData = await request.formData(); + const name = formData.get("name"); + const email = formData.get("email"); + if (name === "OpenNext [] () %&#!%$#" && email === "opennext@opennext.com") { + return Response.json( + { + message: "ok", + }, + { + status: 202, + }, + ); + } + return Response.json({ message: "forbidden" }, { status: 403 }); +} diff --git a/examples/app-router/app/methods/route.ts b/examples/app-router/app/methods/route.ts new file mode 100644 index 000000000..a1831930d --- /dev/null +++ b/examples/app-router/app/methods/route.ts @@ -0,0 +1,76 @@ +import type { NextRequest } from "next/server"; + +export async function GET() { + return Response.json({ + message: "OpenNext is awesome! :) :] :> :D", + }); +} + +export async function POST(request: Request) { + const text = await request.text(); + if (text === "OpenNext is awesome! :] :) :> :D") { + return Response.json( + { + message: "ok", + }, + { + status: 202, + }, + ); + } + return Response.json({ message: "forbidden" }, { status: 403 }); +} + +export async function PUT(request: Request) { + const res = (await request.json()) as { + message: string; + }; + if (res.message === "OpenNext PUT") { + return Response.json({ message: "ok" }, { status: 201 }); + } + return Response.json({ message: "error" }, { status: 500 }); +} + +export async function PATCH(request: Request) { + const res = (await request.json()) as { + message: string; + }; + if (res.message === "OpenNext PATCH") { + return Response.json( + { message: "ok", modified: true, timestamp: new Date().toISOString() }, + { status: 202 }, + ); + } + return Response.json({ message: "error" }, { status: 500 }); +} + +export async function DELETE(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const command = searchParams.get("command"); + if (command === "rm -rf / --no-preserve-root") { + return new Response(null, { status: 204 }); + } + return Response.json({ message: "error" }, { status: 500 }); +} + +export async function HEAD() { + return new Response("hello", { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + // Once deployed to AWS this will always be 0 + // "content-length": "1234567", + "special-header": "OpenNext is the best :) :] :> :D", + }, + }); +} + +export async function OPTIONS() { + return new Response(null, { + status: 204, + headers: { + Allow: "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, LOVE", + Special: "OpenNext is the best :) :] :> :D", + }, + }); +} diff --git a/packages/tests-e2e/tests/appRouter/methods.test.ts b/packages/tests-e2e/tests/appRouter/methods.test.ts new file mode 100644 index 000000000..4d9c4f492 --- /dev/null +++ b/packages/tests-e2e/tests/appRouter/methods.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "@playwright/test"; + +test.describe("all supported methods should work in route handlers", () => { + test("GET", async ({ request }) => { + const getRes = await request.get("/methods"); + const getData = await getRes.json(); + expect(getRes.status()).toEqual(200); + expect(getData.message).toEqual("OpenNext is awesome! :) :] :> :D"); + }); + + test("POST", async ({ request }) => { + const postRes = await request.post("/methods", { + headers: { + "Content-Type": "text/plain", + }, + data: "OpenNext is awesome! :] :) :> :D", + }); + expect(postRes.status()).toBe(202); + const postData = await postRes.json(); + expect(postData.message).toBe("ok"); + const errorPostRes = await request.post("/methods", { + headers: { + "Content-Type": "text/plain", + }, + data: "OpenNext is not awesome! :C", + }); + expect(errorPostRes.status()).toBe(403); + const errorData = await errorPostRes.json(); + expect(errorData.message).toBe("forbidden"); + }); + + test("PUT", async ({ request }) => { + const putRes = await request.put("/methods", { + data: { + message: "OpenNext PUT", + }, + }); + expect(putRes.status()).toEqual(201); + const putData = await putRes.json(); + expect(putData.message).toEqual("ok"); + }); + + test("PATCH", async ({ request }) => { + const timestampBefore = new Date(); + const patchRes = await request.patch("/methods", { + data: { message: "OpenNext PATCH" }, + }); + expect(patchRes.status()).toEqual(202); + const patchData = await patchRes.json(); + expect(patchData.message).toEqual("ok"); + expect(patchData.modified).toEqual(true); + expect(Date.parse(patchData.timestamp)).toBeGreaterThan( + timestampBefore.getTime(), + ); + }); + + test("DELETE", async ({ request }) => { + const deleteRes = await request.delete("/methods", { + params: { + command: "rm -rf / --no-preserve-root", + }, + }); + expect(deleteRes.status()).toEqual(204); + }); + + test("HEAD", async ({ request }) => { + const headRes = await request.head("/methods"); + expect(headRes.status()).toEqual(200); + const headers = headRes.headers(); + expect(headers["content-type"]).toEqual("text/html; charset=utf-8"); + // expect(headers["content-length"]).toEqual("1234567"); + expect(headers["special-header"]).toEqual( + "OpenNext is the best :) :] :> :D", + ); + }); + + test("OPTIONS", async ({ request }) => { + const optionsRes = await request.fetch("/methods", { + method: "OPTIONS", + }); + expect(optionsRes.status()).toEqual(204); + const headers = optionsRes.headers(); + expect(headers.allow).toBe( + "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, LOVE", + ); + expect(headers.special).toBe("OpenNext is the best :) :] :> :D"); + }); +}); + +test("formData should work in POST route handler", async ({ request }) => { + const formData = new FormData(); + formData.append("name", "OpenNext [] () %&#!%$#"); + formData.append("email", "opennext@opennext.com"); + const postRes = await request.post("/methods/post/formdata", { + form: formData, + }); + expect(postRes.status()).toBe(202); + const postData = await postRes.json(); + expect(postData.message).toBe("ok"); +}); + +test("revalidate should work in GET route handler", async ({ + request, + page, +}) => { + let time = Date.parse( + (await request.get("/methods/get/revalidate").then((res) => res.json())) + .time, + ); + let newTime: number; + let tempTime = time; + do { + await page.waitForTimeout(1000); + time = tempTime; + const newTimeRes = await request.get("/methods/get/revalidate"); + newTime = Date.parse((await newTimeRes.json()).time); + tempTime = newTime; + } while (time !== newTime); + const midTime = Date.parse( + (await request.get("/methods/get/revalidate").then((res) => res.json())) + .time, + ); + + await page.waitForTimeout(1000); + // Expect that the time is still stale + expect(midTime).toEqual(newTime); + + // Wait 5 + 1 seconds for ISR to regenerate time + await page.waitForTimeout(6000); + let finalTime = newTime; + do { + await page.waitForTimeout(2000); + finalTime = Date.parse( + (await request.get("/methods/get/revalidate").then((res) => res.json())) + .time, + ); + } while (newTime === finalTime); + + expect(newTime).not.toEqual(finalTime); +}); + +test("should cache a static GET route", async ({ request }) => { + const res = await request.get("/methods/get/static"); + expect(res.headers()["cache-control"]).toBe("s-maxage=31536000,"); +}); + +test("should be able to set cookies in route handler", async ({ request }) => { + const postRes = await request.post("/methods/post/cookies", { + form: { + username: "hakuna", + password: "matata", + }, + }); + expect(postRes.status()).toBe(202); + const postData = await postRes.json(); + expect(postData.message).toBe("ok"); + const cookies = postRes.headers()["set-cookie"]; + expect(cookies).toContain("auth_session=SUPER_SECRET_SESSION_ID_1234"); +}); + +test("should be able to redirect in route handler", async ({ request }) => { + const redirectRes = await request.get("/methods/get/redirect", { + // Disable auto-redirect to check initial response + maxRedirects: 0, + }); + expect(redirectRes.status()).toBe(307); + expect(redirectRes.headers().location).toBe("https://nextjs.org/"); + + // Check if the redirect works + const followedRes = await request.get("/methods/get/redirect"); + expect(followedRes.url()).toBe("https://nextjs.org/"); +}); + +test("dynamic segments should work in route handlers", async ({ request }) => { + const res = await request.get("/methods/get/dynamic-segments/this-is-a-slug"); + const data = await res.json(); + expect(data.slug).toBe("this-is-a-slug"); +}); + +test("query parameters should work in route handlers", async ({ request }) => { + const res = await request.get("/methods/get/query", { + params: { + query: "OpenNext is awesome!", + }, + }); + const data = await res.json(); + expect(data.query).toBe("OpenNext is awesome!"); +});