Skip to content

Commit

Permalink
Add generic-google-api-http-definitions
Browse files Browse the repository at this point in the history
  • Loading branch information
jas-chen committed Jul 24, 2024
1 parent 039d364 commit 2715391
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 41 deletions.
66 changes: 66 additions & 0 deletions GOOGLE-API-HTTP.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Generate generic definitions from [google.api.http](https://cloud.google.com/endpoints/docs/grpc/transcoding)

To generate `.ts` files for your `.proto` files that contain `google.api.http`, you can use the `--ts_proto_opt=onlyTypes=true,outputServices=generic-google-api-http-definitions` option.

Please refer to [integration/google-api-http](./integration/google-api-http) for an input/output example.

## Client implementation example

```typescript
// This is just an example, please test it to see if it works for you.
function createApi<
S extends Record<string, { path: string; method: string; body?: string; requestType: any; responseType: any }>,
>(
fetcher: (input: { path: string; method: string; body?: string }) => Promise<unknown>,
serviceDef: S,
): { [K in keyof S]: (payload: S[K]["requestType"]) => Promise<S[K]["responseType"]> } {
// @ts-expect-error
return Object.fromEntries(
Object.entries(serviceDef).map(([name, endpointDef]) => {
return [
name,
async (payload: typeof endpointDef.requestType): Promise<typeof endpointDef.responseType> => {
const bodyKey = endpointDef.body;
const payloadClone = bodyKey === "*" ? JSON.parse(JSON.stringify(payload)) : null;
const path = endpointDef.path.replace(/\{([^\}]+)\}/g, (_, key) => {
delete payloadClone[key];
return payload[key];
});
let body: string | undefined = undefined;
if (bodyKey === "*") {
body = JSON.stringify(payloadClone);
} else if (bodyKey) {
body = JSON.stringify({ [bodyKey]: payload[bodyKey] });
}

return fetcher({ path, method: endpointDef.method, body });
},
];
}),
);
}

const fetcher = (input: { path: string; method: string; body?: string }) => {
const url = "http://localhost:8080" + input.path;
const init: RequestInit = {
method: input.method,
headers: {
"Content-Type": "application/json",
},
};
if (input.body) {
init.body = input.body;
}
return fetch(url, init).then((res) => res.json());
};

const api = createApi(fetcher, Messaging);

api
.GetMessage({
messageId: "123",
})
.then((res) => {
console.log(res);
});
```
4 changes: 4 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,10 @@ Generated code will be placed in the Gradle build directory.

- With `--ts_proto_opt=outputServices=generic-definitions`, ts-proto will output generic (framework-agnostic) service definitions. These definitions contain descriptors for each method with links to request and response types, which allows to generate server and client stubs at runtime, and also generate strong types for them at compile time. An example of a library that uses this approach is [nice-grpc](https://github.com/deeplay-io/nice-grpc).

- With `--ts_proto_opt=outputServices=generic-google-api-http-definitions`, ts-proto will output generic (framework-agnostic) service definitions from [google.api.http](https://cloud.google.com/endpoints/docs/grpc/transcoding). These definitions contain descriptors for each method with links to request and response types, which allows to implement a http client based on it. For more information see the [google.api.http readme](GOOGLE-API-HTTP.markdown).

(Requires `onlyTypes=true`.)

- With `--ts_proto_opt=outputServices=nice-grpc`, ts-proto will output server and client stubs for [nice-grpc](https://github.com/deeplay-io/nice-grpc). This should be used together with generic definitions, i.e. you should specify two options: `outputServices=nice-grpc,outputServices=generic-definitions`.

- With `--ts_proto_opt=metadataType=Foo@./some-file`, ts-proto add a generic (framework-agnostic) metadata field to the generic service definition.
Expand Down
14 changes: 7 additions & 7 deletions integration/google-api-http/google-api-http-test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
/**
* @jest-environment node
*/
import { MessagingDefinition, GetMessageRequest, GetMessageResponse } from "./simple";
import { Messaging, GetMessageRequest, GetMessageResponse } from "./simple";

describe("google-api-http-test", () => {
it("compiles", () => {
expect(MessagingDefinition.methods).toStrictEqual({
getMessage: {
expect(Messaging).toStrictEqual({
GetMessage: {
path: "/v1/messages/{message_id}",
method: "get",
requestType: undefined,
responseType: undefined,
},
createMessage: {
CreateMessage: {
path: "/v1/messages/{message_id}",
method: "post",
body: "message",
requestType: undefined,
responseType: undefined,
},
updateMessage: {
UpdateMessage: {
path: "/v1/messages/{message_id}",
method: "patch",
body: "*",
requestType: undefined,
responseType: undefined,
},
deleteMessage: {
DeleteMessage: {
path: "/v1/messages/{message_id}",
method: "delete",
body: "*",
Expand All @@ -36,7 +36,7 @@ describe("google-api-http-test", () => {
});

// Test that the request and response types are correctly typed
const copy = { ...MessagingDefinition.methods.getMessage };
const copy = { ...Messaging.GetMessage };
const request: GetMessageRequest = {
messageId: "1",
};
Expand Down
2 changes: 1 addition & 1 deletion integration/google-api-http/parameters.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
outputClientImpl=false,outputEncodeMethods=false,outputJsonMethods=false,outputServices=generic-definitions
onlyTypes=true,outputServices=generic-google-api-http-definitions
61 changes: 28 additions & 33 deletions integration/google-api-http/simple.ts

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

50 changes: 50 additions & 0 deletions src/generate-generic-google-api-http-service-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ServiceDescriptorProto, MethodOptions } from "ts-proto-descriptors";
import { Code, code, joinCode } from "ts-poet";
import { requestType, responseType } from "./types";
import { assertInstanceOf, FormattedMethodDescriptor, findHttpRule } from "./utils";
import { Context } from "./context";

export function generateGenericGoogleApiHttpServiceDefinition(ctx: Context, serviceDesc: ServiceDescriptorProto): Code {
const chunks: Code[] = [];

const httpRules: Record<string, ReturnType<typeof findHttpRule> | undefined> = {};
let hasHttpRule = false;

serviceDesc.method.forEach((methodDesc) => {
const httpRule = findHttpRule(methodDesc.options?.httpRule);

if (httpRule) {
hasHttpRule = true;
assertInstanceOf(methodDesc, FormattedMethodDescriptor);
httpRules[methodDesc.formattedName] = httpRule;
}
});

if (hasHttpRule) {
chunks.push(code`
export const ${serviceDesc.name} = {
`);

serviceDesc.method.forEach((methodDesc) => {
assertInstanceOf(methodDesc, FormattedMethodDescriptor);
const httpRule = httpRules[methodDesc.formattedName];

if (!httpRule) {
return;
}

chunks.push(code`\
${methodDesc.formattedName}: {
path: "${httpRule.path}",
method: "${httpRule.method}",${httpRule.body ? `\nbody: "${httpRule.body}",` : ""}
requestType: undefined as unknown as ${requestType(ctx, methodDesc)},
responseType: undefined as unknown as ${responseType(ctx, methodDesc)},
},`);
});

chunks.push(code`}`);
return joinCode(chunks, { on: "\n" });
}

return code``;
}
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
withOrMaybeCheckIsNull,
} from "./utils";
import { visit, visitServices } from "./visit";
import { generateGenericGoogleApiHttpServiceDefinition } from "./generate-generic-google-api-http-service-definition";

export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [string, Code] {
const { options, utils } = ctx;
Expand Down Expand Up @@ -340,6 +341,8 @@ export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [stri
chunks.push(generateNiceGrpcService(ctx, fileDesc, sInfo, serviceDesc));
} else if (outputService === ServiceOption.GENERIC) {
chunks.push(generateGenericServiceDefinition(ctx, fileDesc, sInfo, serviceDesc));
} else if (outputService === ServiceOption.GENERIC_GOOGLE_API_HTTP) {
chunks.push(generateGenericGoogleApiHttpServiceDefinition(ctx, serviceDesc));
} else if (outputService === ServiceOption.DEFAULT) {
// This service could be Twirp or grpc-web or JSON (maybe). So far all of their
// interfaces are fairly similar so we share the same service interface.
Expand Down
1 change: 1 addition & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export enum ServiceOption {
GRPC = "grpc-js",
NICE_GRPC = "nice-grpc",
GENERIC = "generic-definitions",
GENERIC_GOOGLE_API_HTTP = "generic-google-api-http-definitions",
DEFAULT = "default",
NONE = "none",
}
Expand Down

0 comments on commit 2715391

Please sign in to comment.