Skip to content

Commit

Permalink
Merge pull request #80 from doseofted/support-rpc-prop-verbs
Browse files Browse the repository at this point in the history
Support new RPC keyword for idempotency
  • Loading branch information
doseofted authored Feb 25, 2024
2 parents 62679b3 + 3ccc4de commit 7888ad4
Show file tree
Hide file tree
Showing 21 changed files with 488 additions and 275 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-timers-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@doseofted/prim-rpc": minor
---

.methodsOnMethods option now requires an key/value object where the key is the method-on-method name and the value is either `true` or `"idempotent"` (similar to .allowList option)
5 changes: 5 additions & 0 deletions .changeset/wet-boxes-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@doseofted/prim-rpc": minor
---

RPC can no longer be made by GET requests by default: introduced new keyword for function's `.rpc` property named "idempotent" that, when used with HTTP plugins, allows RPC over GET requests
2 changes: 1 addition & 1 deletion apps/documentation/src/content/docs/learn/advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function markdownToHtml(markdownFile: File | string) {
const html = micromark(markdown)
return new File([html], "snippet.html", { type: "text/html" })
}
markdownToHtml.rpc = true
markdownToHtml.rpc = "idempotent"
```

</CodeFile>
Expand Down
43 changes: 43 additions & 0 deletions apps/documentation/src/content/docs/learn/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,49 @@ type SayHelloParams = Input<typeof sayHello.params>
Now we can be certain that not only will this function's arguments be validated but functions added in the future will
require validation through the `params` property.

## Limit RPC Access

By default, when Prim+RPC is used over a network, it only accepts requests over a POST request. However, you can still
make an RPC with a GET request using the special keyword `"idempotent"`.

However, you should take caution when doing so. We can demonstrate with an example. This is an idempotent function:

<CodeFile filename="server/module.ts">

```typescript
export function add(x: number, y: number) {
return x + y
}
add.rpc = "idempotent"
```

</CodeFile>

No matter how many times that we call this function, we will always get the same result. This can be exposed over a URL
(with a GET request) because simply visiting that URL won't change the state of the server. Here's a bad example:

<CodeFile filename="server/module.ts">

```typescript
const x = 0

export function add(y: number) {
return x + y
}
// NOTE: the property below should be set to `true` instead
add.rpc = "idempotent"
```

</CodeFile>

If we expose this function over a URL, then visiting that URL will change the state of the server which may not be
intended.

It's also important to note that GET requests are often logged so functions that use the `"idempotent"` keyword should
**never have sensitive arguments passed** to them. All functions where `.rpc = true` will be POST requests and cannot be
accessed with a GET request. All functions where `.rpc = "idempotent"` may be accessed from either a GET or POST
request.

## Selectively Import

Prim+RPC is very selective about what gets exposed from the server. Functions (and functions only) must be passed to the
Expand Down
40 changes: 35 additions & 5 deletions apps/documentation/src/content/docs/learn/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,20 +273,50 @@ export type Module = typeof module

</CodeFile>

We can now make requests to the server! We haven't set up the client yet but we can still try it out. Requests in
Prim+RPC are typically made over POST but for demo's sake:
We can now make requests to the server! We haven't set up the client yet but we can still try it out from the command line:

<CodeFile>

```zsh
curl \
--request POST \
--header "Content-Type: application/json" \
--data '{ "method": "sayHello", "args": ["Backend", "Terminal"] }' \
"http://localhost:3001/prim"
# {"result":"Backend, meet Terminal."}
```

</CodeFile>

In fact, we could even call this function over a GET request. We just need to modify out `.rpc` property to use a
special keyword: `"idempotent"`

<CodeFile filename="server/module.ts">

```typescript /"idempotent"/
export function sayHello(x = "Backend", y = "Frontend") {
return `${x}, meet ${y}.`
}
sayHello.rpc = "idempotent"
```

</CodeFile>

This will signal to Prim+RPC that it's safe to call from a URL. Now we can make a GET request to this function
or paste the link into a web browser:

<CodeFile>

```zsh
curl "http://localhost:3001/prim/sayHello?0=Backend&1=Terminal"
# {"result":"Backend, meet Terminal."}
```

</CodeFile>

We'll be calling this function from a web browser next. This means that we'll need to
[implement CORS](https://developer.mozilla.org/en-US/docs/Glossary/CORS) so that the client is allowed to call the
server. We can configure this in the Fetch handler's options:
Of course, this is not the primary way we'll be using Prim+RPC: we'll be setting up the client next. This means that
we'll need to [implement CORS](https://developer.mozilla.org/en-US/docs/Glossary/CORS) so that the client is allowed to
call the server. We can configure this in the Fetch handler's options:

<CodeFile filename="server/index.ts">

Expand Down
6 changes: 5 additions & 1 deletion apps/documentation/src/content/docs/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ one of two ways:
- Define a `.rpc` property on each of your functions with a value of `true`
- Or add your function to the [Allow List](#-allow-list) option

If your function may be accessed by visiting a URL, you may set the `.rpc` option to `"idempotent"`. If an HTTP method
handler has been configured, this will allow the function to be accessed by a GET request (by default, only POST
requests are allowed with a method handler).

## <Icon class="w-6 h-6" name="ph:brackets-curly-bold"/> HTTP Endpoint

<DataListOptions />
Expand Down Expand Up @@ -307,7 +311,7 @@ hello.documentation = () => "I say hello"

createPrimServer({
module: { hello },
methodsOnMethods: ["documentation"],
methodsOnMethods: { documentation: true },
})
```

