From 996d1d42fbfb670ac6746292f81e1e2437f583a8 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Tue, 27 Jan 2026 12:18:46 +0530 Subject: [PATCH] refactor(grpc): shared grpc request builder context to remove redundency Signed-off-by: Devyash Saini --- packages/scrawn/src/core/grpc/callContext.ts | 113 ++++++ packages/scrawn/src/core/grpc/client.ts | 309 +++++++-------- packages/scrawn/src/core/grpc/index.ts | 11 +- .../scrawn/src/core/grpc/requestBuilder.ts | 352 +++++++++--------- .../src/core/grpc/streamRequestBuilder.ts | 62 ++- packages/scrawn/src/core/grpc/types.ts | 47 +++ .../tests/unit/grpc/requestBuilder.test.ts | 25 +- 7 files changed, 543 insertions(+), 376 deletions(-) create mode 100644 packages/scrawn/src/core/grpc/callContext.ts diff --git a/packages/scrawn/src/core/grpc/callContext.ts b/packages/scrawn/src/core/grpc/callContext.ts new file mode 100644 index 0000000..43fda73 --- /dev/null +++ b/packages/scrawn/src/core/grpc/callContext.ts @@ -0,0 +1,113 @@ +/** + * Shared call context for gRPC request builders. + * + * This module encapsulates the common state and operations used by both + * `RequestBuilder` (unary) and `StreamRequestBuilder` (client-streaming), + * eliminating code duplication while keeping each builder focused on its + * specific execution model. + * + * Design: + * - The context is immutable after construction; headers are accumulated via + * a mutable map owned by the context but exposed through a controlled API. + * - Builders receive the context via constructor injection and delegate header + * management and logging to it. + * - The context does NOT own payload or execution logic; those remain in the + * individual builders to preserve separation of concerns. + */ + +import type { Client, Transport } from '@connectrpc/connect'; +import type { ServiceType } from '@bufbuild/protobuf'; +import { createClient } from '@connectrpc/connect'; +import { ScrawnLogger } from '../../utils/logger.js'; +import type { ServiceMethodNames, Headers } from './types.js'; + +/** + * Shared context for a single gRPC call. + * + * Holds the typed client, method name, headers, and logger. Provides helper + * methods used by both unary and streaming request builders. + * + * @template S - The gRPC service type + * @template M - The method name on the service (string literal) + */ +export class GrpcCallContext< + S extends ServiceType, + M extends ServiceMethodNames +> { + /** The typed Connect client for the service */ + public readonly client: Client; + + /** The method name being invoked */ + public readonly methodName: M; + + /** Accumulated headers for the call */ + private readonly headers: Headers = {}; + + /** Logger scoped to the builder type */ + public readonly log: ScrawnLogger; + + /** + * Constructs a new GrpcCallContext. + * + * @param transport - The Connect transport to use + * @param service - The gRPC service definition + * @param methodName - The method to invoke + * @param loggerName - Name for the logger (e.g., 'RequestBuilder') + */ + constructor( + transport: Transport, + service: S, + methodName: M, + loggerName: string + ) { + this.client = createClient(service, transport); + this.methodName = methodName; + this.log = new ScrawnLogger(loggerName); + } + + /** + * Add a header to the call. + * + * @param key - Header name + * @param value - Header value + */ + addHeader(key: string, value: string): void { + this.headers[key] = value; + } + + /** + * Get a shallow copy of the current headers. + * Used when making the actual gRPC call. + */ + getHeaders(): Headers { + return { ...this.headers }; + } + + /** + * Log the start of a call (info level). + */ + logCallStart(): void { + this.log.info(`Making gRPC call to ${String(this.methodName)}`); + this.log.debug(`Headers: ${JSON.stringify(this.headers)}`); + } + + /** + * Log successful completion of a call (info level). + */ + logCallSuccess(): void { + this.log.info(`Successfully completed gRPC call to ${String(this.methodName)}`); + } + + /** + * Log a call failure (error level). + * + * @param error - The error that occurred + */ + logCallError(error: unknown): void { + this.log.error( + `gRPC call to ${String(this.methodName)} failed: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +} diff --git a/packages/scrawn/src/core/grpc/client.ts b/packages/scrawn/src/core/grpc/client.ts index ea72df9..22ea648 100644 --- a/packages/scrawn/src/core/grpc/client.ts +++ b/packages/scrawn/src/core/grpc/client.ts @@ -1,156 +1,163 @@ -/** - * Type-safe gRPC client abstraction layer. - * - * This module provides the main entry point for making gRPC calls with - * full compile-time type safety and a beautiful fluent API. - * - * @example - * ```typescript - * const client = new GrpcClient('https://api.scrawn.dev'); - * - * const response = await client - * .newCall(EventService, 'registerEvent') - * .addHeader('Authorization', `Bearer ${apiKey}`) - * .addHeader('x-request-id', '123') - * .addPayload({ - * type: EventType.SDK_CALL, - * userId: 'user_123', - * data: { case: 'sdkCall', value: new SDKCall({ sdkCallType: SDKCallType.RAW, debitAmount: 10 }) } - * }) - * .request(); - * ``` - */ - +/** + * Type-safe gRPC client abstraction layer. + * + * This module provides the main entry point for making gRPC calls with + * full compile-time type safety and a beautiful fluent API. + * + * @example + * ```typescript + * const client = new GrpcClient('https://api.scrawn.dev'); + * + * const response = await client + * .newCall(EventService, 'registerEvent') + * .addHeader('Authorization', `Bearer ${apiKey}`) + * .addHeader('x-request-id', '123') + * .addPayload({ + * type: EventType.SDK_CALL, + * userId: 'user_123', + * data: { case: 'sdkCall', value: new SDKCall({ sdkCallType: SDKCallType.RAW, debitAmount: 10 }) } + * }) + * .request(); + * ``` + */ + import type { Transport } from '@connectrpc/connect'; import type { ServiceType } from '@bufbuild/protobuf'; import { createConnectTransport } from '@connectrpc/connect-node'; +import { GrpcCallContext } from './callContext.js'; import { RequestBuilder } from './requestBuilder.js'; import { StreamRequestBuilder } from './streamRequestBuilder.js'; import { ScrawnLogger } from '../../utils/logger.js'; import type { ServiceMethodNames } from './types.js'; import { ScrawnConfig } from '../../config.js'; - -const log = new ScrawnLogger('GrpcClient'); - -/** - * Main gRPC client for making type-safe API calls. - * - * This class manages the underlying transport and provides a factory method - * for creating type-safe request builders. Each request builder is bound to - * a specific service and method, ensuring compile-time type safety. - * - * The client handles: - * - Transport configuration (HTTP/1.1) - * - Request building with fluent API - * - Header management (auth should be added via .addHeader()) - * - Error handling and logging - * - * @example - * ```typescript - * // Initialize the client - * const client = new GrpcClient('https://api.scrawn.dev'); - * - * // Fetch credentials from your auth system - * const creds = await getCredentials(); - * - * // Make a call to EventService.registerEvent - * const eventResponse = await client - * .newCall(EventService, 'registerEvent') - * .addHeader('Authorization', `Bearer ${creds.apiKey}`) - * .addPayload({ type: EventType.SDK_CALL, userId: 'u123', ... }) - * .request(); - * - * // Make a call to AuthService.createAPIKey - * const authResponse = await client - * .newCall(AuthService, 'createAPIKey') - * .addHeader('Authorization', `Bearer ${creds.apiKey}`) - * .addPayload({ name: 'My Key', expiresIn: 3600n }) - * .request(); - * ``` - */ -export class GrpcClient { - /** The underlying gRPC transport */ - private readonly transport: Transport; - - /** Base URL for API calls */ - private readonly baseURL: string; - - /** - * Create a new GrpcClient. - * - * @param baseURL - The base URL of the gRPC API (e.g., 'https://api.scrawn.dev') - * - * @example - * ```typescript - * const client = new GrpcClient('https://api.scrawn.dev'); - * ``` - */ - constructor(baseURL: string) { - this.baseURL = baseURL; - - log.info(`Initializing gRPC client for ${baseURL}`); - - this.transport = createConnectTransport({ - baseUrl: this.baseURL, - httpVersion: ScrawnConfig.grpc.httpVersion, - useBinaryFormat: true, // Use binary protobuf (smaller than JSON) - }); - - log.info(`gRPC client initialized with HTTP/${ScrawnConfig.grpc.httpVersion}`); - } - - /** - * Create a new request builder for a specific service method. - * - * This is the entry point for making gRPC calls. The method is fully type-safe: - * - Service parameter must be a valid gRPC service - * - Method name must exist on the service (autocomplete provided) - * - Payload type is inferred from the method - * - Response type is inferred from the method - * - * @template S - The gRPC service type (auto-inferred) - * @template M - The method name (auto-inferred and validated) - * - * @param service - The gRPC service definition (e.g., EventService, AuthService) - * @param method - The method name as a string literal (e.g., 'registerEvent', 'createAPIKey') - * @returns A new RequestBuilder for chaining headers, payload, and execution - * - * @example - * ```typescript - * // EventService.registerEvent - * const eventBuilder = client.newCall(EventService, 'registerEvent'); - * // Payload type is RegisterEventRequest - * // Response type is RegisterEventResponse - * - * // AuthService.createAPIKey - * const authBuilder = client.newCall(AuthService, 'createAPIKey'); - * // Payload type is CreateAPIKeyRequest - * // Response type is CreateAPIKeyResponse - * - * // Type error - method doesn't exist! - * // const invalid = client.newCall(EventService, 'nonExistentMethod'); - * ``` - */ - newCall>( - service: S, - method: M - ): RequestBuilder { - log.debug(`Creating new request builder for ${service.typeName}.${method}`); - return new RequestBuilder(this.transport, service, method); - } - - /** - * Get the base URL of this client. - * - * @returns The base URL string - */ - getBaseURL(): string { - return this.baseURL; - } - + +const log = new ScrawnLogger('GrpcClient'); + /** + * Main gRPC client for making type-safe API calls. + * + * This class manages the underlying transport and provides factory methods + * for creating type-safe request builders. Each request builder is bound to + * a specific service and method, ensuring compile-time type safety. + * + * The client handles: + * - Transport configuration (HTTP/1.1) + * - Request building with fluent API + * - Header management (auth should be added via .addHeader()) + * - Error handling and logging + * + * @example + * ```typescript + * // Initialize the client + * const client = new GrpcClient('https://api.scrawn.dev'); + * + * // Fetch credentials from your auth system + * const creds = await getCredentials(); + * + * // Make a call to EventService.registerEvent + * const eventResponse = await client + * .newCall(EventService, 'registerEvent') + * .addHeader('Authorization', `Bearer ${creds.apiKey}`) + * .addPayload({ type: EventType.SDK_CALL, userId: 'u123', ... }) + * .request(); + * + * // Make a call to AuthService.createAPIKey + * const authResponse = await client + * .newCall(AuthService, 'createAPIKey') + * .addHeader('Authorization', `Bearer ${creds.apiKey}`) + * .addPayload({ name: 'My Key', expiresIn: 3600n }) + * .request(); + * ``` + */ +export class GrpcClient { + /** The underlying gRPC transport */ + private readonly transport: Transport; + + /** Base URL for API calls */ + private readonly baseURL: string; + + /** + * Create a new GrpcClient. + * + * @param baseURL - The base URL of the gRPC API (e.g., 'https://api.scrawn.dev') + * + * @example + * ```typescript + * const client = new GrpcClient('https://api.scrawn.dev'); + * ``` + */ + constructor(baseURL: string) { + this.baseURL = baseURL; + + log.info(`Initializing gRPC client for ${baseURL}`); + + this.transport = createConnectTransport({ + baseUrl: this.baseURL, + httpVersion: ScrawnConfig.grpc.httpVersion, + useBinaryFormat: true, // Use binary protobuf (smaller than JSON) + }); + + log.info(`gRPC client initialized with HTTP/${ScrawnConfig.grpc.httpVersion}`); + } + + /** + * Create a new request builder for a specific service method. + * + * This is the entry point for making unary gRPC calls. The method is fully type-safe: + * - Service parameter must be a valid gRPC service + * - Method name must exist on the service (autocomplete provided) + * - Payload type is inferred from the method + * - Response type is inferred from the method + * + * @template S - The gRPC service type (auto-inferred) + * @template M - The method name (auto-inferred and validated) + * + * @param service - The gRPC service definition (e.g., EventService, AuthService) + * @param method - The method name as a string literal (e.g., 'registerEvent', 'createAPIKey') + * @returns A new RequestBuilder for chaining headers, payload, and execution + * + * @example + * ```typescript + * // EventService.registerEvent + * const eventBuilder = client.newCall(EventService, 'registerEvent'); + * // Payload type is RegisterEventRequest + * // Response type is RegisterEventResponse + * + * // AuthService.createAPIKey + * const authBuilder = client.newCall(AuthService, 'createAPIKey'); + * // Payload type is CreateAPIKeyRequest + * // Response type is CreateAPIKeyResponse + * + * // Type error - method doesn't exist! + * // const invalid = client.newCall(EventService, 'nonExistentMethod'); + * ``` + */ + newCall>( + service: S, + method: M + ): RequestBuilder { + log.debug(`Creating new request builder for ${service.typeName}.${String(method)}`); + const ctx = new GrpcCallContext( + this.transport, + service, + method, + 'RequestBuilder' + ); + return new RequestBuilder(ctx); + } + + /** + * Get the base URL of this client. + * + * @returns The base URL string + */ + getBaseURL(): string { + return this.baseURL; + } + + /** * Get the underlying transport (for advanced use cases). - * + * * @returns The Connect transport instance * @internal */ @@ -160,20 +167,20 @@ export class GrpcClient { /** * Create a new stream request builder for a client-streaming service method. - * + * * This is the entry point for making client-streaming gRPC calls. The method is fully type-safe: * - Service parameter must be a valid gRPC service * - Method name must exist on the service (autocomplete provided) * - Payload type is inferred from the method * - Response type is inferred from the method - * + * * @template S - The gRPC service type (auto-inferred) * @template M - The method name (auto-inferred and validated) - * + * * @param service - The gRPC service definition (e.g., EventService) * @param method - The method name as a string literal (e.g., 'streamEvents') * @returns A new StreamRequestBuilder for chaining headers and streaming payloads - * + * * @example * ```typescript * // EventService.streamEvents (client-streaming) @@ -189,6 +196,12 @@ export class GrpcClient { method: M ): StreamRequestBuilder { log.debug(`Creating new stream request builder for ${service.typeName}.${String(method)}`); - return new StreamRequestBuilder(this.transport, service, method); + const ctx = new GrpcCallContext( + this.transport, + service, + method, + 'StreamRequestBuilder' + ); + return new StreamRequestBuilder(ctx); } } diff --git a/packages/scrawn/src/core/grpc/index.ts b/packages/scrawn/src/core/grpc/index.ts index d82f377..930cdc4 100644 --- a/packages/scrawn/src/core/grpc/index.ts +++ b/packages/scrawn/src/core/grpc/index.ts @@ -1,13 +1,14 @@ /** * gRPC abstraction layer - Type-safe fluent API for gRPC calls. - * + * * This module provides a beautiful, type-safe interface for making gRPC calls * with automatic type inference, compile-time validation, and a fluent API. - * + * * @module grpc */ export { GrpcClient } from './client.js'; +export { GrpcCallContext } from './callContext.js'; export { RequestBuilder } from './requestBuilder.js'; export { StreamRequestBuilder } from './streamRequestBuilder.js'; export type { @@ -16,4 +17,10 @@ export type { MethodOutput, Headers, RequestState, + MethodKind, + MethodsOfKind, + UnaryMethodNames, + ClientStreamingMethodNames, + ServerStreamingMethodNames, + BidiStreamingMethodNames, } from './types.js'; diff --git a/packages/scrawn/src/core/grpc/requestBuilder.ts b/packages/scrawn/src/core/grpc/requestBuilder.ts index 1663b31..2e1be7a 100644 --- a/packages/scrawn/src/core/grpc/requestBuilder.ts +++ b/packages/scrawn/src/core/grpc/requestBuilder.ts @@ -1,184 +1,168 @@ -/** - * Type-safe fluent API for building and executing gRPC requests. - * - * This module provides a beautiful chain-able interface that ensures: - * - Compile-time type safety for all operations - * - Autocomplete for service methods and payload fields - * - Runtime validation of request state - * - Clean separation of concerns - * - * @example - * ```typescript - * const response = await client - * .newCall(EventService, 'registerEvent') - * .addHeader('x-request-id', '123') - * .addPayload({ - * type: EventType.SDK_CALL, - * userId: 'user_123', - * data: { case: 'sdkCall', value: { sdkCallType: SDKCallType.RAW, debitAmount: 10 } } - * }) - * .request(); - * ``` - */ - -import type { Client, Transport } from '@connectrpc/connect'; -import type { ServiceType } from '@bufbuild/protobuf'; -import { createClient } from '@connectrpc/connect'; -import { ScrawnLogger } from '../../utils/logger.js'; -import type { - ServiceMethodNames, - MethodInput, - MethodOutput, - Headers, - RequestState, -} from './types.js'; - -const log = new ScrawnLogger('RequestBuilder'); - -/** - * Builder for constructing type-safe gRPC requests. - * - * This class implements a fluent interface where each method returns `this`, - * allowing for method chaining. Type parameters ensure that the payload type - * matches the selected service method at compile time. - * - * @template S - The gRPC service type - * @template M - The method name on the service (string literal) - */ -export class RequestBuilder< - S extends ServiceType, - M extends ServiceMethodNames -> { - private readonly client: Client; - private readonly methodName: M; - private readonly headers: Headers = {}; - private payload: any = null; - private readonly state: RequestState = { hasPayload: false }; - - /** - * @internal - * Constructs a new RequestBuilder. Use GrpcClient.newCall() instead. - */ - constructor( - transport: Transport, - service: S, - methodName: M - ) { - // Create a typed client for this service - this.client = createClient(service, transport); - this.methodName = methodName; - } - - /** - * Add a header to the request. - * - * Headers are sent with the gRPC call and can be used for authentication, - * tracing, or custom metadata. This method can be called multiple times - * to add multiple headers. - * - * @param key - Header name (e.g., 'authorization', 'x-request-id') - * @param value - Header value - * @returns The builder instance for chaining - * - * @example - * ```typescript - * builder - * .addHeader('authorization', 'Bearer token123') - * .addHeader('x-request-id', 'abc-def-123') - * ``` - */ - addHeader(key: string, value: string): this { - this.headers[key] = value; - return this; - } - - /** - * Set the request payload. - * - * The payload type is automatically inferred from the service method, - * providing full autocomplete and type checking. This method can only - * be called once per request to prevent accidental overwrites. - * - * @param payload - The request payload (type-checked against the method's input type) - * @returns The builder instance for chaining - * @throws Error if payload has already been set - * - * @example - * ```typescript - * builder.addPayload({ - * type: EventType.SDK_CALL, - * userId: 'user_123', - * data: { - * case: 'sdkCall', - * value: { - * sdkCallType: SDKCallType.RAW, - * debitAmount: 10 - * } - * } - * }) - * ``` - */ - addPayload(payload: MethodInput extends infer T ? Partial : never): this { - if (this.state.hasPayload) { - throw new Error( - 'Payload has already been set. Cannot add payload multiple times. ' + - 'Create a new request builder if you need to make another call.' - ); - } - - this.payload = payload; - this.state.hasPayload = true; - return this; - } - - /** - * Execute the gRPC request. - * - * Validates that a payload has been set, then makes the actual gRPC call - * using the configured service, method, headers, and payload. The response - * type is automatically inferred from the service method. - * - * @returns A promise that resolves to the typed response - * @throws Error if no payload has been set - * @throws Error if the gRPC call fails - * - * @example - * ```typescript - * const response = await builder - * .addPayload({ userId: 'user_123', ... }) - * .request(); - * - * // response is fully typed based on the method's output type - * console.log(response.random); - * ``` - */ - async request(): Promise> { - if (!this.state.hasPayload || this.payload === null) { - throw new Error( - 'Cannot make request without payload. Call addPayload() first.' - ); - } - - try { - log.info(`Making gRPC call to ${this.methodName}`); - log.debug(`Headers: ${JSON.stringify(this.headers)}`); - log.debug(`Payload: ${JSON.stringify(this.payload)}`); - - // The actual gRPC call - fully type-safe! - const response = await (this.client[this.methodName] as any)( - this.payload, - { headers: this.headers } - ); - - log.info(`Successfully completed gRPC call to ${this.methodName}`); - return response as MethodOutput; - } catch (error) { - log.error( - `gRPC call to ${this.methodName} failed: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - throw error; - } - } -} +/** + * Type-safe fluent API for building and executing unary gRPC requests. + * + * This module provides a chain-able interface that ensures: + * - Compile-time type safety for all operations + * - Autocomplete for service methods and payload fields + * - Runtime validation of request state + * - Clean separation of concerns + * + * @example + * ```typescript + * const response = await client + * .newCall(EventService, 'registerEvent') + * .addHeader('x-request-id', '123') + * .addPayload({ + * type: EventType.SDK_CALL, + * userId: 'user_123', + * data: { case: 'sdkCall', value: { sdkCallType: SDKCallType.RAW, debitAmount: 10 } } + * }) + * .request(); + * ``` + */ + +import type { ServiceType } from '@bufbuild/protobuf'; +import type { + ServiceMethodNames, + MethodInput, + MethodOutput, + RequestState, +} from './types.js'; +import type { GrpcCallContext } from './callContext.js'; + +/** + * Builder for constructing type-safe unary gRPC requests. + * + * This class implements a fluent interface where each method returns `this`, + * allowing for method chaining. Type parameters ensure that the payload type + * matches the selected service method at compile time. + * + * @template S - The gRPC service type + * @template M - The method name on the service (string literal) + */ +export class RequestBuilder< + S extends ServiceType, + M extends ServiceMethodNames +> { + private readonly ctx: GrpcCallContext; + private payload: MethodInput | null = null; + private readonly state: RequestState = { hasPayload: false }; + + /** + * @internal + * Constructs a new RequestBuilder. Use GrpcClient.newCall() instead. + * + * @param ctx - The shared call context (injected by GrpcClient) + */ + constructor(ctx: GrpcCallContext) { + this.ctx = ctx; + } + + /** + * Add a header to the request. + * + * Headers are sent with the gRPC call and can be used for authentication, + * tracing, or custom metadata. This method can be called multiple times + * to add multiple headers. + * + * @param key - Header name (e.g., 'authorization', 'x-request-id') + * @param value - Header value + * @returns The builder instance for chaining + * + * @example + * ```typescript + * builder + * .addHeader('authorization', 'Bearer token123') + * .addHeader('x-request-id', 'abc-def-123') + * ``` + */ + addHeader(key: string, value: string): this { + this.ctx.addHeader(key, value); + return this; + } + + /** + * Set the request payload. + * + * The payload type is automatically inferred from the service method, + * providing full autocomplete and type checking. This method can only + * be called once per request to prevent accidental overwrites. + * + * @param payload - The request payload (type-checked against the method's input type) + * @returns The builder instance for chaining + * @throws Error if payload has already been set + * + * @example + * ```typescript + * builder.addPayload({ + * type: EventType.SDK_CALL, + * userId: 'user_123', + * data: { + * case: 'sdkCall', + * value: { + * sdkCallType: SDKCallType.RAW, + * debitAmount: 10 + * } + * } + * }) + * ``` + */ + addPayload(payload: MethodInput extends infer T ? Partial : never): this { + if (this.state.hasPayload) { + throw new Error( + 'Payload has already been set. Cannot add payload multiple times. ' + + 'Create a new request builder if you need to make another call.' + ); + } + + this.payload = payload as unknown as MethodInput; + this.state.hasPayload = true; + return this; + } + + /** + * Execute the gRPC request. + * + * Validates that a payload has been set, then makes the actual gRPC call + * using the configured service, method, headers, and payload. The response + * type is automatically inferred from the service method. + * + * @returns A promise that resolves to the typed response + * @throws Error if no payload has been set + * @throws Error if the gRPC call fails + * + * @example + * ```typescript + * const response = await builder + * .addPayload({ userId: 'user_123', ... }) + * .request(); + * + * // response is fully typed based on the method's output type + * console.log(response.random); + * ``` + */ + async request(): Promise> { + if (!this.state.hasPayload || this.payload === null) { + throw new Error( + 'Cannot make request without payload. Call addPayload() first.' + ); + } + + try { + this.ctx.logCallStart(); + this.ctx.log.debug(`Payload: ${JSON.stringify(this.payload)}`); + + // The actual gRPC call - fully type-safe! + const response = await (this.ctx.client[this.ctx.methodName] as any)( + this.payload, + { headers: this.ctx.getHeaders() } + ); + + this.ctx.logCallSuccess(); + return response as MethodOutput; + } catch (error) { + this.ctx.logCallError(error); + throw error; + } + } +} diff --git a/packages/scrawn/src/core/grpc/streamRequestBuilder.ts b/packages/scrawn/src/core/grpc/streamRequestBuilder.ts index c5d6d47..6988de0 100644 --- a/packages/scrawn/src/core/grpc/streamRequestBuilder.ts +++ b/packages/scrawn/src/core/grpc/streamRequestBuilder.ts @@ -1,12 +1,12 @@ /** * Type-safe fluent API for building and executing client-streaming gRPC requests. - * + * * This module provides a chain-able interface for client-streaming RPC calls that ensures: * - Compile-time type safety for all operations * - Autocomplete for service methods and payload fields * - Runtime validation of request state * - Non-blocking stream consumption with internal buffering - * + * * @example * ```typescript * const response = await client @@ -16,26 +16,21 @@ * ``` */ -import type { Client, Transport } from '@connectrpc/connect'; import type { ServiceType } from '@bufbuild/protobuf'; -import { createClient } from '@connectrpc/connect'; -import { ScrawnLogger } from '../../utils/logger.js'; import type { ServiceMethodNames, MethodInput, MethodOutput, - Headers, } from './types.js'; - -const log = new ScrawnLogger('StreamRequestBuilder'); +import type { GrpcCallContext } from './callContext.js'; /** * Builder for constructing type-safe client-streaming gRPC requests. - * + * * This class implements a fluent interface where each method returns `this`, * allowing for method chaining. Type parameters ensure that the payload type * matches the selected service method at compile time. - * + * * @template S - The gRPC service type * @template M - The method name on the service (string literal) */ @@ -43,35 +38,29 @@ export class StreamRequestBuilder< S extends ServiceType, M extends ServiceMethodNames > { - private readonly client: Client; - private readonly methodName: M; - private readonly headers: Headers = {}; + private readonly ctx: GrpcCallContext; /** * @internal * Constructs a new StreamRequestBuilder. Use GrpcClient.newStreamCall() instead. + * + * @param ctx - The shared call context (injected by GrpcClient) */ - constructor( - transport: Transport, - service: S, - methodName: M - ) { - // Create a typed client for this service - this.client = createClient(service, transport); - this.methodName = methodName; + constructor(ctx: GrpcCallContext) { + this.ctx = ctx; } /** * Add a header to the request. - * + * * Headers are sent with the gRPC call and can be used for authentication, * tracing, or custom metadata. This method can be called multiple times * to add multiple headers. - * + * * @param key - Header name (e.g., 'authorization', 'x-request-id') * @param value - Header value * @returns The builder instance for chaining - * + * * @example * ```typescript * builder @@ -80,28 +69,28 @@ export class StreamRequestBuilder< * ``` */ addHeader(key: string, value: string): this { - this.headers[key] = value; + this.ctx.addHeader(key, value); return this; } /** * Execute the client-streaming gRPC request with the provided async iterable. - * + * * Consumes the async iterable and streams each item to the server. * The iterable is consumed in the background to avoid blocking the caller. * Returns a promise that resolves when the stream is complete and the server responds. - * + * * @param iterable - An async iterable of request payloads to stream * @returns A promise that resolves to the typed response * @throws Error if the gRPC call fails - * + * * @example * ```typescript * async function* generatePayloads() { * yield { type: EventType.AI_TOKEN_USAGE, userId: 'u123', data: { ... } }; * yield { type: EventType.AI_TOKEN_USAGE, userId: 'u123', data: { ... } }; * } - * + * * const response = await builder.stream(generatePayloads()); * console.log(`Processed ${response.eventsProcessed} events`); * ``` @@ -110,23 +99,18 @@ export class StreamRequestBuilder< iterable: AsyncIterable extends infer T ? Partial : never> ): Promise> { try { - log.info(`Starting client-streaming gRPC call to ${String(this.methodName)}`); - log.debug(`Headers: ${JSON.stringify(this.headers)}`); + this.ctx.logCallStart(); // The actual client-streaming gRPC call - const response = await (this.client[this.methodName])( + const response = await (this.ctx.client[this.ctx.methodName] as any)( iterable, - { headers: this.headers } + { headers: this.ctx.getHeaders() } ); - log.info(`Successfully completed streaming gRPC call to ${String(this.methodName)}`); + this.ctx.logCallSuccess(); return response as MethodOutput; } catch (error) { - log.error( - `Streaming gRPC call to ${String(this.methodName)} failed: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); + this.ctx.logCallError(error); throw error; } } diff --git a/packages/scrawn/src/core/grpc/types.ts b/packages/scrawn/src/core/grpc/types.ts index 56e87b8..8dad68d 100644 --- a/packages/scrawn/src/core/grpc/types.ts +++ b/packages/scrawn/src/core/grpc/types.ts @@ -68,3 +68,50 @@ export interface Headers { export interface RequestState { hasPayload: boolean; } + +/** + * Represents the kind of a gRPC method. + * Connect RPC method info includes `kind` with these values. + */ +export type MethodKind = 'unary' | 'server_streaming' | 'client_streaming' | 'bidi_streaming'; + +/** + * Extract methods of a specific kind from a service. + * + * @template S - The gRPC service type + * @template K - The method kind to filter by + */ +export type MethodsOfKind< + S extends ServiceType, + K extends MethodKind +> = { + [M in keyof S['methods'] & string]: S['methods'][M] extends { kind: infer MK } + ? MK extends K + ? M + : never + : never; +}[keyof S['methods'] & string]; + +/** + * Extract unary method names from a service. + * Unary methods accept a single request and return a single response. + */ +export type UnaryMethodNames = MethodsOfKind; + +/** + * Extract client-streaming method names from a service. + * Client-streaming methods accept a stream of requests and return a single response. + */ +export type ClientStreamingMethodNames = MethodsOfKind; + +/** + * Extract server-streaming method names from a service. + * Server-streaming methods accept a single request and return a stream of responses. + */ +export type ServerStreamingMethodNames = MethodsOfKind; + +/** + * Extract bidirectional-streaming method names from a service. + * Bidi-streaming methods accept a stream of requests and return a stream of responses. + */ +export type BidiStreamingMethodNames = MethodsOfKind; diff --git a/packages/scrawn/tests/unit/grpc/requestBuilder.test.ts b/packages/scrawn/tests/unit/grpc/requestBuilder.test.ts index ac2a243..8fc146b 100644 --- a/packages/scrawn/tests/unit/grpc/requestBuilder.test.ts +++ b/packages/scrawn/tests/unit/grpc/requestBuilder.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { RequestBuilder } from "../../../src/core/grpc/requestBuilder.js"; +import { GrpcCallContext } from "../../../src/core/grpc/callContext.js"; import { createMockTransport } from "../../mocks/mockTransport.js"; import { PaymentService } from "../../../src/gen/payment/v1/payment_connect.js"; import { CreateCheckoutLinkResponse } from "../../../src/gen/payment/v1/payment_pb.js"; @@ -16,7 +17,13 @@ describe("RequestBuilder", () => { }, }); - const builder = new RequestBuilder(transport, PaymentService, "createCheckoutLink"); + const ctx = new GrpcCallContext( + transport, + PaymentService, + "createCheckoutLink", + "RequestBuilder" + ); + const builder = new RequestBuilder(ctx); const response = await builder .addHeader("Authorization", "Bearer token") .addPayload({ userId: "user_1" }) @@ -30,7 +37,13 @@ describe("RequestBuilder", () => { unary: () => new CreateCheckoutLinkResponse({ checkoutLink: "" }), }); - const builder = new RequestBuilder(transport, PaymentService, "createCheckoutLink"); + const ctx = new GrpcCallContext( + transport, + PaymentService, + "createCheckoutLink", + "RequestBuilder" + ); + const builder = new RequestBuilder(ctx); await expect(builder.request()).rejects.toThrow("addPayload"); }); @@ -39,7 +52,13 @@ describe("RequestBuilder", () => { unary: () => new CreateCheckoutLinkResponse({ checkoutLink: "" }), }); - const builder = new RequestBuilder(transport, PaymentService, "createCheckoutLink"); + const ctx = new GrpcCallContext( + transport, + PaymentService, + "createCheckoutLink", + "RequestBuilder" + ); + const builder = new RequestBuilder(ctx); builder.addPayload({ userId: "user_1" }); expect(() => builder.addPayload({ userId: "user_2" })).toThrow(