Skip to content

Commit

Permalink
feat(proxy): Add ability to call gRPC endpoints (#1561)
Browse files Browse the repository at this point in the history
  • Loading branch information
amckinney authored Sep 30, 2024
1 parent 8d44977 commit 486cc17
Show file tree
Hide file tree
Showing 34 changed files with 849 additions and 2 deletions.
45 changes: 45 additions & 0 deletions fern/apis/proxy/definition/__package__.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ service:
request:
body: ProxyRequest
response: ProxyResponse
grpc:
path: /grpc
display-name: Proxy gRPC
method: POST
request:
body: GrpcProxyRequest
response: GrpcProxyResponse
file:
path: /file
display-name: Proxy File
Expand Down Expand Up @@ -89,3 +96,41 @@ types:
- error
- opaque
- opaqueredirect

GrpcProxyRequest:
properties:
baseUrl:
docs: The base URL to use for the call (e.g. https://acme.co).
type: string
endpoint:
docs: The gRPC endpoint name (e.g. user.v1.UserService/GetUser).
type: string
headers:
docs: |
The set of encoded headers to send with the request (e.g. 'Authorization: Bearer ...').
type: map<string, string>
schema:
docs: |
The Protobuf schema that defines the API. If not specified, it's assumed the server
supports gRPC reflection.
type: optional<ProtobufSchema>
body:
docs: |
The request body (represented as JSON) to include in the request, if any.
type: optional<unknown>

GrpcProxyResponse:
properties:
body: optional<unknown>

ProtobufSchema:
union:
remote: RemoteProtobufSchema

RemoteProtobufSchema:
properties:
sourceUrl:
docs: |
The remote URL containing the Protobuf schema files that define this API.
The content is assumed to be in a .zip file.
type: string
16 changes: 15 additions & 1 deletion fern/apis/proxy/generators.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
default-group: sdk
default-group: local
groups:
sdk:
generators: []
Expand All @@ -13,3 +13,17 @@ groups:
# outputEsm: true
# noSerdeLayer: true
# noOptionalProperties: true

local:
generators:
- name: fernapi/fern-typescript-node-sdk
version: 0.20.9
output:
location: local-file-system
path: ../../../servers/fern-bot/src/generated
config:
includeUtilsOnUnionMembers: true
noSerdeLayer: true
useBrandedStringAliases: true
outputSourceFiles: true
outputEsm: true
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions servers/fern-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"serverless-step-functions": "^3.21.0",
"simple-git": "^3.24.0",
"tmp-promise": "^3.0.3",
"url-join": "^5.0.0",
"zod": "^3.22.4",
"semver": "^7.6.2"
},
Expand All @@ -46,6 +47,7 @@
"@types/aws-lambda": "^8.10.71",
"@types/js-yaml": "^4.0.9",
"@types/node": "^18.7.18",
"@types/url-join": "4.0.1",
"esbuild": "0.20.2",
"json-schema-to-ts": "^1.5.0",
"serverless": "^3.0.0",
Expand Down
13 changes: 13 additions & 0 deletions servers/fern-bot/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ functions:
rate: cron(0 10 * * ? 1)
enabled: true

# =============================================

# gRPC proxy endpooint, exposed as an endpoint (not scheduled).
proxyGrpc:
timeout: 900
memorySize: 5120
ephemeralStorageSize: 10240
handler: "src/functions/grpc-proxy/proxyGrpc.handler"
layers:
- arn:aws:lambda:us-east-1:553035198032:layer:git-lambda2:8
events:
- httpApi: "POST /grpc"

stepFunctions:
stateMachines:
updateSpecs:
Expand Down
165 changes: 165 additions & 0 deletions servers/fern-bot/src/__test__/grpc-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { expect } from "vitest";
import { proxyGrpc } from "../functions/grpc-proxy/proxyGrpc";

const AWS_BUCKET_NAME = "fdr-api-definition-source-test";
const AWS_OBJECT_KEY = "fern/fern/2024-08-11T22:35:49.980Z/f6ea473b-1884-4ccc-b386-113cbff139d1";

interface ElizaResponse {
sentence: string;
}

interface CreateUserRequest {
username: string;
email: string;
age: number;
weight: number;
metadata: object;
}

interface UpsertRequest {
namespace: string;
vectors: Vector[];
}

interface UpsertResponse {
upsertedCount: number;
}

interface Vector {
id: string;
values: number[];
}

it("unary w/ gRPC server reflection", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await proxyGrpc({
body: {
baseUrl: "https://demo.connectrpc.com",
endpoint: "connectrpc.eliza.v1.ElizaService/Say",
headers: {},
body: {
sentence: "Feeling happy? Tell me more.",
},
},
skipDefaultSchema: true,
});

expect(response).not.toBe(null);

const elizaResponse = response as ElizaResponse;
expect(elizaResponse.sentence).not.toBe(null);
});