Expand Down
2 changes: 2 additions & 0 deletions apps/documentation/src/content/docs/reference/create.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ Prim+RPC can understand.

- `server.prepareCall()` takes common server options and transforms it into an RPC object.
- `server.prepareRpc()` takes result of `server.prepareCall()` and creates an RPC result object from the function call.
It also takes an optional HTTP method argument if used over a network (if not used on a network, set this argument to
`false`).
- `server.prepareSend()` takes the result of `server.prepareRpc()` and serializes it into common server response
options.
- `server.call()` is a shortcut that calls all of the above methods in order. This is useful in HTTP/WebSocket server
Expand Down
4 changes: 2 additions & 2 deletions apps/documentation/src/content/plugins/server/express.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The Prim+RPC server can be configured with Express. First, create some module to
export function hello() {
return "Hi from Prim+RPC!"
}
hello.rpc = true
hello.rpc = "idempotent"

export default hello
```
Expand Down Expand Up @@ -75,7 +75,7 @@ Now we can test this out with a simple call from the command line:
<CodeFile>

```zsh
curl --request GET --url "http://localhost:3000/prim"
curl "http://localhost:3000/prim"
```

</CodeFile>
4 changes: 2 additions & 2 deletions apps/documentation/src/content/plugins/server/fastify.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The Prim+RPC server can be configured with Fastify. First, create some module to
export function hello() {
return "Hi from Prim+RPC!"
}
hello.rpc = true
hello.rpc = "idempotent"

export default hello
```
Expand Down Expand Up @@ -75,7 +75,7 @@ Now we can test this out with a simple call from the command line:
<CodeFile>

```zsh
curl --request GET --url "http://localhost:3000/prim"
curl "http://localhost:3000/prim"
```

</CodeFile>
6 changes: 3 additions & 3 deletions apps/documentation/src/content/plugins/server/h3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ naming patterns. We'll cover all frameworks separately to reduce confusion.
export function hello() {
return "Hi from Prim+RPC!"
}
hello.rpc = true
hello.rpc = "idempotent"

