Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
62 changes: 53 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -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 ()`

Expand All @@ -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
6 changes: 6 additions & 0 deletions examples/test/test.mo
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
85 changes: 83 additions & 2 deletions src/lib.mo
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -133,6 +140,9 @@ module {

var deleteRequests = HashMap.StableHashMap<Text, HttpFunction>(0, Text.equal, Text.hash);

// Add a map for OPTIONS requests
var optionsRequests = HashMap.StableHashMap<Text, HttpFunction>(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
Expand All @@ -155,7 +165,7 @@ module {
};
};
case (#err _) {
return null;
// This is not a match, continue to the next pattern
};
};
};
Expand Down Expand Up @@ -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) {
Expand All @@ -224,6 +242,9 @@ module {
case "DELETE" {
await handleFunction(deleteRequests, req, null);
};
case "OPTIONS" {
await handleFunction(optionsRequests, req, null);
};
case _ {
missingResponse;
};
Expand Down Expand Up @@ -302,6 +323,9 @@ module {
case "DELETE" {
deleteRequests.put(lowercaseUrl, function);
};
case "OPTIONS" {
optionsRequests.put(lowercaseUrl, function);
};
case _ {};
};
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading