Skip to content

Add ingress client #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 20, 2024
Merged
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
88 changes: 88 additions & 0 deletions examples/ingress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2023-2023 - Restate Software, Inc., Restate GmbH
*
* This file is part of the Restate SDK for Node.js/TypeScript,
* which is released under the MIT license.
*
* You can find a copy of the license in file LICENSE in the root
* directory of this repository or package, or at
* https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
*/

/* eslint-disable no-console */

import * as restate from "../src/public_api";
import type { CounterObject, GreeterService } from "./example";

const Greeter: GreeterService = { path: "greeter" };
const Counter: CounterObject = { path: "counter" };

const ingress = restate.ingress.connect({ url: "http://localhost:8080" });

const simpleCall = async (name: string) => {
const greeter = ingress.service(Greeter);
const greeting = await greeter.greet(name);

console.log(greeting);
};

const objectCall = async (name: string) => {
const couter = ingress.object(Counter, name);
const count = await couter.count();

console.log(`The count for ${name} is ${count}`);
};

const idempotentCall = async (name: string, idempotencyKey: string) => {
const greeter = ingress.service(Greeter);

// send the request with the idempotent key, and ask restate
// to remember that key for 3 seconds.
const greeting = await greeter.greet(
name,
restate.ingress.Opts.from({ idempotencyKey, retain: 3 })
);

console.log(greeting);
};

const customHeadersCall = async (name: string) => {
const greeter = ingress.service(Greeter);

const greeting = await greeter.greet(
name,
restate.ingress.Opts.from({ headers: { "x-bob": "1234" } })
);

console.log(greeting);
};

const globalCustomHeaders = async (name: string) => {
const ingress = restate.ingress.connect({
url: "http://localhost:8080",
headers: { Authorization: "Bearer mytoken123" },
});

const greeting = await ingress.service(Greeter).greet(name);

console.log(greeting);
};

// Before running this example, make sure
// to run and register `greeter` and `counter` services.
//
// to run them, run:
//
// 1. npm run example
// 2. make sure that restate is running
// 3. restate deployment add localhost:9080