export default hello
```
Expand Down Expand Up @@ -91,7 +91,7 @@ We can test this out by issuing a new request from the command line:
<CodeFile>

```zsh
curl --request GET --url "http://localhost:3000/prim"
curl "http://localhost:3000/prim"
```

</CodeFile>
Expand Down Expand Up @@ -137,7 +137,7 @@ We can test this out by issuing a new request from the command line (change port
<CodeFile>

```zsh
curl --request GET --url "http://localhost:3000/prim"
curl "http://localhost:3000/prim"
```

</CodeFile>
Expand Down
4 changes: 2 additions & 2 deletions apps/documentation/src/content/plugins/server/hono.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Prim+RPC supports Hono and is very easy to set up. First, let's set up our funct
export function hello() {
return "Hi from Prim+RPC!"
}
hello.rpc = true
hello.rpc = "idempotent"

export default hello
```
Expand Down Expand Up @@ -56,7 +56,7 @@ to be changed):
<CodeFile>

```zsh
curl --request GET --url "http://localhost:3000/prim"
curl "http://localhost:3000/prim"
```

</CodeFile>
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ First, you'll need some module with functions that you'd like to expose to the c
export function hello() {
return "Hi from Prim+RPC!"
}
hello.rpc = true
hello.rpc = "idempotent"

