@grest-ts/http 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +45 -0
  2. package/dist/src/client/GGHttpSchema.createClient.d.ts +26 -0
  3. package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -1
  4. package/dist/src/client/GGHttpSchema.createClient.js +11 -3
  5. package/dist/src/client/GGHttpSchema.createClient.js.map +1 -1
  6. package/dist/src/index-node.d.ts +2 -0
  7. package/dist/src/index-node.d.ts.map +1 -1
  8. package/dist/src/index-node.js +2 -0
  9. package/dist/src/index-node.js.map +1 -1
  10. package/dist/src/rpc/GGHttpRouteRPC.d.ts +19 -6
  11. package/dist/src/rpc/GGHttpRouteRPC.d.ts.map +1 -1
  12. package/dist/src/rpc/GGHttpRouteRPC.js +27 -5
  13. package/dist/src/rpc/GGHttpRouteRPC.js.map +1 -1
  14. package/dist/src/rpc/openApiHelpers.d.ts +21 -0
  15. package/dist/src/rpc/openApiHelpers.d.ts.map +1 -0
  16. package/dist/src/rpc/openApiHelpers.js +70 -0
  17. package/dist/src/rpc/openApiHelpers.js.map +1 -0
  18. package/dist/src/rpc/openApiSuccessResponse.d.ts +15 -0
  19. package/dist/src/rpc/openApiSuccessResponse.d.ts.map +1 -0
  20. package/dist/src/rpc/openApiSuccessResponse.js +32 -0
  21. package/dist/src/rpc/openApiSuccessResponse.js.map +1 -0
  22. package/dist/src/schema/GGHttpSchema.d.ts +66 -1
  23. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
  24. package/dist/src/schema/GGHttpSchema.js.map +1 -1
  25. package/dist/src/schema/httpSchema.d.ts.map +1 -1
  26. package/dist/src/schema/httpSchema.js +14 -3
  27. package/dist/src/schema/httpSchema.js.map +1 -1
  28. package/dist/src/server/GGHttp.d.ts +6 -1
  29. package/dist/src/server/GGHttp.d.ts.map +1 -1
  30. package/dist/src/server/GGHttp.js +5 -0
  31. package/dist/src/server/GGHttp.js.map +1 -1
  32. package/dist/src/server/GGHttpSchema.startServer.js +15 -0
  33. package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
  34. package/dist/src/server/GGHttpServer.d.ts +43 -0
  35. package/dist/src/server/GGHttpServer.d.ts.map +1 -1
  36. package/dist/src/server/GGHttpServer.js +70 -2
  37. package/dist/src/server/GGHttpServer.js.map +1 -1
  38. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  39. package/package.json +10 -10
  40. package/src/client/GGHttpSchema.createClient.ts +45 -4
  41. package/src/index-node.ts +2 -0
  42. package/src/rpc/GGHttpRouteRPC.ts +34 -6
  43. package/src/rpc/openApiHelpers.ts +87 -0
  44. package/src/rpc/openApiSuccessResponse.ts +38 -0
  45. package/src/schema/GGHttpSchema.ts +73 -1
  46. package/src/schema/httpSchema.ts +16 -4
  47. package/src/server/GGHttp.ts +6 -1
  48. package/src/server/GGHttpSchema.startServer.ts +14 -0
  49. package/src/server/GGHttpServer.ts +84 -2
@@ -9,10 +9,40 @@ declare module "../schema/GGHttpSchema" {
9
9
  }
10
10
  }
11
11
 
12
+ /**
13
+ * HTTP transport function. The signature mirrors `fetch` (URL string + init bag),
14
+ * which lets the default implementation just be `fetch`. Pass a custom transport
15
+ * to plug in pinned-TLS dialing, custom dispatchers, signed-request proxies,
16
+ * or any other wire-layer concern that `fetch` can't accommodate.
17
+ *
18
+ * The `url` arg is `(config.url ?? "") + path-built-from-schema`, so when you
19
+ * pass `url: ""` (or rely on transport-implies-empty), your transport receives
20
+ * just the request path and decides the host itself.
21
+ *
22
+ * The init bag is a fetch-compatible subset: only the fields createClient
23
+ * actually populates. Extending it later is a non-breaking change.
24
+ */
25
+ export type GGHttpTransport = (
26
+ url: string,
27
+ init: {
28
+ method: string
29
+ headers: Record<string, string>
30
+ body: string | FormData | undefined
31
+ signal: AbortSignal
32
+ }
33
+ ) => Promise<Response>
34
+
12
35
  export interface GGHttpClientConfig {
13
36
  url?: string;
14
37
  timeout?: number;
15
38
  noValidation?: boolean
39
+ /**
40
+ * Override the wire-layer call. Defaults to `fetch`. When provided,
41
+ * service discovery is skipped (the transport is presumed to know how
42
+ * to reach the target) and `url` defaults to `""` so the transport sees
43
+ * just the schema-built path.
44
+ */
45
+ transport?: GGHttpTransport
16
46
  }