Promise.resolve()
.then(() => simpleCall("bob"))
.then(() => objectCall("bob"))
.then(() => objectCall("mop"))
.then(() => idempotentCall("joe", "idemp-1"))
.then(() => idempotentCall("joe", "idemp-1"))
.then(() => customHeadersCall("bob"))
.then(() => globalCustomHeaders("bob"))
.catch((e) => console.error(e));
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"verify": "npm run format-check && npm run lint && npm run test && npm run build",
"release": "release-it",
"example": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/example.ts",
"workflowexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/workflow_example.ts"
"workflowexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/workflow_example.ts",
"ingress": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/ingress.ts"
},
"dependencies": {
"protobufjs": "^7.2.2",
Expand Down
217 changes: 217 additions & 0 deletions src/clients/ingress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH
*
* This file is part of the Restate SDK for Node.js/TypeScript,
* which is released under the MIT license.
*
* You can find a copy of the license in file LICENSE in the root
* directory of this repository or package, or at
* https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
*/

import { ServiceDefintion, VirtualObjectDefintion } from "../public_api";
import { deserializeJson, serializeJson } from "../utils/serde";

export interface Ingress {
service<P extends string, M>(opts: ServiceDefintion<P, M>): IngressClient<M>;
object<P extends string, M>(
opts: VirtualObjectDefintion<P, M>,
key: string
): IngressClient<M>;
objectSend<P extends string, M>(
opts: VirtualObjectDefintion<P, M>,
key: string
): IngressSendClient<M>;
serviceSend<P extends string, M>(
opts: ServiceDefintion<P, M>
): IngressSendClient<M>;
}

export interface IngresCallOptions {
idempotencyKey?: string;
retain?: number;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you allow to add additional headers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah!, good idea!

Comment on lines +31 to +32
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thing I would have done differently is to group the idempotency options all together, like

export interface IdempotencyOptions {
  key: string;
  retain?: number;
}

export interface IngressCallOptions {
  idempotency?: IdempotencyOptions;
  headers: {[string]: string};
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather to keep it flat for now.

headers?: Record<string, string>;
}

export class Opts {
public static from(opts: IngresCallOptions): Opts {
return new Opts(opts);
}

constructor(readonly opts: IngresCallOptions) {}
}

export type IngressClient<M> = {
[K in keyof M as M[K] extends never ? never : K]: M[K] extends (
...args: infer P
) => PromiseLike<infer O>
? (...args: [...P, ...[opts?: Opts]]) => PromiseLike<O>
: never;
};

export type IngressSendClient<M> = {
[K in keyof M as M[K] extends never ? never : K]: M[K] extends (
...args: infer P
) => unknown
? (...args: [...P, ...[opts?: Opts]]) => void
: never;
};

export type ConnectionOpts = {
url: string;
headers?: Record<string, string>;
};

export function connect(opts: ConnectionOpts): Ingress {
return new HttpIngress(opts);
}

type InvocationParameters<I> = {
component: string;
handler: string;
key?: string;
send?: boolean;
delay?: number;
opts?: Opts;
parameter?: I;
};

function optsFromArgs(args: unknown[]): { parameter?: unknown; opts?: Opts } {
let parameter: unknown | undefined;
let opts: Opts | undefined;
switch (args.length) {
case 0: {
break;
}
case 1: {
if (args[0] instanceof Opts) {
opts = args[0] as Opts;
} else {
parameter = args[0];
}
break;
}
case 2: {
parameter = args[0];
opts = args[1] as Opts;
break;
}
default: {
throw new TypeError(`unexpected number of arguments`);
}
}
return {
parameter,
opts,
};
}

const IDEMPOTENCY_KEY_HEADER = "idempotency-key";
const IDEMPOTENCY_KEY_RETAIN_HEADER = "idempotency-retention-period";

export class HttpIngress implements Ingress {
constructor(readonly opts: ConnectionOpts) {}

private proxy(
component: string,
key?: string,
send?: boolean,
delay?: number
) {
return new Proxy(
{},
{
get: (_target, prop) => {
const handler = prop as string;
return (...args: unknown[]) => {
const { parameter, opts } = optsFromArgs(args);
return this.invoke({
component,
handler,
key,
parameter,
opts,
send,
delay,
});
};
},
}
);
}

async invoke<I, O>(params: InvocationParameters<I>): Promise<O> {
const fragments = [];
// ingress URL
fragments.push(this.opts.url);
// component
fragments.push(params.component);
// has key?
if (params.key) {
const key = encodeURIComponent(params.key);
fragments.push(key);
}
// handler
fragments.push(params.handler);
if (params.send ?? false) {
if (params.delay) {
fragments.push(`send?delay=${params.delay}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now number right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed a number, like the retention number. We said that we would like to align both of them once that feature will be implemented.

} else {
fragments.push("send");
}
}
const url = fragments.join("/");
const headers = {
"Content-Type": "application/json",
...(this.opts.headers ?? {}),
...(params.opts?.opts?.headers ?? {}),
};

const idempotencyKey = params.opts?.opts.idempotencyKey;
if (idempotencyKey) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(headers as any)[IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
}
const retain = params.opts?.opts.retain;
if (retain) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(headers as any)[IDEMPOTENCY_KEY_RETAIN_HEADER] = `${retain}`;
}
const body = serializeJson(params.parameter);
const httpResponse = await fetch(url, {
method: "POST",
headers,
body,
});
if (!httpResponse.ok) {
const body = await httpResponse.text();
throw new Error(`Request failed: ${httpResponse.status}\n${body}`);
}
const responseBuf = await httpResponse.arrayBuffer();
return deserializeJson(new Uint8Array(responseBuf));
}

service<P extends string, M>(opts: ServiceDefintion<P, M>): IngressClient<M> {
return this.proxy(opts.path) as IngressClient<M>;
}

object<P extends string, M>(
opts: VirtualObjectDefintion<P, M>,
key: string
): IngressClient<M> {
return this.proxy(opts.path, key) as IngressClient<M>;
}

objectSend<P extends string, M>(
opts: VirtualObjectDefintion<P, M>,
key: string
): IngressSendClient<M> {
return this.proxy(opts.path, key, true) as IngressSendClient<M>;
}

serviceSend<P extends string, M>(
opts: ServiceDefintion<P, M>
): IngressSendClient<M> {
return this.proxy(opts.path, undefined, true) as IngressSendClient<M>;
}
}
1 change: 1 addition & 0 deletions src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export * as RestateUtils from "./utils/public_utils";
export { RestateError, TerminalError, TimeoutError } from "./types/errors";
export * as workflow from "./workflows/workflow";
export * as clients from "./clients/workflow_client";
export * as ingress from "./clients/ingress";