export default hello
```
Expand Down Expand Up @@ -125,7 +125,7 @@ your Prim+RPC server. You can test it out like so:

```zsh
# Remember to change the address depending on your server
curl --request GET --url "http://localhost:3000/prim"
curl "http://localhost:3000/prim"
```

</CodeFile>
4 changes: 2 additions & 2 deletions libs/example/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function sayHello(options?: Greeting) {
const { greeting, name } = options ?? {}
return `${greeting ?? "Hello"} ${name ?? "you"}!`
}
sayHello.rpc = true
sayHello.rpc = "idempotent"

/**
* An alternative to `sayHello` that uses positional arguments.
Expand Down Expand Up @@ -291,7 +291,7 @@ export async function makeItATextFile(text: string) {
const { File } = await import("node:buffer")
return new File([text], "text.txt", { type: "text/plain" })
}
makeItATextFile.rpc = true
makeItATextFile.rpc = "idempotent" // I mean in a way, server resources aren't changed, maybe I need another keyword

/**
* Make an introduction.
Expand Down
2 changes: 1 addition & 1 deletion libs/plugins/src/server/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("Hono plugin is functional as Prim Plugin", () => {
const expected = { id: 1, result: module.sayHello(args) }
test("registered as Prim Plugin", async () => {
const response = await request(server)
.post("/prim/sayHello")
.post("/prim")
.send({
method: "sayHello",
args,
Expand Down
1 change: 1 addition & 0 deletions libs/plugins/src/server/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function honoPrimRpc(options: PrimHonoPluginOptions) {
const formData = await req.formData()
for (const [key, value] of formData) {
if (key === "rpc") {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
body = value instanceof Blob && jsonHandler.binary ? await value.arrayBuffer() : value.toString()
} else if (key.startsWith("_bin_") && value instanceof Blob) {
blobs[key] = value
Expand Down
41 changes: 41 additions & 0 deletions libs/rpc/src/allow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Check that a function can be executed as RPC based on the given `.rpc` property. The `.rpc` property can be retrieved
* from a function by calling `getFunctionRpcProperty()`.
*
* This compares the given `rpcSpecifier` with given request options (currently, only the `httpMethod`).
*
* By default, all requests sent over a network to Prim+RPC are POST-like:
*
* - When `.rpc` is `true`, the HTTP method must be `"POST"` when applicable or `false` (inapplicable, i.e. IPC)
* - When `.rpc` is `"idempotent"` (a special keyword), the HTTP method may also be `"GET"`
*
* @param rpcSpecifier The value of a function's `.rpc` property
* @param httpMethod The optional HTTP method used in a request
* @returns whether function can be called based on given RPC property and given request options
*/
export function checkRpcIdentifier(rpcSpecifier: string | boolean, httpMethod: string | false) {
const postMethodAllowed =
typeof rpcSpecifier === "boolean" && rpcSpecifier && (httpMethod === "POST" || httpMethod === false)
if (postMethodAllowed) return postMethodAllowed
const getMethodAllowed =
typeof rpcSpecifier === "string" &&
rpcSpecifier === "idempotent" &&
(typeof httpMethod === "string" ? ["GET", "POST"].includes(httpMethod) : httpMethod === false)
if (getMethodAllowed) return getMethodAllowed
return false
}

/**
* Given a function, grab the `.rpc` property. **This does not check that the `.rpc` property is valid** only that it
* exists and it is the expected scalar type.
*
* @param possibleFunction A possible function given on a module
*/
export function getFunctionRpcProperty(possibleFunction?: unknown) {
return (
typeof possibleFunction === "function" &&
"rpc" in possibleFunction &&
(typeof possibleFunction.rpc === "string" || typeof possibleFunction.rpc === "boolean") &&
possibleFunction.rpc
)
}
10 changes: 6 additions & 4 deletions libs/rpc/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,21 @@ describe("Prim Client cannot call non-RPC", () => {
const functionCall = () => prim.definitelyNotRpc()
await expect(functionCall()).rejects.toThrow("Method was not allowed")
})
test("with method on method that's not allowed", async () => {
test("with methods on methods that are not allowed", async () => {
const { callbackPlugin, methodPlugin, callbackHandler, methodHandler } = createPrimTestingPlugins()
// We pass some made-up method-on-method name because if none are given then no methods on methods are allowed
// and I need to test to make sure that the allow list is working
createPrimServer({ module, callbackHandler, methodHandler, methodsOnMethods: ["somethingMadeUp"] })
createPrimServer({ module, callbackHandler, methodHandler, methodsOnMethods: { somethingMadeUp: true } })
const prim = createPrimClient<IModule>({ callbackPlugin, methodPlugin })
// below is not valid because it's in Prim RPC's deny list
const functionCall1 = () => prim.greetings.toString()
await expect(functionCall1()).rejects.toThrow("Method was not valid")
// this is not valid because, while the method-on-method is allowed, it's called on the prototype
const functionCall2 = () => prim.lookAtThisMess.prototype.somethingMadeUp()
await expect(functionCall2()).rejects.toThrow("Method was not valid")
// this is valid because the method-on-method is allowed and is not defined on the prototype
const functionCall5 = () => prim.lookAtThisMess.somethingMadeUp()
await expect(functionCall5()).resolves.toEqual("Maybe we'll allow it")
// below is not valid because "docs" method-on-method is not in the allowed list
const functionCall3 = () => prim.lookAtThisMess.docs()
await expect(functionCall3()).rejects.toThrow("Method on method was not allowed")
Expand Down Expand Up @@ -126,7 +129,7 @@ describe("Prim Client can call methods with positional parameters", () => {
test("Prim Client can call allowed methods on methods", async () => {
const { callbackPlugin, methodPlugin, callbackHandler, methodHandler } = createPrimTestingPlugins()
// JSON handler is only useful with remote source (no local source test needed)
createPrimServer({ module, callbackHandler, methodHandler, methodsOnMethods: ["docs"] })
createPrimServer({ module, callbackHandler, methodHandler, methodsOnMethods: { docs: true } })
const prim = createPrimClient<IModule>({ callbackPlugin, methodPlugin })
const expected = module.lookAtThisMess.docs()
const result = await prim.lookAtThisMess.docs()
Expand Down Expand Up @@ -331,7 +334,6 @@ test("Prim Client knows when an experimental flag is disabled", async () => {
const expected = module.promisesUnwrapped(10)
// FIXME: callback is needed to use callback plugin (otherwise method plugin is used which doesn't support promises)
const result = await prim.promisesUnwrapped(10, () => "uh oh")
console.log("expected", result)
expect(result.hi).toEqual(expected.hi)
expect(result.date).toBeInstanceOf(Date)
// NOTE: without promise support (flag disabled), promises in the result are stringified into empty object
Expand Down
Loading

0 comments on commit 7888ad4

Please sign in to comment.