Skip to content

Commit

Permalink
added CORS helper w tests
Browse files Browse the repository at this point in the history
  • Loading branch information
tomredman committed Jul 30, 2024
1 parent 3b42a3e commit e5b9e88
Show file tree
Hide file tree
Showing 13 changed files with 935 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ find the npm package in [./packages/convex-helpers](./packages/convex-helpers).
| [Filter db queries with JS](./packages/convex-helpers/README.md#filter)
| [Manual pagination](./packages/convex-helpers/README.md#manual-pagination)
| [Query caching with ConvexQueryCacheProvider](./packages/convex-helpers/README.md#query-caching)
| [HttpRouter with automatic CORS support](./packages/convex-helpers/README.md#cors)

| In this directory for copy-pasting:
| -----------------------------------
Expand Down
90 changes: 90 additions & 0 deletions convex/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Welcome to your Convex functions directory!

Write your Convex functions here.
See https://docs.convex.dev/functions for more.

A query function that takes two arguments looks like:

```ts
// functions.js
import { query } from "./_generated/server";
import { v } from "convex/values";

export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},

// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();

// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);

// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```

Using this query function in a React component looks like:

```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
});
```

A mutation function looks like:

```ts
// functions.js
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},

// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);

// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```

Using this mutation function in a React component looks like:

```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
}
```

Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.
2 changes: 1 addition & 1 deletion convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* Generated by [email protected].0.
* Generated by [email protected].2.
* To regenerate, run `npx convex dev`.
* @module
*/
Expand Down
2 changes: 1 addition & 1 deletion convex/_generated/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* Generated by [email protected].0.
* Generated by [email protected].2.
* To regenerate, run `npx convex dev`.
* @module
*/
Expand Down
2 changes: 1 addition & 1 deletion convex/_generated/dataModel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* Generated by [email protected].0.
* Generated by [email protected].2.
* To regenerate, run `npx convex dev`.
* @module
*/
Expand Down
2 changes: 1 addition & 1 deletion convex/_generated/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* Generated by [email protected].0.
* Generated by [email protected].2.
* To regenerate, run `npx convex dev`.
* @module
*/
Expand Down
2 changes: 1 addition & 1 deletion convex/_generated/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* Generated by [email protected].0.
* Generated by [email protected].2.
* To regenerate, run `npx convex dev`.
* @module
*/
Expand Down
143 changes: 143 additions & 0 deletions convex/corsHttpRouterExample.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { convexTest } from "convex-test";
import { expect, test, describe, beforeAll, afterAll } from "vitest";

beforeAll(() => {
//setup
});

afterAll(() => {
//teardown
});

describe("HTTP routes", () => {
const expectedHeaders = ({ method }: { method: string }) => {
return {
"access-control-allow-headers": "Content-Type",
"access-control-allow-methods": `${method}`,
"access-control-allow-origin": "*",
"access-control-max-age": "86400",
"content-type": "application/json",
};
};

const verifyHeaders = (method: string, headers: Headers) => {
expect(headers.get("access-control-allow-headers")).toBe(
expectedHeaders({ method })["access-control-allow-headers"],
);
expect(headers.get("access-control-allow-methods")).toBe(
expectedHeaders({ method })["access-control-allow-methods"],
);
expect(headers.get("access-control-allow-origin")).toBe(
expectedHeaders({ method })["access-control-allow-origin"],
);
expect(headers.get("access-control-max-age")).toBe(
expectedHeaders({ method })["access-control-max-age"],
);
expect(headers.get("content-type")).toBe(
expectedHeaders({ method })["content-type"],
);
};

test("GET /fact", async () => {
const t = convexTest();
const response = await t.fetch("/fact", { method: "GET" });
expect(response.status).toBe(200);
verifyHeaders("GET", response.headers);
const body = await response.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0]).toHaveProperty("fact");
expect(typeof body[0].fact).toBe("string");
expect(body[0].fact).toBe("Hello, world!");
});

test("POST /fact", async () => {
const t = convexTest();
const response = await t.fetch("/fact", {
method: "POST",
});
verifyHeaders("POST", response.headers);
const body = await response.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0]).toHaveProperty("fact");
expect(typeof body[0].fact).toBe("string");
expect(body[0].fact).toBe("Hello, world!");
});

test("GET /dynamicFact/123", async () => {
const t = convexTest();
const response = await t.fetch("/dynamicFact/123", { method: "GET" });
expect(response.status).toBe(200);
verifyHeaders("GET", response.headers);
const body = await response.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0]).toHaveProperty("fact");
expect(typeof body[0].fact).toBe("string");
expect(body[0].fact).toBe("Hello, world!");
});