17
47
 
18
48
  GGHttpSchema.prototype.createClient = function <TContract extends GGContractApiDefinition, TContext>(
@@ -29,6 +59,14 @@ export function createClient<TContract extends GGContractApiDefinition, TContext
29
59
  config ??= {};
30
60
  config.timeout ??= 15000;
31
61
 
62
+ // A custom transport implies "I take over the wire layer" — discovery is
63
+ // off the table (the transport knows how to find the target), and the
64
+ // default base URL becomes "" so the transport sees just the request path.
65
+ const transport: GGHttpTransport = config.transport ?? defaultFetchTransport;
66
+ if (config.transport && config.url === undefined) {
67
+ config.url = "";
68
+ }
69
+
32
70
  if (config.url === undefined && isBrowser()) {
33
71
  throw new Error("Must define URL for GGHttpClient when running in browser! Use empty string for same-origin requests.");
34
72
  }
@@ -69,7 +107,7 @@ export function createClient<TContract extends GGContractApiDefinition, TContext
69
107
  const fetchRequest = await wireFormat.createRequest(validatedInput);
70
108
  const controller = new AbortController();
71
109
  const timeoutId = setTimeout(() => controller.abort(), config.timeout);
72
- const wireResponse = await fetch(baseUrl + fetchRequest.url, {
110
+ const wireResponse = await transport(baseUrl + fetchRequest.url, {
73
111
  method: fetchRequest.method,
74
112
  signal: controller.signal,
75
113
  headers: fetchRequest.headers,
@@ -101,7 +139,10 @@ export function createClient<TContract extends GGContractApiDefinition, TContext
101
139
  }
102
140
 
103
141
  }
104
- // @TODO Next line is just so test would register it correctly.
105
- httpSchema.contract.implement(transportImplementation as GGContractImplementation<TContract>)
142
+ // Per-client stub doesn't belong in the callOn registry (server's impl does).
143
+ httpSchema.contract.implement(transportImplementation as GGContractImplementation<TContract>, {skipLocatorRegistration: true})
106
144
  return transportImplementation;
107
- }
145
+ }
146
+
147
+ const defaultFetchTransport: GGHttpTransport = (url, init) =>
148
+ fetch(url, init as RequestInit)
package/src/index-node.ts CHANGED
@@ -17,6 +17,8 @@ export * from "./server/GG_HTTP_REQUEST";
17
17
  export * from "./schema/GGHttpSchema";
18
18
  export * from "./schema/httpSchema";
19
19
  export * from "./rpc/GGHttpRouteRPC";
20
+ export * from "./rpc/openApiSuccessResponse";
21
+ export * from "./rpc/openApiHelpers";
20
22
  export * from "./rpc/RpcRequest/GGRpcRequestBuilder";
21
23
  export * from "./rpc/RpcRequest/GGRpcRequestParser";
22
24
  export * from "./rpc/RpcResponse/GGRpcResponseBuilder";
@@ -1,7 +1,11 @@
1
1
  import {HttpMethod} from "@grest-ts/common"
2
- import {ClientHttpRouteToRpcTransformClientCodec, ClientHttpRouteToRpcTransformClientConfig, ClientHttpRouteToRpcTransformServerCodec, ClientHttpRouteToRpcTransformServerConfig, GGHttpCodec} from "../schema/GGHttpSchema"
2
+ import {ClientHttpRouteToRpcTransformClientCodec, ClientHttpRouteToRpcTransformClientConfig, ClientHttpRouteToRpcTransformServerCodec, ClientHttpRouteToRpcTransformServerConfig, GGHttpCodec, GGHttpCodecOpenApiConfig} from "../schema/GGHttpSchema"
3
3
  import {GGRpcRequestBuilder} from "./RpcRequest/GGRpcRequestBuilder";
4
4
  import {GGRpcResponseParser} from "./RpcResponse/GGRpcResponseParser";
5
+ import type {OpenAPIV3_1} from "openapi-types";
6
+ import {GGSchema} from "@grest-ts/schema";
7
+ import {buildRpcSuccessResponses} from "./openApiSuccessResponse";
8
+ import {buildOpenApiParameters} from "./openApiHelpers";
5
9
 
6
10
  export type GGRpcServerCodecFactory = (method: HttpMethod, path: string, config: ClientHttpRouteToRpcTransformServerConfig) => ClientHttpRouteToRpcTransformServerCodec;
7
11
 
@@ -12,20 +16,23 @@ export function _registerRpcServerCodecFactory(factory: GGRpcServerCodecFactory)
12
16
  }
