Skip to content
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ generate:
.PHONY: test
test:
$(MAKE) -C go test
$(MAKE) -C js test

.PHONY: build
build:
Expand Down
47 changes: 6 additions & 41 deletions examples/js/ip-list.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,16 @@
import * as apiv2 from "@metal-stack/api/js/metalstack/api/v2/ip_pb";
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";

import { Code, ConnectError, Interceptor } from "@connectrpc/connect";

class AuthInterceptor {
private authToken: string;

constructor(authToken: string) {
this.authToken = authToken;
}

interceptor: Interceptor = (next) => async (req) => {
if (!this.authToken) {
throw new ConnectError("Missing auth token", Code.Unauthenticated);
}

req.header.append("Authorization", `Bearer ${this.authToken}`);

try {
const res = await next(req);
return res;
} catch (e) {
if (e instanceof ConnectError && e.code === Code.Unauthenticated) {
// e.g. message: "token has expired"
console.error("unauthenticated", e);
}
throw e;
}
};
}
import { newClient } from "../../js/client";

async function main() {
const token = process.env["API_TOKEN"];
const project = process.env["PROJECT_ID"];
const baseUrl = process.env["METAL_APISERVER_URL"];

const auth = new AuthInterceptor(token!);
const client = createClient(
apiv2.IPService,
createConnectTransport({
baseUrl: baseUrl!,
interceptors: [auth.interceptor],
}),
);
const client = newClient({
baseUrl: baseUrl!,
token: token!,
});

const listResp = await client.list({ project });
const listResp = await client.apiv2().ip().list({ project });

for (const ip of listResp.ips) {
console.log("ip", ip);
Expand Down
12 changes: 12 additions & 0 deletions examples/js/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es6",
"module": "esnext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}
2 changes: 2 additions & 0 deletions generate/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ generate:
# we remove files explicitly to ensure files really get generated
rm -f ../go/permissions/servicepermissions.go
rm -f ../go/client/client.go
rm -f ../python/metalstack/client.py
rm -f ../ts/client/client.*

go run ./generate.go
42 changes: 34 additions & 8 deletions generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"bytes"
"fmt"
"go/format"
"html/template"
"text/template"
"os"
"path"
"path/filepath"
Expand All @@ -31,6 +31,8 @@ var (
servicePermissionsTpl string
//go:embed go_client.tpl
clientTpl string
//go:embed ts_client.tpl
tsClientTpl string
//go:embed python_client.tpl
pythonClientTpl string
)
Expand All @@ -52,7 +54,7 @@ func main() {
panic(err)
}

err = writeTemplate("../go/permissions/servicepermissions.go", servicePermissionsTpl, perms)
err = writeTemplate("../go/permissions/servicepermissions.go", servicePermissionsTpl, perms, sprig.FuncMap())
if err != nil {
panic(err)
}
Expand All @@ -64,7 +66,24 @@ func main() {
panic(err)
}

err = writeTemplate("../go/client/client.go", clientTpl, svcs)
funcs := sprig.FuncMap()
funcs["lowerFirst"] = func(s string) string {
if len(s) == 0 {
return s
}
// If the string is all uppercase (e.g. "IP", "VPN", "BMC"), lowercase the entire thing
if s == strings.ToUpper(s) {
return strings.ToLower(s)
}
return strings.ToLower(s[:1]) + s[1:]
}

err = writeTemplate("../go/client/client.go", clientTpl, svcs, funcs)
if err != nil {
panic(err)
}

err = writeTemplate("../js/client.ts", tsClientTpl, svcs, funcs)
if err != nil {
panic(err)
}
Expand Down Expand Up @@ -281,8 +300,8 @@ func svcs(root string) (map[string]api, error) {
return result, nil
}

func writeTemplate(dest, text string, data any) error {
t, err := template.New("").Funcs(sprig.FuncMap()).Parse(text)
func writeTemplate(dest, text string, data any, funcs template.FuncMap) error {
t, err := template.New("").Funcs(funcs).Parse(text)
if err != nil {
return err
}
Expand All @@ -292,15 +311,22 @@ func writeTemplate(dest, text string, data any) error {
return err
}

p, err := format.Source(buf.Bytes())
if err != nil {
return err
var p []byte
if strings.HasSuffix(dest, ".go") {
var ferr error
p, ferr = format.Source(buf.Bytes())
if ferr != nil {
return ferr
}
} else {
p = buf.Bytes()
}

fmt.Println("wrote " + dest)

return os.WriteFile(dest, p, 0755) // nolint:gosec
}

func writePythonTemplate(dest, text string, data any) error {
t, err := template.New("").Funcs(sprig.FuncMap()).Parse(text)
if err != nil {
Expand Down
106 changes: 106 additions & 0 deletions generate/ts_client.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Code generated by generate_clients.go. DO NOT EDIT.

import { createClient, Interceptor, Transport } from "@connectrpc/connect";
import type { Client as ConnectClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
{{ range $name, $api := . }}
{{ range $svc := $api.Services }}
import { {{ $svc.Name }} as {{ $name | title }}{{ $svc.Name }} } from ".{{ $api.Path }}/{{ $svc.FileName | trimSuffix ".proto" }}_pb";
{{ end }}
{{ end }}

export interface ClientConfig {
baseUrl: string;
token?: string;
interceptors?: Interceptor[];
}

export interface Client {
{{ range $name, $api := . }}
{{ $name }}(): {{ $name | title }};
{{ end }}
}

{{ range $name, $api := . }}
export interface {{ $name | title }} {
{{ range $svc := $api.Services }}
{{ $svc.Name | trimSuffix "Service" | title | lowerFirst }}(): ConnectClient<typeof {{ $name | title }}{{ $svc.Name }}>;
{{ end }}
}

{{ end }}

function authInterceptor(token: string): Interceptor {
return (next) => async (req) => {
req.header.set("Authorization", `Bearer ${token}`);
return await next(req);
};
}

function buildTransport(config: ClientConfig): Transport {
const interceptors: Interceptor[] = [];

if (config.token) {
interceptors.push(authInterceptor(config.token));
}

if (config.interceptors) {
interceptors.push(...config.interceptors);
}

return createConnectTransport({
baseUrl: config.baseUrl,
interceptors: interceptors.length > 0 ? interceptors : undefined,
defaultTimeoutMs: 30_000,
});
}

export function newClient(config: ClientConfig): Client {
const transport = buildTransport(config);
return new ClientImpl(transport);
}

class ClientImpl implements Client {
private transport: Transport;

{{ range $name, $api := . }}
private _{{ $name }}?: {{ $name | title }}Impl;
{{ end }}

constructor(transport: Transport) {
this.transport = transport;
}

{{ range $name, $api := . }}
{{ $name }}(): {{ $name | title }} {
if (!this._{{ $name }}) {
this._{{ $name }} = new {{ $name | title }}Impl(this.transport);
}
return this._{{ $name }};
}
{{ end }}
}

{{ range $name, $api := . }}
class {{ $name | title }}Impl implements {{ $name | title }} {
private transport: Transport;

{{ range $svc := $api.Services }}
private _{{ $svc.Name | trimSuffix "Service" | title | lowerFirst }}?: ConnectClient<typeof {{ $name | title }}{{ $svc.Name }}>;
{{ end }}

constructor(transport: Transport) {
this.transport = transport;
}

{{ range $svc := $api.Services }}
{{ $svc.Name | trimSuffix "Service" | title | lowerFirst }}(): ConnectClient<typeof {{ $name | title }}{{ $svc.Name }}> {
if (!this._{{ $svc.Name | trimSuffix "Service" | title | lowerFirst }}) {
this._{{ $svc.Name | trimSuffix "Service" | title | lowerFirst }} = createClient({{ $name | title }}{{ $svc.Name }}, this.transport);
}
return this._{{ $svc.Name | trimSuffix "Service" | title | lowerFirst }};
}
{{ end }}
}

{{ end }}
4 changes: 4 additions & 0 deletions js/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ ifeq ($(CI),true)
yq e -o=json ".version" package.json
endif
cd .. && bun run build

.PHONY: test
test:
bun test ./client-test.ts
48 changes: 48 additions & 0 deletions js/client-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "bun:test";
import { create } from "@bufbuild/protobuf";
import type { DescMessage, MessageShape } from "@bufbuild/protobuf";
import {
VersionServiceGetRequestSchema,
VersionServiceGetResponseSchema,
VersionService,
} from "./metalstack/api/v2/version_pb";
import type { UnaryResponse } from "@connectrpc/connect";
import { newClient } from "./client";
import { newTestInterceptor } from "./test-interceptor";

function unaryResponse<Req extends DescMessage, Res extends DescMessage>(
schema: Req,
message: MessageShape<Res>,
): UnaryResponse<Req, Res> {
return {
stream: false as const,
message: message as any,
header: new Headers(),
trailer: new Headers(),
service: undefined as any,
method: undefined as any,
};
}

describe("client", () => {
it("works with test interceptor", async () => {
const c = newClient({
baseUrl: "http://this-is-just-for-testing",
interceptors: [
newTestInterceptor([
{
wantRequest: create(VersionServiceGetRequestSchema, {}),
wantRequestSchema: VersionServiceGetRequestSchema,
wantResponse: () =>
unaryResponse(VersionServiceGetRequestSchema, {
version: { version: "1.0", revision: "", gitSha1: "", buildDate: "" },
}),
},
]),
],
});

const v = await c.apiv2().version().get({});
expect(v.version?.version).toBe("1.0");
});
});
Loading
Loading