test("PATCH /dynamicFact/123", async () => {
const t = convexTest();
const response = await t.fetch("/dynamicFact/123", { method: "PATCH" });
expect(response.status).toBe(200);
verifyHeaders("PATCH", response.headers);
const body = await response.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0]).toHaveProperty("fact");
expect(typeof body[0].fact).toBe("string");
expect(body[0].fact).toBe("Hello, world!");
});

test("OPTIONS /fact (CORS preflight)", async () => {
const t = convexTest();
const response = await t.fetch("/fact", { method: "OPTIONS" });
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toContain(
"GET",
);
expect(response.headers.get("Access-Control-Allow-Methods")).toContain(
"POST",
);
expect(response.headers.get("Access-Control-Allow-Methods")).toContain(
"PATCH",
);
expect(response.headers.get("Access-Control-Allow-Methods")).toContain(
"DELETE",
);
});

test("Route with custom allowedOrigins", async () => {
const t = convexTest();
const response = await t.fetch("/specialRouteOnlyForThisOrigin", {
method: "GET",
});
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"http://localhost:3000",
);
const body = await response.json();
expect(body).toEqual({ message: "Custom allowed origins! Wow!" });
});

test("OPTIONS for route with custom allowedOrigins", async () => {
const t = convexTest();
const response = await t.fetch("/specialRouteOnlyForThisOrigin", {
method: "OPTIONS",
});
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"http://localhost:3000",
);
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET");
});

test("Non-existent route", async () => {
const t = convexTest();
const response = await t.fetch("/nonexistent", { method: "GET" });
expect(response.status).toBe(404);
});
});
109 changes: 109 additions & 0 deletions convex/corsHttpRouterExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { corsHttpRouter } from "../packages/convex-helpers/server/corsHttpRouter";
import { httpAction } from "./_generated/server";

const everythingHandler = httpAction(async () => {
return new Response(JSON.stringify([{ fact: "Hello, world!" }]));
});

const http = corsHttpRouter({
allowedOrigins: ["*"],
});

/**
* Exact routes will match /fact exactly
*/
http.corsRoute({
path: "/fact",
method: "GET",
handler: everythingHandler,
});

http.corsRoute({
path: "/fact",
method: "POST",
handler: everythingHandler,
});

http.corsRoute({
path: "/fact",
method: "PATCH",
handler: everythingHandler,
});

http.corsRoute({
path: "/fact",
method: "DELETE",
handler: everythingHandler,
});

/**
* Non-CORS routes
*/
http.route({
path: "/nocors/fact",
method: "GET",
handler: everythingHandler,
});

http.route({
path: "/nocors/fact",
method: "POST",
handler: everythingHandler,
});

/**
* Prefix routes will match /dynamicFact/123 and /dynamicFact/456 etc.
*/
http.corsRoute({
pathPrefix: "/dynamicFact/",
method: "GET",
handler: everythingHandler,
});

http.corsRoute({
pathPrefix: "/dynamicFact/",
method: "PATCH",
handler: everythingHandler,
});

/**
* Per-path "allowedOrigins" will override the default "allowedOrigins" for that route
*/
http.corsRoute({
path: "/specialRouteOnlyForThisOrigin",
method: "GET",
handler: httpAction(async () => {
return new Response(
JSON.stringify({ message: "Custom allowed origins! Wow!" }),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
}),
allowedOrigins: ["http://localhost:3000"],
});

/**
* Disable CORS for this route
*/
http.route({
path: "/routeWithoutCors",
method: "GET",
handler: httpAction(async () => {
return new Response(
JSON.stringify({ message: "No CORS allowed here, pal." }),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
}),
});

// Convex expects the router to be the default export of `convex/http.js`.
export default http;
Loading

0 comments on commit e5b9e88

Please sign in to comment.