13
17
 
14
18
  export const GGRpc = {
15
- GET: (path: string) => new GGHttpRpcCodec("GET", path),
16
- DELETE: (path: string) => new GGHttpRpcCodec("DELETE", path),
17
- POST: (path: string) => new GGHttpRpcCodec("POST", path),
18
- PUT: (path: string) => new GGHttpRpcCodec("PUT", path),
19
+ GET: (path: string, opts?: {deprecated?: boolean}) => new GGHttpRpcCodec("GET", path, opts?.deprecated),
20
+ DELETE: (path: string, opts?: {deprecated?: boolean}) => new GGHttpRpcCodec("DELETE", path, opts?.deprecated),
21
+ POST: (path: string, opts?: {deprecated?: boolean}) => new GGHttpRpcCodec("POST", path, opts?.deprecated),
22
+ PUT: (path: string, opts?: {deprecated?: boolean}) => new GGHttpRpcCodec("PUT", path, opts?.deprecated),
19
23
  }
20
24
 
21
25
  class GGHttpRpcCodec implements GGHttpCodec {
22
26
 
23
27
  public readonly method: HttpMethod
24
28
  public readonly path: string
29
+ public readonly deprecated: boolean | undefined
30
+ public readonly responseHeaders: Record<string, GGSchema<string | undefined>> = {}
25
31
 
26
- constructor(method: HttpMethod, path: string) {
32
+ constructor(method: HttpMethod, path: string, deprecated?: boolean) {
27
33
  this.method = method
28
34
  this.path = path
35
+ this.deprecated = deprecated
29
36
  }
30
37
 
31
38
  public createForClient(config: ClientHttpRouteToRpcTransformClientConfig): ClientHttpRouteToRpcTransformClientCodec {
@@ -39,4 +46,25 @@ class GGHttpRpcCodec implements GGHttpCodec {
39
46
  if (!_serverCodecFactory) throw new Error("Server RPC codec not available. Ensure @grest-ts/http server entry is imported.");
40
47
  return _serverCodecFactory(this.method, this.path, config);
41
48
  }
49
+
50
+ public toOpenApiOperation(config: GGHttpCodecOpenApiConfig): Partial<OpenAPIV3_1.OperationObject> {
51
+ const hasBody = this.method === "POST" || this.method === "PUT" || this.method === "PATCH";
52
+ const operationId = config.methodName;
53
+
54
+ const parameters = buildOpenApiParameters(this.path, hasBody, config.contract.input ?? undefined, config.schemaResolver);
55
+ const operation: Partial<OpenAPIV3_1.OperationObject> = {
56
+ operationId,
57
+ parameters,
58
+ responses: buildRpcSuccessResponses(config.contract, config.schemaResolver)
59
+ };
60
+
61
+ if (hasBody && config.contract.input) {
62
+ operation.requestBody = {
63
+ required: true,
64
+ content: {'application/json': {schema: config.schemaResolver(config.contract.input.toSchemaDescription())}}
65
+ };
66
+ }
67
+
68
+ return operation;
69
+ }
42
70
  }
@@ -0,0 +1,87 @@
1
+ import type {GGSchema, GGSchemaDescription} from "@grest-ts/schema";
2
+ import type {GGOpenApiSchemaResolver} from "../schema/GGHttpSchema";
3
+ import type {OpenAPIV3_1} from "openapi-types";
4
+
5
+ /**
6
+ * Build OpenAPI parameter objects for a route from the contract's input schema.
7
+ *
8
+ * Path parameters are extracted from the path template (:id → {id}).
9
+ * For GET/DELETE (no body), remaining input fields become query parameters.
10
+ *
11
+ * Uses toSchemaDescription() to walk the input object's fields as
12
+ * GGSchemaDescription instances, then passes each to schemaResolver so named
13
+ * schemas are emitted as $ref where applicable.
14
+ *
15
+ * Type-cast note: openapi-types@12 defines OpenAPIV3_1.ParameterObject as a
16
+ * direct alias of OpenAPIV3.ParameterObject, whose `schema` field resolves to
17
+ * V3 schema types (missing `type:"null"` as a valid NonArraySchemaObjectType).
18
+ * The casts to ParameterObject["schema"] are the precise boundary of that
19
+ * typedef limitation — runtime objects are fully valid OpenAPI 3.1 parameters.
20
+ */
21
+ export function buildOpenApiParameters(
22
+ pathTemplate: string,
23
+ hasBody: boolean,
24
+ inputSchema: GGSchema<unknown> | undefined,
25
+ schemaResolver: GGOpenApiSchemaResolver
26
+ ): OpenAPIV3_1.ParameterObject[] {
27
+ const pathParams = (pathTemplate.match(/:(\w+)/g) || []).map(m => m.slice(1));
28
+ if (!inputSchema) return pathParams.map(name => buildPathParam(name, undefined, schemaResolver));
29
+
30
+ // Use toSchemaDescription() to get GGSchemaDescription instances per field.
31
+ // This gives us the format-agnostic tree without coupling to internal def structure.
32
+ const desc = inputSchema.toSchemaDescription();
33
+ if (desc.node.kind !== 'object') {
34
+ // Input schemas must be object schemas so their fields can be mapped to
35
+ // path/query parameters. Any other kind is a contract definition error.
36
+ throw new Error(
37
+ `buildOpenApiParameters: input schema must be an object schema (kind='object'), ` +
38
+ `got kind='${desc.node.kind}'. Contract input schemas must use IsObject({...}).`
39
+ );
40
+ }
41
+
42
+ const properties = desc.node.properties;
43
+
44
+ const params: OpenAPIV3_1.ParameterObject[] = pathParams.map(name =>
45
+ buildPathParam(name, properties[name], schemaResolver)
46
+ );
47
+
48
+ if (!hasBody) {
49
+ for (const [name, fieldDesc] of Object.entries(properties)) {
50
+ if (pathParams.includes(name)) continue;
51
+ const resolved = schemaResolver(fieldDesc);
52
+ const {description, ...schemaWithoutDescription} = resolved as any;
53
+ const isRequired = !fieldDesc.optional && (resolved as any).default === undefined;
54
+ const param: OpenAPIV3_1.ParameterObject = {
55
+ name,
56
+ in: 'query' as const,
57
+ required: isRequired,
58
+ schema: schemaWithoutDescription as OpenAPIV3_1.ParameterObject["schema"]
59
+ };
60
+ if (description) param.description = description;
61
+ params.push(param);
62
+ }
63
+ }
64
+ return params;
65
+ }
66
+
67
+ function buildPathParam(
68
+ name: string,
69
+ fieldDesc: GGSchemaDescription | undefined,
70
+ schemaResolver: GGOpenApiSchemaResolver
71
+ ): OpenAPIV3_1.ParameterObject {
72
+ if (!fieldDesc) {
73
+ // Path params are always non-empty — an empty segment cannot be routed.
74
+ return {name, in: 'path' as const, required: true as const,
75
+ schema: {type: 'string', minLength: 1} as OpenAPIV3_1.ParameterObject["schema"]};
76
+ }
77
+ const resolved = schemaResolver(fieldDesc);
78
+ const {description, ...schemaWithoutDescription} = resolved as any;
79
+ const param: OpenAPIV3_1.ParameterObject = {
80
+ name,
81
+ in: 'path' as const,
82
+ required: true as const,
83
+ schema: schemaWithoutDescription as OpenAPIV3_1.ParameterObject["schema"]
84
+ };
85
+ if (description) param.description = description;
86
+ return param;
87
+ }
@@ -0,0 +1,38 @@
1
+ import type {GGContractMethod} from "@grest-ts/schema";
2
+ import type {OpenAPIV3_1} from "openapi-types";
3
+ import type {GGOpenApiSchemaResolver} from "../schema/GGHttpSchema";
4
+
5
+ /**
6
+ * Builds the standard GGRpc JSON-envelope success response for OpenAPI.
7
+ *
8
+ * On-wire the response is always:
9
+ * { success: true, type: "OK", data: <success schema> } (200)
10
+ * or no body (204 when no success schema)
11
+ *
12
+ * The resolver is used to emit $ref for named success schemas rather than
13
+ * inlining them. Pass config.schemaResolver from toOpenApiOperation().
14
+ */
15
+ export function buildRpcSuccessResponses(
16
+ contract: GGContractMethod,
17
+ resolver: GGOpenApiSchemaResolver
18
+ ): OpenAPIV3_1.ResponsesObject {
19
+ if (contract.success) {
20
+ const dataSchema = resolver(contract.success.toSchemaDescription());
21
+ const successSchema: OpenAPIV3_1.NonArraySchemaObject = {
22
+ type: "object",
23
+ properties: {
24
+ success: {type: "boolean", enum: [true]},
25
+ type: {type: "string", enum: ["OK"]},
26
+ data: dataSchema
27
+ },
28
+ required: ["success", "type", "data"]
29
+ };
30
+ return {
31
+ "200": {
32
+ description: "Success",
33
+ content: {"application/json": {schema: successSchema}}
34
+ }
35
+ };
36
+ }
37
+ return {"204": {description: "No content"}};
38
+ }
@@ -1,7 +1,8 @@
1
- import {ERROR, ERROR_JSON, GGContractApiDefinition, GGContractClass, GGContractMethod, OK} from "@grest-ts/schema";
1
+ import {ERROR, ERROR_JSON, GGContractApiDefinition, GGContractClass, GGContractMethod, GGSchema, GGSchemaDescription, OK} from "@grest-ts/schema";
2
2
  import type {HttpMethod} from "@grest-ts/common";
3
3
  import type http from "http";
4
4
  import type {GGHttpServerMiddleware} from "../server/GGHttpSchema.startServer";
5
+ import type {OpenAPIV3_1} from "openapi-types";
5
6
 
6
7
  export class GGHttpSchema<TContract extends GGContractApiDefinition, TContext> {
7
8
 
@@ -69,14 +70,63 @@ export interface ClientHttpRouteToRpcTransformServerCodec {
69
70
  // Codec
70
71
  // --------------------------------------------------------------------------------------------------------
71
72
 
73
+ /**
74
+ * Config passed to toOpenApiOperation? — gives the codec access to the contract and route context
75
+ * so it can produce accurate OpenAPI operation metadata.
76
+ */
77
+ /**
78
+ * Resolves a GGSchemaDescription to an OpenAPI SchemaObject or ReferenceObject.
79
+ * When provided via GGHttpCodecOpenApiConfig, codecs should use this for all schema
80
+ * conversions — it enables $ref extraction for named schemas.
81
+ * For standalone use (tests, custom tools), use inlineSchemaResolver from @grest-ts/openapi.
82
+ */
83
+ export type GGOpenApiSchemaResolver = (desc: GGSchemaDescription) => OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject;
84
+
85
+ export interface GGHttpCodecOpenApiConfig {
86
+ readonly pathPrefix: string;
87
+ readonly methodName: string;
88
+ readonly contract: GGContractMethod;
89
+ /**
90
+ * Resolves a GGSchema to an OpenAPI SchemaObject or ReferenceObject.
91
+ * Provided by the document builder (toOpenApi) as registry.schemaOrRef.
92
+ * For standalone codec use or tests, pass inlineSchemaResolver from @grest-ts/openapi.
93
+ */
94
+ readonly schemaResolver: GGOpenApiSchemaResolver;
95
+ }
96
+
97
+
72
98
  export interface GGHttpCodec {
73
99
  readonly method: HttpMethod;
74
100
  readonly path: string;
75
101
 
102
+ /**
103
+ * Mark this operation as deprecated in the OpenAPI spec.
104
+ * Swagger UI renders deprecated operations with a strikethrough.
105
+ * @default false
106
+ */
107
+ readonly deprecated?: boolean;
108
+
109
+ /**
110
+ * Response headers this codec sets, mapped to their value schemas.
111
+ * Keys are header names; values describe the header value format.
112
+ * Used for CORS Access-Control-Expose-Headers and OpenAPI response header docs.
113
+ * Use {} if the codec sets no custom response headers.
114
+ */
115
+ readonly responseHeaders: Record<string, GGSchema<string | undefined>>;
116
+
76
117
  createForClient(config: ClientHttpRouteToRpcTransformClientConfig): ClientHttpRouteToRpcTransformClientCodec
77
118
 
78
119
  createForServer(config: ClientHttpRouteToRpcTransformServerConfig): ClientHttpRouteToRpcTransformServerCodec
79
120
 
121
+ /**
122
+ * Optional hook for custom codecs to describe their OpenAPI operation semantics.
123
+ * The returned partial is merged on top of the auto-generated operation object,
124
+ * allowing overrides for requestBody content type, security schemes, etc.
125
+ *
126
+ * Built-in GGRpc.* codecs implement this automatically.
127
+ * Custom codec authors (e.g. GGFileUpload) may implement it for accurate docs.
128
+ */
129
+ toOpenApiOperation?(config: GGHttpCodecOpenApiConfig): Partial<OpenAPIV3_1.OperationObject>
80
130
  }
81
131
 
82
132
  // --------------------------------------------------------------------------------------------------------
@@ -84,6 +134,28 @@ export interface GGHttpCodec {
84
134
  // --------------------------------------------------------------------------------------------------------
85
135
 
86
136
  export interface GGHttpTransportMiddleware {
137
+ /**
138
+ * Request headers this middleware reads or writes, mapped to their value schemas.
139
+ * Keys are header names; values describe the header value format for validation and docs.
140
+ * Used for CORS Access-Control-Allow-Headers and OpenAPI parameter docs.
141
+ * Use {} if the middleware touches no custom request headers.
142
+ *
143
+ * @example
144
+ * headers: {
145
+ * "authorization": IsString.nonEmpty.docs({title: "Bearer token", example: "Bearer ..."}),
146
+ * "accept-language": IsLocale.orUndefined
147
+ * }
148
+ */
149
+ readonly headers: Record<string, GGSchema<string | undefined>>;
150
+
151
+ /**
152
+ * Response headers this middleware sets, mapped to their value schemas.
153
+ * Keys are header names; values describe the header value format for validation and docs.
154
+ * Used for CORS Access-Control-Expose-Headers and OpenAPI response header docs.
155
+ * Use {} if the middleware sets no custom response headers.
156
+ */
157
+ readonly responseHeaders: Record<string, GGSchema<string | undefined>>;
158
+
87
159
  /**
88
160
  * Client-side: modify outgoing request (add headers, etc.)
89
161
  */
@@ -1,7 +1,7 @@
1
1
  import type http from "http";
2
2
  import {GGHttpCodec, GGHttpSchema} from "./GGHttpSchema";
3
3
  import {GGHttpRequest, GGHttpTransportMiddleware} from "./GGHttpSchema";
4
- import {GGContractApiDefinition, GGContractClass} from "@grest-ts/schema";
4
+ import {GGContractApiDefinition, GGContractClass, GGSchema} from "@grest-ts/schema";
5
5
  import {GGContextKey} from "@grest-ts/context";
6
6
 
7
7
  /**
@@ -60,9 +60,23 @@ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext =
60
60
  throw new Error(`Context key '${contextKey.name}' does not have an 'http-header' codec registered.`);
61
61
  }
62
62
 
63
+ // Build the typed header map from codec.inputSchema — the IsObject({headerName: schema})
64
+ // that describes which headers this codec reads and what format each value has.
65
+ const inputSchema = codec.inputSchema;
66
+ const headers: Record<string, GGSchema<string | undefined>> = {};
67
+ if (inputSchema) {
68
+ const desc = inputSchema.toSchemaDescription();
69
+ if (desc.node.kind === 'object') {
70
+ for (const [k, fieldDesc] of Object.entries(desc.node.properties)) {
71
+ headers[k] = fieldDesc.schema as GGSchema<string | undefined>;
72
+ }
73
+ }
74
+ }
75
+
63
76
  const middleware: GGHttpTransportMiddleware = {
77
+ headers,
78
+ responseHeaders: {},
64
79
  updateRequest(req: GGHttpRequest) {
65
- // Client-side: context -> headers
66
80
  const contextValue = contextKey.get();
67
81
  if (contextValue !== undefined) {
68
82
  const result = codec.decode(contextValue);
@@ -70,7 +84,6 @@ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext =
70
84
  Object.assign(req.headers, result.value);
71
85
  }
72
86
  } else {
73
- // Clear any default headers by decoding an empty context
74
87
  const emptyResult = codec.decode({} as Input);
75
88
  if (emptyResult.success) {
76
89
  for (const key of Object.keys(emptyResult.value as object)) {
@@ -80,7 +93,6 @@ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext =
80
93
  }
81
94
  },
82
95
  parseRequest(req: http.IncomingMessage) {
83
- // Server-side: headers -> context
84
96
  const headers = req.headers as Record<string, string>;
85
97
  const result = codec.encode(headers);
86
98
  if (result.success) {
@@ -5,7 +5,12 @@ import {GGHttpServer} from "./GGHttpServer";
5
5
 
6
6
  export class GGHttp<TContext = undefined> {
7
7
 
8
- private readonly httpServer: GGHttpServer
8
+ /**
9
+ * Protected (not private) so that plugin modules can access the underlying server
10
+ * via module augmentation (e.g. @grest-ts/openapi adds .openApi() to the builder).
11
+ * Do not tighten back to private.
12
+ */
13
+ protected readonly httpServer: GGHttpServer
9
14
  private readonly middlewares: GGHttpServerMiddleware[] = [];
10
15
 
11
16
  constructor(httpServer: GGHttpServer) {
@@ -65,11 +65,25 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
65
65
  const server = config.http ?? GGLocator.getScope().get(GG_HTTP_SERVER);
66
66
  if (!server) throw new Error(`No HTTP server found. Make sure to register GGHttpServerAdapter in the scope or pass handler via config`)
67
67
 
68
+ server._registerSchema(httpSchema as GGHttpSchema<any, any>);
69
+
68
70
  const pathPrefix = "/" + httpSchema.pathPrefix + "/"
69
71
  const apiMiddlewares = httpSchema.apiMiddlewares;
70
72
  const scope = GGLocator.getScope();
71
73
  const parentContext = GGContextStore.tryGetContext();
72
74
 
75
+ for (const mw of apiMiddlewares) {
76
+ const hKeys = Object.keys(mw.headers);
77
+ const rhKeys = Object.keys(mw.responseHeaders);
78
+ if (hKeys.length) server.registerCorsHeaders(hKeys);
79
+ if (rhKeys.length) server.registerCorsExposeHeaders(rhKeys);
80
+ }
81
+ for (const methodName in httpSchema.codec) {
82
+ const codec: GGHttpCodec = httpSchema.codec[methodName];
83
+ const rhKeys = Object.keys(codec?.responseHeaders ?? {});
84
+ if (rhKeys.length) server.registerCorsExposeHeaders(rhKeys);
85
+ }
86
+
73
87
  server.onStart(() => {
74
88
  GG_DISCOVERY.tryGet()?.registerRoutes([{
75
89
  runtime: scope.serviceName,
@@ -5,6 +5,10 @@ import {GGLocator, GGLocatorKey, GGLocatorScope, GGLocatorServiceType} from "@gr
5
5
  import {GG_HTTP_SERVER} from "./GG_HTTP_SERVER";
6
6
  import {GGLog} from "@grest-ts/logger";
7
7
  import findMyWay, {HTTPMethod} from "find-my-way";
8
+ import type {GGHttpSchema} from "../schema/GGHttpSchema";
9
+ // Forward declaration — actual type lives in @grest-ts/websocket to avoid circular dep.
10
+ // GGHttpServer only stores the array; callers cast as needed.
11
+ type AnyWebSocketSchema = {name: string; path: string; contract: unknown; middlewares: readonly unknown[]};
8
12
 
9
13
  export interface GGHttpServerAdapterConfig {
10
14
  key?: GGLocatorKey<GGHttpServer>;
@@ -34,10 +38,31 @@ export class GGHttpServer {
34
38
  private readonly _onStart: Array<() => void> = [];
35
39
  private readonly _onTeardown: Array<() => void> = [];
36
40
 
41
+ /**
42
+ * Mutable during compose(); frozen and exposed as ReadonlyArray once the server starts.
43
+ * Framework-internal — only setupRoutes() (in GGHttpSchema.startServer.ts) should push here.
44
+ */
45
+ private readonly _registeredSchemas: GGHttpSchema<any, any>[] = [];
46
+
47
+ /**
48
+ * All GGHttpSchema instances registered on this server, in registration order.
49
+ * Available from the moment compose() begins; frozen (no further push allowed) once start() is called.
50
+ */
51
+ get registeredSchemas(): ReadonlyArray<GGHttpSchema<any, any>> {
52
+ return this._registeredSchemas;
53
+ }
54
+
37
55
  public readonly httpServer: http.Server;
38
56
  private activeRequests = 0;
39
57
  private router = findMyWay<findMyWay.HTTPVersion.V1>();
40
58
 
59
+ private static readonly DEFAULT_CORS_HEADERS = ['Content-Type'];
60
+ private readonly _corsHeaders = new Set<string>(GGHttpServer.DEFAULT_CORS_HEADERS);
61
+ private _corsHeadersCache: string = GGHttpServer.DEFAULT_CORS_HEADERS.join(', ');
62
+
63
+ private readonly _corsExposeHeaders = new Set<string>();
64
+ private _corsExposeHeadersCache: string = '';
65
+
41
66
  constructor(config?: GGHttpServerAdapterConfig) {
42
67
 
43
68
  this.runtimeName = GGLocator.getScope().serviceName;
@@ -61,8 +86,10 @@ export class GGHttpServer {
61
86
  if (req.headers.origin) { // For browsers
62
87
  res.setHeader('Access-Control-Allow-Origin', '*');
63
88
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
64
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-org-token');
65
- res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
89
+ res.setHeader('Access-Control-Allow-Headers', this._corsHeadersCache);
90
+ if (this._corsExposeHeadersCache) {
91
+ res.setHeader('Access-Control-Expose-Headers', this._corsExposeHeadersCache);
92
+ }
66
93
  }
67
94
  if (req.method === 'OPTIONS') {
68
95
  res.writeHead(204);
@@ -90,6 +117,40 @@ export class GGHttpServer {
90
117
  : http.createServer(handler);
91
118
  }
92
119
 
120
+ /**
121
+ * Register custom header names for CORS Access-Control-Allow-Headers.
122
+ * Called automatically during schema registration based on middleware declarations.
123
+ */
124
+ public registerCorsHeaders(headers: readonly string[]): void {
125
+ let changed = false;
126
+ for (const h of headers) {
127
+ if (!this._corsHeaders.has(h)) {
128
+ this._corsHeaders.add(h);
129
+ changed = true;
130
+ }
131
+ }
132
+ if (changed) {
133
+ this._corsHeadersCache = Array.from(this._corsHeaders).join(', ');
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Register custom header names for CORS Access-Control-Expose-Headers.
139
+ * Called automatically during schema registration based on codec and middleware declarations.
140
+ */
141
+ public registerCorsExposeHeaders(headers: readonly string[]): void {
142
+ let changed = false;
143
+ for (const h of headers) {
144
+ if (!this._corsExposeHeaders.has(h)) {
145
+ this._corsExposeHeaders.add(h);
146
+ changed = true;
147
+ }
148
+ }
149
+ if (changed) {
150
+ this._corsExposeHeadersCache = Array.from(this._corsExposeHeaders).join(', ');
151
+ }
152
+ }
153
+
93
154
  /**
94
155
  * Current port the server is listening to.
95
156
  * May be undefined before start() is called.
@@ -102,11 +163,32 @@ export class GGHttpServer {
102
163
  // Common implementation
103
164
  // =========================================================================
104
165
 
166
+ /** @internal Called by setupRoutes() during compose(). Do not call directly. */
167
+ public _registerSchema(schema: GGHttpSchema<any, any>): void {
168
+ this._registeredSchemas.push(schema);
169
+ }
170
+
171
+ private readonly _registeredWebSocketSchemas: AnyWebSocketSchema[] = [];
172
+
173
+ /**
174
+ * All GGWebSocketSchema instances registered on this server, in registration order.
175
+ * Populated automatically by GGWebSocketSchema.startServer() / .register().
176
+ */
177
+ get registeredWebSocketSchemas(): ReadonlyArray<AnyWebSocketSchema> {
178
+ return this._registeredWebSocketSchemas;
179
+ }
180
+
181
+ /** @internal Called by GGWebSocketSchema.startServer(). Do not call directly. */
182
+ public _registerWebSocketSchema(schema: AnyWebSocketSchema): void {
183
+ this._registeredWebSocketSchemas.push(schema);
184
+ }
185
+
105
186
  public registerRoute(method: HttpMethod, path: string, handler: GGHttpRequestCallback): void {
106
187
  this.router.on(method as HTTPMethod, path, handler as unknown as findMyWay.Handler<findMyWay.HTTPVersion.V1>);
107
188
  }
108
189
 
109
190
  public async start(): Promise<void> {
191
+ Object.freeze(this._registeredSchemas);
110
192
  this._port = await new Promise((resolve) => {
111
193
  this.httpServer.listen(this.configuredPort, '0.0.0.0', () => {
112
194
  const port = (this.httpServer.address() as any).port;