Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095cb859a5 |
+250
-1
@@ -167,6 +167,153 @@ result = await requestInterceptorClient.request("Svc", "interceptors", { value:
|
|||||||
assert.deepEqual(requestInterceptorSeen, { value: 1 });
|
assert.deepEqual(requestInterceptorSeen, { value: 1 });
|
||||||
assert.equal(result.msg, "intercepted");
|
assert.equal(result.msg, "intercepted");
|
||||||
|
|
||||||
|
const stateClient = new Client("http://example.test");
|
||||||
|
stateClient.setState({ shared: "global", globalOnly: "yes" });
|
||||||
|
const stateContexts = new Map();
|
||||||
|
const nativeResponses = new Map();
|
||||||
|
const payloads = new Map();
|
||||||
|
let releaseFirst;
|
||||||
|
const firstMayContinue = new Promise(resolve => { releaseFirst = resolve; });
|
||||||
|
let firstInterceptorEntered;
|
||||||
|
const firstDidEnter = new Promise(resolve => { firstInterceptorEntered = resolve; });
|
||||||
|
let firstStateReference;
|
||||||
|
stateClient.addRequestInterceptor(async (_service, method, state, dto) => {
|
||||||
|
state.interceptor = dto.id;
|
||||||
|
if (method === "first") {
|
||||||
|
firstStateReference = state;
|
||||||
|
firstInterceptorEntered();
|
||||||
|
await firstMayContinue;
|
||||||
|
}
|
||||||
|
state.completed = dto.id;
|
||||||
|
});
|
||||||
|
stateClient.addResponseInterceptor((_service, method, _value, context) => {
|
||||||
|
stateContexts.set(method, context);
|
||||||
|
});
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
const payload = JSON.parse(init.body);
|
||||||
|
payloads.set(payload.methodName, payload);
|
||||||
|
const response = json({ status: 0, data: payload.methodName });
|
||||||
|
nativeResponses.set(payload.methodName, response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
const firstOverride = { shared: "first", requestOnly: "one" };
|
||||||
|
const firstRequest = stateClient.request("Svc", "first", { id: "one" }, { state: firstOverride });
|
||||||
|
await firstDidEnter;
|
||||||
|
firstOverride.shared = "mutated outside";
|
||||||
|
const secondRequest = stateClient.request("Svc", "second", { id: "two" }, {
|
||||||
|
state: { shared: "second", requestOnly: "two" },
|
||||||
|
});
|
||||||
|
assert.equal((await secondRequest).status, 0);
|
||||||
|
releaseFirst();
|
||||||
|
assert.equal((await firstRequest).status, 0);
|
||||||
|
assert.deepEqual(payloads.get("first").state, {
|
||||||
|
shared: "first",
|
||||||
|
globalOnly: "yes",
|
||||||
|
requestOnly: "one",
|
||||||
|
interceptor: "one",
|
||||||
|
completed: "one",
|
||||||
|
});
|
||||||
|
assert.deepEqual(payloads.get("second").state, {
|
||||||
|
shared: "second",
|
||||||
|
globalOnly: "yes",
|
||||||
|
requestOnly: "two",
|
||||||
|
interceptor: "two",
|
||||||
|
completed: "two",
|
||||||
|
});
|
||||||
|
firstStateReference.shared = "mutated after snapshot";
|
||||||
|
for (const method of ["first", "second"]) {
|
||||||
|
const context = stateContexts.get(method);
|
||||||
|
assert.deepEqual(context.requestState, payloads.get(method).state);
|
||||||
|
assert.equal(Object.isFrozen(context.requestState), true);
|
||||||
|
assert.equal(Object.isFrozen(context), true);
|
||||||
|
assert.equal(context.response, nativeResponses.get(method));
|
||||||
|
assert.ok(context.response instanceof Response);
|
||||||
|
}
|
||||||
|
assert.equal(stateContexts.get("first").requestState.shared, "first");
|
||||||
|
|
||||||
|
const throwingState = Object.defineProperty({}, "token", {
|
||||||
|
enumerable: true,
|
||||||
|
get() { throw new Error("state read failed"); },
|
||||||
|
});
|
||||||
|
const stateFailureClient = new Client("http://example.test");
|
||||||
|
let stateFailureSeen = [];
|
||||||
|
stateFailureClient.addResponseInterceptor((_service, _method, value, context) => {
|
||||||
|
stateFailureSeen.push([value.status, context.requestState]);
|
||||||
|
});
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run for state failure");
|
||||||
|
result = await stateFailureClient.request("Svc", "requestStateFailure", undefined, { state: throwingState });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /prepare request state/i);
|
||||||
|
result = await stateFailureClient.stream("Svc", "streamStateFailure", undefined, () => {}, { state: throwingState });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /prepare request state/i);
|
||||||
|
assert.deepEqual(stateFailureSeen, [[1, {}], [1, {}]]);
|
||||||
|
|
||||||
|
const snapshotFailureClient = new Client("http://example.test");
|
||||||
|
snapshotFailureClient.addRequestInterceptor((_service, _method, state) => {
|
||||||
|
Object.defineProperty(state, "broken", {
|
||||||
|
enumerable: true,
|
||||||
|
get() { throw new Error("snapshot read failed"); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run for snapshot failure");
|
||||||
|
result = await snapshotFailureClient.request("Svc", "requestSnapshotFailure");
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /snapshot request state/i);
|
||||||
|
result = await snapshotFailureClient.stream("Svc", "streamSnapshotFailure", undefined, () => {});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.msg, /snapshot request state/i);
|
||||||
|
|
||||||
|
const contextClient = new Client("http://example.test");
|
||||||
|
contextClient.setState({ base: "global" });
|
||||||
|
const outcomeContexts = new Map();
|
||||||
|
contextClient.addResponseInterceptor((_service, method, _value, context) => {
|
||||||
|
outcomeContexts.set(method, context);
|
||||||
|
});
|
||||||
|
contextClient.addRequestInterceptor((_service, method, state) => {
|
||||||
|
state.method = method;
|
||||||
|
if (method === "requestHookFailure") throw new Error("request context hook");
|
||||||
|
});
|
||||||
|
globalThis.fetch = async () => assert.fail("fetch must not run before serialization");
|
||||||
|
const contextCyclic = {};
|
||||||
|
contextCyclic.self = contextCyclic;
|
||||||
|
result = await contextClient.request("Svc", "serializeContext", contextCyclic, { state: { base: "request" } });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.deepEqual(outcomeContexts.get("serializeContext").requestState, {
|
||||||
|
base: "request",
|
||||||
|
method: "serializeContext",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("serializeContext").response, undefined);
|
||||||
|
result = await contextClient.request("Svc", "requestHookFailure", undefined, { state: { request: "hook" } });
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.deepEqual(outcomeContexts.get("requestHookFailure").requestState, {
|
||||||
|
base: "global",
|
||||||
|
request: "hook",
|
||||||
|
method: "requestHookFailure",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("requestHookFailure").response, undefined);
|
||||||
|
globalThis.fetch = async () => { throw new TypeError("offline"); };
|
||||||
|
result = await contextClient.request("Svc", "networkContext", undefined, { state: { request: "network" } });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.deepEqual(outcomeContexts.get("networkContext").requestState, {
|
||||||
|
base: "global",
|
||||||
|
request: "network",
|
||||||
|
method: "networkContext",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("networkContext").response, undefined);
|
||||||
|
const bodyReadResponse = new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new Error("context body read failed")); },
|
||||||
|
}));
|
||||||
|
globalThis.fetch = async () => bodyReadResponse;
|
||||||
|
result = await contextClient.request("Svc", "bodyReadContext", undefined, { state: { request: "body" } });
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
assert.deepEqual(outcomeContexts.get("bodyReadContext").requestState, {
|
||||||
|
base: "global",
|
||||||
|
request: "body",
|
||||||
|
method: "bodyReadContext",
|
||||||
|
});
|
||||||
|
assert.equal(outcomeContexts.get("bodyReadContext").response, bodyReadResponse);
|
||||||
|
|
||||||
const failingRequestInterceptor = new Client("http://example.test");
|
const failingRequestInterceptor = new Client("http://example.test");
|
||||||
let normalized = [];
|
let normalized = [];
|
||||||
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
|
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
|
||||||
@@ -299,6 +446,81 @@ streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(val
|
|||||||
result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
|
result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
|
||||||
assert.equal(result.status, 1);
|
assert.equal(result.status, 1);
|
||||||
assert.deepEqual(normalized, [1]);
|
assert.deepEqual(normalized, [1]);
|
||||||
|
|
||||||
|
const streamStateClient = new Client("http://example.test");
|
||||||
|
streamStateClient.setState({ shared: "global", globalOnly: "stream" });
|
||||||
|
const streamContexts = new Map();
|
||||||
|
const streamResponses = new Map();
|
||||||
|
const streamPayloads = new Map();
|
||||||
|
streamStateClient.addRequestInterceptor((_service, method, state) => {
|
||||||
|
state.interceptor = method;
|
||||||
|
if (method === "streamHookFailure") throw new Error("stream context hook");
|
||||||
|
});
|
||||||
|
streamStateClient.addResponseInterceptor((_service, method, _value, context) => {
|
||||||
|
streamContexts.set(method, context);
|
||||||
|
});
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
const payload = JSON.parse(init.body);
|
||||||
|
streamPayloads.set(payload.methodName, payload);
|
||||||
|
if (payload.methodName === "streamNetwork") throw new TypeError("stream offline");
|
||||||
|
const response = payload.methodName === "streamWrongMedia"
|
||||||
|
? json({ status: 0 })
|
||||||
|
: payload.methodName === "streamReadFailure"
|
||||||
|
? new Response(new ReadableStream({
|
||||||
|
pull(controller) { controller.error(new Error("stream context read failed")); },
|
||||||
|
}), { headers: { "Content-Type": "application/x-ndjson" } })
|
||||||
|
: ndjson('{"n":1}\n');
|
||||||
|
streamResponses.set(payload.methodName, response);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
result = await streamStateClient.stream("Svc", "streamSuccess", undefined, () => {}, {
|
||||||
|
state: { shared: "request", requestOnly: "success" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamWrongMedia", undefined, () => {}, {
|
||||||
|
state: { shared: "wrong-media" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamNetwork", undefined, () => {}, {
|
||||||
|
state: { shared: "network" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamReadFailure", undefined, () => {}, {
|
||||||
|
state: { shared: "read" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 4);
|
||||||
|
const streamCyclic = {};
|
||||||
|
streamCyclic.self = streamCyclic;
|
||||||
|
result = await streamStateClient.stream("Svc", "streamSerialize", streamCyclic, () => {}, {
|
||||||
|
state: { shared: "serialize" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
result = await streamStateClient.stream("Svc", "streamHookFailure", undefined, () => {}, {
|
||||||
|
state: { shared: "hook" },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
for (const method of ["streamSuccess", "streamWrongMedia", "streamNetwork", "streamReadFailure"]) {
|
||||||
|
const context = streamContexts.get(method);
|
||||||
|
assert.deepEqual(context.requestState, streamPayloads.get(method).state);
|
||||||
|
assert.equal(Object.isFrozen(context.requestState), true);
|
||||||
|
assert.equal(Object.isFrozen(context), true);
|
||||||
|
}
|
||||||
|
assert.equal(streamContexts.get("streamSuccess").response, streamResponses.get("streamSuccess"));
|
||||||
|
assert.equal(streamContexts.get("streamWrongMedia").response, streamResponses.get("streamWrongMedia"));
|
||||||
|
assert.equal(streamContexts.get("streamReadFailure").response, streamResponses.get("streamReadFailure"));
|
||||||
|
assert.equal(streamContexts.get("streamNetwork").response, undefined);
|
||||||
|
assert.deepEqual(streamContexts.get("streamSerialize").requestState, {
|
||||||
|
shared: "serialize",
|
||||||
|
globalOnly: "stream",
|
||||||
|
interceptor: "streamSerialize",
|
||||||
|
});
|
||||||
|
assert.equal(streamContexts.get("streamSerialize").response, undefined);
|
||||||
|
assert.deepEqual(streamContexts.get("streamHookFailure").requestState, {
|
||||||
|
shared: "hook",
|
||||||
|
globalOnly: "stream",
|
||||||
|
interceptor: "streamHookFailure",
|
||||||
|
});
|
||||||
|
assert.equal(streamContexts.get("streamHookFailure").response, undefined);
|
||||||
`
|
`
|
||||||
scriptPath := filepath.Join(dir, "behavior.mjs")
|
scriptPath := filepath.Join(dir, "behavior.mjs")
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
|
||||||
@@ -320,8 +542,35 @@ func TestTypeScriptClientStrictTypecheck(t *testing.T) {
|
|||||||
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
usagePath := filepath.Join(dir, "usage.ts")
|
||||||
|
const usage = `import { Client, type ContextResponseInterceptor, type RequestOptions, type ResponseContext, type ResponseInterceptor, type StreamOptions } from "./client";
|
||||||
|
|
||||||
|
const requestOptions: RequestOptions = { state: { token: "request" } };
|
||||||
|
const streamOptions: StreamOptions = { state: { token: "stream" } };
|
||||||
|
const legacy: ResponseInterceptor = (_service, _method, result) => result;
|
||||||
|
const legacyConsumer: (service: string, method: string, result: import("./client").result<any>) => unknown = legacy;
|
||||||
|
const current: ContextResponseInterceptor = (_service, _method, result, context) => {
|
||||||
|
const token: string | undefined = context.requestState.token;
|
||||||
|
const response: Response | undefined = context.response;
|
||||||
|
void token;
|
||||||
|
void response;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
void legacyConsumer;
|
||||||
|
const context = {} as ResponseContext;
|
||||||
|
// @ts-expect-error requestState is readonly
|
||||||
|
context.requestState.token = "changed";
|
||||||
|
const client = new Client("/");
|
||||||
|
client.addResponseInterceptor(legacy);
|
||||||
|
client.addResponseInterceptor(current);
|
||||||
|
void client.request("Svc", "method", undefined, requestOptions);
|
||||||
|
void client.stream("Svc", "method", undefined, () => {}, streamOptions);
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(usagePath, []byte(usage), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
command := exec.Command(tsc,
|
command := exec.Command(tsc,
|
||||||
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path)
|
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath)
|
||||||
if output, err := command.CombinedOutput(); err != nil {
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
|
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ type Fun struct {
|
|||||||
methods map[string]methodInfo
|
methods map[string]methodInfo
|
||||||
routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器(精确匹配)
|
routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器(精确匹配)
|
||||||
wildcardRoutes map[string][]wildcardRoute
|
wildcardRoutes map[string][]wildcardRoute
|
||||||
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value
|
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value
|
||||||
guards []*any // 全局 Guard
|
guards []*any // 全局 Guard
|
||||||
serviceGuards map[string][]*any // 服务级 Guard,按服务名
|
serviceGuards map[string][]*any // 服务级 Guard,按服务名
|
||||||
}
|
}
|
||||||
|
|
||||||
// wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册):
|
// wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册):
|
||||||
@@ -68,16 +68,7 @@ func GetFun() *Fun {
|
|||||||
//
|
//
|
||||||
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行
|
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行
|
||||||
func (f *Fun) BindService(service any, guardList ...Guard) {
|
func (f *Fun) BindService(service any, guardList ...Guard) {
|
||||||
t := reflect.TypeOf(service)
|
t, name := serviceType(service)
|
||||||
// 必须是指针指向的结构体,匿名类型无法注册
|
|
||||||
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
|
|
||||||
panic("fun: BindService requires a pointer to a struct")
|
|
||||||
}
|
|
||||||
name := t.Elem().Name()
|
|
||||||
if name == "" {
|
|
||||||
panic("fun: BindService requires a named type")
|
|
||||||
}
|
|
||||||
|
|
||||||
boxWired(service, f)
|
boxWired(service, f)
|
||||||
|
|
||||||
serviceGuards := make([]*any, 0, len(guardList))
|
serviceGuards := make([]*any, 0, len(guardList))
|
||||||
@@ -86,7 +77,29 @@ func (f *Fun) BindService(service any, guardList ...Guard) {
|
|||||||
serviceGuards = append(serviceGuards, serviceGuardWired(guard, f))
|
serviceGuards = append(serviceGuards, serviceGuardWired(guard, f))
|
||||||
}
|
}
|
||||||
f.serviceGuards[name] = serviceGuards
|
f.serviceGuards[name] = serviceGuards
|
||||||
|
f.bindServiceMethods(t, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindServiceForGen registers service metadata for code generation without
|
||||||
|
// constructing runtime dependencies or guards.
|
||||||
|
func (f *Fun) BindServiceForGen(service any) {
|
||||||
|
t, name := serviceType(service)
|
||||||
|
f.bindServiceMethods(t, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceType(service any) (reflect.Type, string) {
|
||||||
|
t := reflect.TypeOf(service)
|
||||||
|
if t == nil || t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
|
||||||
|
panic("fun: BindService requires a pointer to a struct")
|
||||||
|
}
|
||||||
|
name := t.Elem().Name()
|
||||||
|
if name == "" {
|
||||||
|
panic("fun: BindService requires a named type")
|
||||||
|
}
|
||||||
|
return t, name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fun) bindServiceMethods(t reflect.Type, name string) {
|
||||||
for m := range t.Methods() {
|
for m := range t.Methods() {
|
||||||
m := m
|
m := m
|
||||||
// Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法
|
// Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package fun
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -31,6 +32,16 @@ type ZebraGenSvc struct{}
|
|||||||
|
|
||||||
func (*ZebraGenSvc) Watch() (*Stream, error) { return &Stream{}, nil }
|
func (*ZebraGenSvc) Watch() (*Stream, error) { return &Stream{}, nil }
|
||||||
|
|
||||||
|
type GenOnlyDependency struct{}
|
||||||
|
|
||||||
|
func (*GenOnlyDependency) New() { panic("generation initialized a runtime dependency") }
|
||||||
|
|
||||||
|
type DependencyGenSvc struct {
|
||||||
|
Dependency *GenOnlyDependency
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DependencyGenSvc) Ping() error { return nil }
|
||||||
|
|
||||||
func isolateGeneratorGlobals(t *testing.T) {
|
func isolateGeneratorGlobals(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
oldFun, oldDirectory := fun, directory
|
oldFun, oldDirectory := fun, directory
|
||||||
@@ -69,6 +80,18 @@ func generatedFiles(t *testing.T, root string) map[string]string {
|
|||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBindServiceForGenDoesNotInitializeDependencies(t *testing.T) {
|
||||||
|
isolateGeneratorGlobals(t)
|
||||||
|
f := GetFun()
|
||||||
|
f.BindServiceForGen(&DependencyGenSvc{})
|
||||||
|
if _, ok := f.methods["DependencyGenSvc.Ping"]; !ok {
|
||||||
|
t.Fatal("generation-only service method was not registered")
|
||||||
|
}
|
||||||
|
if _, ok := f.boxes.Load(reflect.TypeFor[*GenOnlyDependency]()); ok {
|
||||||
|
t.Fatal("generation-only registration stored a runtime dependency")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
|
func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
|
||||||
isolateGeneratorGlobals(t)
|
isolateGeneratorGlobals(t)
|
||||||
f := GetFun()
|
f := GetFun()
|
||||||
@@ -87,6 +110,20 @@ func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
|
|||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := read("client.ts")
|
||||||
|
for _, want := range []string{
|
||||||
|
`state?: Record<string, string>;`,
|
||||||
|
`export type ResponseContext = {`,
|
||||||
|
`readonly requestState: Readonly<Record<string, string>>;`,
|
||||||
|
`readonly response?: Response;`,
|
||||||
|
`state = { ...this.state, ...options?.state };`,
|
||||||
|
`interceptor(serviceName, methodName, current, context)`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(client, want) {
|
||||||
|
t.Errorf("client.ts missing %q:\n%s", want, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
alpha := read("alphaGenSvc.ts")
|
alpha := read("alphaGenSvc.ts")
|
||||||
if first := strings.SplitN(alpha, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions } from "./client";` {
|
if first := strings.SplitN(alpha, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions } from "./client";` {
|
||||||
t.Fatalf("unexpected request-only imports: %s", first)
|
t.Fatalf("unexpected request-only imports: %s", first)
|
||||||
|
|||||||
+146
-26
@@ -16,10 +16,12 @@ export type resultStatus = 0 | 1 | 2 | 4 | 5;
|
|||||||
|
|
||||||
export type RequestOptions = {
|
export type RequestOptions = {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
state?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StreamOptions = {
|
export type StreamOptions = {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
state?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestInterceptor = (
|
export type RequestInterceptor = (
|
||||||
@@ -29,12 +31,24 @@ export type RequestInterceptor = (
|
|||||||
dto?: any
|
dto?: any
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
|
|
||||||
|
export type ResponseContext = {
|
||||||
|
readonly requestState: Readonly<Record<string, string>>;
|
||||||
|
readonly response?: Response;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResponseInterceptor = (
|
export type ResponseInterceptor = (
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
methodName: string,
|
methodName: string,
|
||||||
result: result<any>
|
result: result<any>
|
||||||
) => Promise<result<any> | void> | result<any> | void;
|
) => Promise<result<any> | void> | result<any> | void;
|
||||||
|
|
||||||
|
export type ContextResponseInterceptor = (
|
||||||
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
result: result<any>,
|
||||||
|
context: ResponseContext
|
||||||
|
) => Promise<result<any> | void> | result<any> | void;
|
||||||
|
|
||||||
function messageOf(error: unknown): string {
|
function messageOf(error: unknown): string {
|
||||||
if (error instanceof Error && error.message) return error.message;
|
if (error instanceof Error && error.message) return error.message;
|
||||||
if (typeof error === "string" && error) return error;
|
if (typeof error === "string" && error) return error;
|
||||||
@@ -137,7 +151,7 @@ export class Client {
|
|||||||
private url: string;
|
private url: string;
|
||||||
private state: Record<string, string> = {};
|
private state: Record<string, string> = {};
|
||||||
private requestInterceptors: RequestInterceptor[] = [];
|
private requestInterceptors: RequestInterceptor[] = [];
|
||||||
private responseInterceptors: ResponseInterceptor[] = [];
|
private responseInterceptors: ContextResponseInterceptor[] = [];
|
||||||
|
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
this.url = url.replace(/\/+$/, "");
|
this.url = url.replace(/\/+$/, "");
|
||||||
@@ -151,19 +165,26 @@ export class Client {
|
|||||||
this.requestInterceptors.push(interceptor);
|
this.requestInterceptors.push(interceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
addResponseInterceptor(interceptor: ResponseInterceptor) {
|
addResponseInterceptor(interceptor: ResponseInterceptor): void;
|
||||||
this.responseInterceptors.push(interceptor);
|
addResponseInterceptor(interceptor: ContextResponseInterceptor): void;
|
||||||
|
addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) {
|
||||||
|
this.responseInterceptors.push(interceptor as ContextResponseInterceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async interceptResponse(
|
private async interceptResponse(
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
methodName: string,
|
methodName: string,
|
||||||
initial: result<any>
|
initial: result<any>,
|
||||||
|
requestState: Readonly<Record<string, string>>,
|
||||||
|
response?: Response
|
||||||
): Promise<result<any>> {
|
): Promise<result<any>> {
|
||||||
let current = initial;
|
let current = initial;
|
||||||
|
const context: ResponseContext = Object.freeze(
|
||||||
|
response === undefined ? { requestState } : { requestState, response }
|
||||||
|
);
|
||||||
for (const interceptor of this.responseInterceptors) {
|
for (const interceptor of this.responseInterceptors) {
|
||||||
try {
|
try {
|
||||||
const replaced = await interceptor(serviceName, methodName, current);
|
const replaced = await interceptor(serviceName, methodName, current, context);
|
||||||
if (replaced) current = replaced;
|
if (replaced) current = replaced;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `);
|
current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `);
|
||||||
@@ -172,12 +193,19 @@ export class Client {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestState(serviceName: string, methodName: string, dto: any): Promise<Record<string, string>> {
|
private async interceptRequest(
|
||||||
const state: Record<string, string> = { ...this.state };
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
state: Record<string, string>,
|
||||||
|
dto: any
|
||||||
|
): Promise<void> {
|
||||||
for (const interceptor of this.requestInterceptors) {
|
for (const interceptor of this.requestInterceptors) {
|
||||||
await interceptor(serviceName, methodName, state, dto);
|
await interceptor(serviceName, methodName, state, dto);
|
||||||
}
|
}
|
||||||
return state;
|
}
|
||||||
|
|
||||||
|
private snapshotState(state: Record<string, string>): Readonly<Record<string, string>> {
|
||||||
|
return Object.freeze({ ...state });
|
||||||
}
|
}
|
||||||
|
|
||||||
async request<T>(
|
async request<T>(
|
||||||
@@ -188,12 +216,45 @@ export class Client {
|
|||||||
): Promise<result<T>> {
|
): Promise<result<T>> {
|
||||||
let state: Record<string, string>;
|
let state: Record<string, string>;
|
||||||
try {
|
try {
|
||||||
state = await this.requestState(serviceName, methodName, dto);
|
state = { ...this.state, ...options?.state };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `)
|
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
|
||||||
|
Object.freeze({})
|
||||||
|
) as result<T>;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.interceptRequest(serviceName, methodName, state, dto);
|
||||||
|
} catch (error) {
|
||||||
|
let requestState: Readonly<Record<string, string>>;
|
||||||
|
try {
|
||||||
|
requestState = this.snapshotState(state);
|
||||||
|
} catch (snapshotError) {
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
|
||||||
|
Object.freeze({})
|
||||||
|
) as result<T>;
|
||||||
|
}
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
|
||||||
|
requestState
|
||||||
|
) as result<T>;
|
||||||
|
}
|
||||||
|
let requestState: Readonly<Record<string, string>>;
|
||||||
|
try {
|
||||||
|
requestState = this.snapshotState(state);
|
||||||
|
} catch (error) {
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
|
||||||
|
Object.freeze({})
|
||||||
) as result<T>;
|
) as result<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +264,7 @@ export class Client {
|
|||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
data: dto,
|
data: dto,
|
||||||
...(Object.keys(state).length ? { state } : {}),
|
...(Object.keys(requestState).length ? { state: requestState } : {}),
|
||||||
});
|
});
|
||||||
if (serialized === undefined) throw new Error("serialization produced no output");
|
if (serialized === undefined) throw new Error("serialization produced no output");
|
||||||
body = serialized;
|
body = serialized;
|
||||||
@@ -211,13 +272,15 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `)
|
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
|
||||||
|
requestState
|
||||||
) as result<T>;
|
) as result<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let output: result<any>;
|
let output: result<any>;
|
||||||
|
let response: Response | undefined;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(` + "`${this.url}/cell`" + `, {
|
response = await fetch(` + "`${this.url}/cell`" + `, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body,
|
body,
|
||||||
@@ -231,7 +294,7 @@ export class Client {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
output = requestFailure(error, options?.signal, false);
|
output = requestFailure(error, options?.signal, false);
|
||||||
}
|
}
|
||||||
return await this.interceptResponse(serviceName, methodName, output) as result<T>;
|
return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stream<T>(
|
async stream<T>(
|
||||||
@@ -243,12 +306,45 @@ export class Client {
|
|||||||
): Promise<result<void>> {
|
): Promise<result<void>> {
|
||||||
let state: Record<string, string>;
|
let state: Record<string, string>;
|
||||||
try {
|
try {
|
||||||
state = await this.requestState(serviceName, methodName, dto);
|
state = { ...this.state, ...options?.state };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `)
|
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
|
||||||
|
Object.freeze({})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.interceptRequest(serviceName, methodName, state, dto);
|
||||||
|
} catch (error) {
|
||||||
|
let requestState: Readonly<Record<string, string>>;
|
||||||
|
try {
|
||||||
|
requestState = this.snapshotState(state);
|
||||||
|
} catch (snapshotError) {
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
|
||||||
|
Object.freeze({})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
|
||||||
|
requestState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let requestState: Readonly<Record<string, string>>;
|
||||||
|
try {
|
||||||
|
requestState = this.snapshotState(state);
|
||||||
|
} catch (error) {
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
|
||||||
|
Object.freeze({})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +354,7 @@ export class Client {
|
|||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
data: dto,
|
data: dto,
|
||||||
...(Object.keys(state).length ? { state } : {}),
|
...(Object.keys(requestState).length ? { state: requestState } : {}),
|
||||||
});
|
});
|
||||||
if (serialized === undefined) throw new Error("serialization produced no output");
|
if (serialized === undefined) throw new Error("serialization produced no output");
|
||||||
body = serialized;
|
body = serialized;
|
||||||
@@ -266,7 +362,8 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `)
|
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
|
||||||
|
requestState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +379,8 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
requestFailure(error, options?.signal, true)
|
requestFailure(error, options?.signal, true),
|
||||||
|
requestState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +392,18 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
responseReadFailure(error, response, options?.signal, true)
|
responseReadFailure(error, response, options?.signal, true),
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await this.interceptResponse(serviceName, methodName, parseResult(response, text));
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
parseResult(response, text),
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType(response) !== "application/x-ndjson") {
|
if (mediaType(response) !== "application/x-ndjson") {
|
||||||
@@ -308,7 +414,9 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
responseReadFailure(error, response, options?.signal, true)
|
responseReadFailure(error, response, options?.signal, true),
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const rpcResult = parseResult(response, text);
|
const rpcResult = parseResult(response, text);
|
||||||
@@ -317,12 +425,20 @@ export class Client {
|
|||||||
methodName,
|
methodName,
|
||||||
rpcResult.status === 0
|
rpcResult.status === 0
|
||||||
? failure(1, "Expected application/x-ndjson response")
|
? failure(1, "Expected application/x-ndjson response")
|
||||||
: rpcResult
|
: rpcResult,
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
return await this.interceptResponse(serviceName, methodName, { status: 0 });
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
{ status: 0 },
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||||
@@ -332,7 +448,9 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
responseReadFailure(error, response, options?.signal, true)
|
responseReadFailure(error, response, options?.signal, true),
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||||
@@ -426,7 +544,9 @@ export class Client {
|
|||||||
return await this.interceptResponse(
|
return await this.interceptResponse(
|
||||||
serviceName,
|
serviceName,
|
||||||
methodName,
|
methodName,
|
||||||
failed || { status: 0 }
|
failed || { status: 0 },
|
||||||
|
requestState,
|
||||||
|
response
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|||||||
Reference in New Issue
Block a user