diff --git a/README.md b/README.md index eb19b47..5acc2f3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a simple HTTP server for Motoko. Its interface is designed to be similar Check out the [examples](./examples) directory for examples of how to use this library. -Live http_greet example: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/]([https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/]) +Live http_greet example: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/](https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/) # Installation @@ -68,13 +68,33 @@ actor { } ``` +### Enabling CORS (Cross-Origin Resource Sharing) + +If you are building a web application where the frontend is served from a different origin than your canister, the browser will block `fetch` requests for security reasons. To allow your frontend to call your canister's API, you must enable CORS. + +This library provides a simple helper function to enable CORS for all routes. Call it right after initializing your server. + +```lua +// After initializing the server +server.enableCors( + // Allowed origins. Use "*" for public APIs, or a specific origin for production. + "*", + // Allowed HTTP methods. + "GET, POST, OPTIONS", + // Allowed request headers. + "Content-Type, Authorization" +); +``` + +This will automatically handle preflight `OPTIONS` requests and add the necessary `Access-Control-Allow-Origin` header to your responses. + ## Adding routes -As with the Express.js library, you can add routes to the server using the `get`, `post`, `put`, and `delete` functions. +As with the Express.js library, you can add routes to the server using the `get`, `post`, `put`, `delete`, and `options` functions. Each of these functions takes a path and a callback function. The callback function is called when a request is made to the server with a matching path. -The callback function takes a `Request` object and a `Response` object. The `Request` object contains information about the request, such as the request body, query parameters, and headers. The `Response` object is used to send a response to the client. +The callback function takes a `Request` object and a `ResponseClass` object. The `Request` object contains information about the request, such as the request body, query parameters, and headers. The `ResponseClass` object is used to send a response to the client. Here is an example of how to add a route to the server: @@ -105,6 +125,24 @@ server.get("/api", func (req : Request, res : ResponseClass) : async Response { }); ``` +While the `server.enableCors()` helper is recommended, you can also define manual `OPTIONS` routes for fine-grained control over specific endpoints. + +```lua +server.options("/my-custom-route", func (req : Request, res : ResponseClass) : async Response { + res.send({ + status_code = 204; // No Content + headers = [ + ("Access-Control-Allow-Origin", "https://my-frontend.com"), + ("Access-Control-Allow-Methods", "PUT, OPTIONS"), + ("Access-Control-Allow-Headers", "X-Custom-Header") + ]; + body = Blob.fromArray([]); + streaming_strategy = null; + cache_strategy = #noCache; + }); +}); +``` + ## Request and response objects The `Request` object contains information about the request, such as the request body, query parameters, and headers. @@ -147,7 +185,7 @@ For requests that are not cached, the server will upgrade the request to an upda See the `examples` directory for examples of how to use this library. These examples are also available on the Internet Computer as canisters: -- Http Greet: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/]([https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/]) +- Http Greet: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/](https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/) ## Roadmap @@ -175,7 +213,7 @@ Below are all of the types and functions that are exported by this library, as w - `type HttpRequest = Server.HttpRequest` - [./src/Server.mo](https://github.com/krpeacock/certified-cache/blob/c1f209d14f490f905b7de2a2bd3f917377310675/src/Http.mo#L36) - `type HttpResponse = Server.HttpResponse` - [./src/Server.mo](https://github.com/krpeacock/certified-cache/blob/c1f209d14f490f905b7de2a2bd3f917377310675/src/Http.mo#L28) - `type Request = Server.Request` - [./src/Server.mo](https://github.com/NatLabs/http-parser.mo/blob/27cba8ed0d39387e0fb660f65909ffe2a7d54413/src/Types.mo#L92) -- `type Response = Server.Response` - +- `type Response = Server.Response` - ``` { status_code : Nat16; @@ -189,15 +227,21 @@ Below are all of the types and functions that are exported by this library, as w ``` ( [(HttpRequest, (HttpResponse, Nat))], - [(AssetTypes.Key, Assets.StableAsset)], + [(AssetTypes.Key, Assets.StableAsset)], [Principal] ) ``` ### Classes -- Server - the primary export of this library -- ResponseClass - a class provided during `get`, `post`, `put`, and `delete`, with the following methods: +- **Server** - The primary export of this library. + - `get(path, handler)` + - `post(path, handler)` + - `put(path, handler)` + - `delete(path, handler)` + - `options(path, handler)` + - `enableCors(origin, methods, headers)` +- **ResponseClass** - A class provided to handlers with the following methods: - `send (Response) : async ()` - `json (Response) : async ()` @@ -212,4 +256,4 @@ These functions are used internally by the library, but are also exported for us - `public func encodeRequest(req : HttpRequest) : Blob` - Encodes a request as a blob - `public func yieldResponse(b : HttpResponse) : Blob` - - Encodes a response as a blob + - Encodes a response as a blob \ No newline at end of file diff --git a/examples/test/test.mo b/examples/test/test.mo index dc14469..4eb062e 100644 --- a/examples/test/test.mo +++ b/examples/test/test.mo @@ -18,6 +18,12 @@ shared ({ caller = creator }) actor class () { var server = Server.Server({ serializedEntries }); + server.enableCors( + "*", + "GET, POST, OPTIONS", + "Content-Type, Authorization", + ); + server.get( "/", func(_ : Request, res : ResponseClass) : async Response { diff --git a/src/lib.mo b/src/lib.mo index 6563950..0a568a8 100644 --- a/src/lib.mo +++ b/src/lib.mo @@ -83,6 +83,13 @@ module { public var authorized = cacheAuthorized; + // Store CORS configuration if enabled + var corsConfig : ?{ + origin : Text; + methods : Text; + headers : Text; + } = null; + let missingResponse : Response = { status_code = 404; headers = []; @@ -133,6 +140,9 @@ module { var deleteRequests = HashMap.StableHashMap(0, Text.equal, Text.hash); + // Add a map for OPTIONS requests + var optionsRequests = HashMap.StableHashMap(0, Text.equal, Text.hash); + /** * iterates through the request handlers and finds the first one that matches the path * @param path The path to match @@ -155,7 +165,7 @@ module { }; }; case (#err _) { - return null; + // This is not a match, continue to the next pattern }; }; }; @@ -198,6 +208,14 @@ module { case null {}; }; + // Check for a wildcard handler, useful for CORS + switch (map.get("*")) { + case (?f) { + return await f(req); + }; + case null {}; + }; + // Check for a fallback switch (fallback) { case (?f) { @@ -224,6 +242,9 @@ module { case "DELETE" { await handleFunction(deleteRequests, req, null); }; + case "OPTIONS" { + await handleFunction(optionsRequests, req, null); + }; case _ { missingResponse; }; @@ -302,6 +323,9 @@ module { case "DELETE" { deleteRequests.put(lowercaseUrl, function); }; + case "OPTIONS" { + optionsRequests.put(lowercaseUrl, function); + }; case _ {}; }; }; @@ -372,6 +396,50 @@ module { registerRequestWithHandler("DELETE", path, handler); }; + /** + * Register an OPTIONS request handler + * @param path The path to handle + * @param handler The function to handle the request + */ + public func options(path : Text, handler : (request : Request, response : ResponseClass) -> async Response) { + registerRequestWithHandler("OPTIONS", path, handler); + }; + + /** + * Enables CORS for all routes by setting up a default OPTIONS handler + * and adding the Access-Control-Allow-Origin header to all responses. + * @param origin The allowed origin (e.g., "*", "https://my-frontend.com") + * @param methods The allowed HTTP methods (e.g., "GET, POST, OPTIONS") + * @param allowedHeaders The allowed request headers (e.g., "Content-Type, Authorization") + */ + public func enableCors(origin : Text, methods : Text, allowedHeaders : Text) { + // 1. Store the config to add headers to all responses + corsConfig := ?{ + origin = origin; + methods = methods; + headers = allowedHeaders; + }; + + // 2. Register a global OPTIONS handler for preflight requests + options( + "*", + func(request : Request, response : ResponseClass) : async Response { + return response.send({ + status_code = 204; // No Content + headers = [ + ("Access-Control-Allow-Origin", origin), + ("Access-Control-Allow-Methods", methods), + ("Access-Control-Allow-Headers", allowedHeaders), + ("Access-Control-Max-Age", "86400") // Cache preflight for 1 day + ]; + body = Blob.fromArray([]); + streaming_strategy = null; + cache_strategy = #noCache; + }); + }, + ); + }; + public func entries() : SerializedEntries { let serializedAssets = assets.entries(); let (stableAssets, _) = serializedAssets; @@ -465,9 +533,22 @@ module { let response = await process_request(parsedrequest); + // Add CORS headers to the actual response if configured + var finalHeaders = response.headers; + switch (corsConfig) { + case (?config) { + // CRITICAL FIX: Only add the origin header to non-preflight requests. + // The OPTIONS handler already adds this header for its own responses. + if (parsedrequest.method != "OPTIONS") { + finalHeaders := joinArrays(finalHeaders, [("Access-Control-Allow-Origin", config.origin)]); + }; + }; + case null {}; + }; + let formattedResponse = { status_code = response.status_code; - headers = response.headers; + headers = finalHeaders; // Use potentially modified headers body = response.body; streaming_strategy = response.streaming_strategy; upgrade = null; diff --git a/test/server.test.js b/test/server.test.js index b0d252f..0239a23 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -180,6 +180,88 @@ describe(`compare with express`, () => { }); }); +describe(`CORS`, () => { + test(`should handle a preflight OPTIONS request correctly`, async () => { + const url = createUrl(`/json`); // The path doesn't matter much for our wildcard handler + + // Simulate a browser's preflight request for a POST with a custom header + const response = await fetch(url, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", // A different origin + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type, Authorization", + }, + }); + + // Preflight should return 204 No Content + expect(response.status).toBe(204); + + // It should not have a body + const text = await response.text(); + expect(text).toBe(``); + + // Check for the essential CORS headers + const headers = response.headers; + expect(headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(headers.get("Access-Control-Allow-Methods")).toContain("POST"); + expect(headers.get("Access-Control-Allow-Headers")).toContain( + "Authorization" + ); + }); + + test(`should include 'Access-Control-Allow-Origin' header on actual GET request`, async () => { + const url = createUrl(`/json`); + + // Simulate the actual GET request following a successful preflight + const response = await fetch(url, { + method: "GET", + headers: { + // The browser will include the Origin header on the actual request too + Origin: "http://localhost:5173", + }, + }); + + // The request should succeed + expect(response.status).toBe(200); + + // The response to the actual request MUST also include the ACAO header + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + + // Verify the body is correct, just to be sure + const json = await response.json(); + expect(json.hello).toBe("world"); + }); + + test(`should not send duplicate CORS headers (fixes the previous bug)`, async () => { + const url = createUrl(`/json`); + + // Make a preflight request + const optionsResponse = await fetch(url, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "GET", + }, + }); + + // The 'get' method on the Headers object returns a single value. + // If the header was sent as '*, *', this test would fail. + expect(optionsResponse.headers.get("Access-Control-Allow-Origin")).toBe( + "*" + ); + + // Make the actual request + const getResponse = await fetch(url, { + headers: { + Origin: "http://localhost:5173", + }, + }); + + expect(getResponse.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); + afterAll(() => { server.close(); });