it.skip("unary w/ default schema", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await proxyGrpc({
body: {
baseUrl: "https://serverless-test-gb6vrs7.svc.aped-4627-b74a.pinecone.io",
endpoint: "endpoint_index.upsert",
headers: { "Api-Key": process.env.PINECONE_API_KEY },
body: {
namespace: "test",
vectors: [
{
id: "v2",
values: [0.1, 0.2, 0.3],
},
{
id: "v3",
values: [0.4, 0.5, 0.6],
},
] as Vector[],
} as UpsertRequest,
},
});

expect(response).not.toBe(null);

const upsertResponse = JSON.parse(response as string) as UpsertResponse;
expect(upsertResponse.upsertedCount).toBe(2);
});

it("unauthorized", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await proxyGrpc({
body: {
baseUrl: "https://serverless-test-gb6vrs7.svc.aped-4627-b74a.pinecone.io",
endpoint: "endpoint_index.upsert",
headers: { Authorization: "Bearer invalid" },
body: {
namespace: "test",
vectors: [
{
id: "v2",
values: [0.1, 0.2, 0.3],
},
{
id: "v3",
values: [0.4, 0.5, 0.6],
},
] as Vector[],
} as UpsertRequest,
},
});

expect(response).not.toBe(null);
expect(response).toEqual(`{
"code": "unauthenticated",
"message": "Unauthorized"
}`);
});

it("invalid schema", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await proxyGrpc({
body: {
baseUrl: "https://demo.connectrpc.com",
endpoint: "connectrpc.eliza.v1.ElizaService/Say",
headers: {},
schema: {
sourceUrl: `https://${AWS_BUCKET_NAME}.s3.amazonaws.com/${AWS_OBJECT_KEY}`,
},
body: {
sentence: "Feeling happy? Tell me more.",
},
},
});

expect(response).not.toBe(null);
expect(response).toEqual('Failure: failed to find service named "connectrpc.eliza.v1.ElizaService" in schema');

const elizaResponse = response as ElizaResponse;
expect(elizaResponse.sentence).toBe(undefined);
});

it("invalid host", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await proxyGrpc({
body: {
baseUrl: "https://demo.connectrpc.com",
endpoint: "user.v1.User/Create",
headers: {},
schema: {
sourceUrl: `https://${AWS_BUCKET_NAME}.s3.amazonaws.com/${AWS_OBJECT_KEY}`,
},
body: {
username: "john.doe",
email: "[email protected]",
age: 42,
weight: 180.5,
metadata: {
foo: "bar",
},
} as CreateUserRequest,
},
});

expect(response).not.toBe(null);
expect(response).toEqual(`{
"code": "unknown",
"message": "HTTP status 302 Found"
}`);

const elizaResponse = response as ElizaResponse;
expect(elizaResponse.sentence).toBe(undefined);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const DEFAULT_PROTO_DIRECTORY = "proto";
export const DEFAULT_PROTO_SOURCE_URL =
"https://fdr-dev2-api-definition-source-files.s3.us-east-1.amazonaws.com/pinecone/api/2024-09-30T21%3A29%3A51.859Z/10095813-78e1-4999-9a9f-6f08d1084609?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIA6KXJSKKNE6LAYO7B%2F20240930%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240930T212951Z&X-Amz-Expires=604800&X-Amz-Signature=67f5548b27f26ea33a5f9bec23885d6159c8d6d5deb01f99744ce34898b791f4&X-Amz-SignedHeaders=host&x-id=GetObject";
Loading

0 comments on commit 486cc17

Please sign in to comment.