@grest-ts/http 0.0.5

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +613 -0
  3. package/dist/src/client/GGHttpSchema.createClient.d.ts +14 -0
  4. package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -0
  5. package/dist/src/client/GGHttpSchema.createClient.js +80 -0
  6. package/dist/src/client/GGHttpSchema.createClient.js.map +1 -0
  7. package/dist/src/index-browser.d.ts +8 -0
  8. package/dist/src/index-browser.d.ts.map +1 -0
  9. package/dist/src/index-browser.js +11 -0
  10. package/dist/src/index-browser.js.map +1 -0
  11. package/dist/src/index-node.d.ts +18 -0
  12. package/dist/src/index-node.d.ts.map +1 -0
  13. package/dist/src/index-node.js +32 -0
  14. package/dist/src/index-node.js.map +1 -0
  15. package/dist/src/rpc/GGHttpRouteRPC.d.ts +19 -0
  16. package/dist/src/rpc/GGHttpRouteRPC.d.ts.map +1 -0
  17. package/dist/src/rpc/GGHttpRouteRPC.js +32 -0
  18. package/dist/src/rpc/GGHttpRouteRPC.js.map +1 -0
  19. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts +18 -0
  20. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -0
  21. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +80 -0
  22. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -0
  23. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts +18 -0
  24. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts.map +1 -0
  25. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js +90 -0
  26. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js.map +1 -0
  27. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts +12 -0
  28. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts.map +1 -0
  29. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js +77 -0
  30. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js.map +1 -0
  31. package/dist/src/rpc/RpcResponse/GGRpcResponseParser.d.ts +7 -0
  32. package/dist/src/rpc/RpcResponse/GGRpcResponseParser.d.ts.map +1 -0
  33. package/dist/src/rpc/RpcResponse/GGRpcResponseParser.js +21 -0
  34. package/dist/src/rpc/RpcResponse/GGRpcResponseParser.js.map +1 -0
  35. package/dist/src/schema/GGHttpSchema.d.ts +68 -0
  36. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -0
  37. package/dist/src/schema/GGHttpSchema.js +18 -0
  38. package/dist/src/schema/GGHttpSchema.js.map +1 -0
  39. package/dist/src/schema/httpSchema.d.ts +43 -0
  40. package/dist/src/schema/httpSchema.d.ts.map +1 -0
  41. package/dist/src/schema/httpSchema.js +85 -0
  42. package/dist/src/schema/httpSchema.js.map +1 -0
  43. package/dist/src/server/GGHttp.d.ts +12 -0
  44. package/dist/src/server/GGHttp.d.ts.map +1 -0
  45. package/dist/src/server/GGHttp.js +16 -0
  46. package/dist/src/server/GGHttp.js.map +1 -0
  47. package/dist/src/server/GGHttpMetrics.d.ts +22 -0
  48. package/dist/src/server/GGHttpMetrics.d.ts.map +1 -0
  49. package/dist/src/server/GGHttpMetrics.js +15 -0
  50. package/dist/src/server/GGHttpMetrics.js.map +1 -0
  51. package/dist/src/server/GGHttpSchema.startServer.d.ts +30 -0
  52. package/dist/src/server/GGHttpSchema.startServer.d.ts.map +1 -0
  53. package/dist/src/server/GGHttpSchema.startServer.js +114 -0
  54. package/dist/src/server/GGHttpSchema.startServer.js.map +1 -0
  55. package/dist/src/server/GGHttpServer.d.ts +32 -0
  56. package/dist/src/server/GGHttpServer.d.ts.map +1 -0
  57. package/dist/src/server/GGHttpServer.js +116 -0
  58. package/dist/src/server/GGHttpServer.js.map +1 -0
  59. package/dist/src/server/GG_HTTP_REQUEST.d.ts +16 -0
  60. package/dist/src/server/GG_HTTP_REQUEST.d.ts.map +1 -0
  61. package/dist/src/server/GG_HTTP_REQUEST.js +10 -0
  62. package/dist/src/server/GG_HTTP_REQUEST.js.map +1 -0
  63. package/dist/src/server/GG_HTTP_SERVER.d.ts +4 -0
  64. package/dist/src/server/GG_HTTP_SERVER.d.ts.map +1 -0
  65. package/dist/src/server/GG_HTTP_SERVER.js +3 -0
  66. package/dist/src/server/GG_HTTP_SERVER.js.map +1 -0
  67. package/dist/src/tsconfig.json +17 -0
  68. package/dist/testkit/clientHttp/GGHttpCall.d.ts +35 -0
  69. package/dist/testkit/clientHttp/GGHttpCall.d.ts.map +1 -0
  70. package/dist/testkit/clientHttp/GGHttpCall.js +37 -0
  71. package/dist/testkit/clientHttp/GGHttpCall.js.map +1 -0
  72. package/dist/testkit/clientHttp/GGHttpSchema.callOn.d.ts +37 -0
  73. package/dist/testkit/clientHttp/GGHttpSchema.callOn.d.ts.map +1 -0
  74. package/dist/testkit/clientHttp/GGHttpSchema.callOn.js +29 -0
  75. package/dist/testkit/clientHttp/GGHttpSchema.callOn.js.map +1 -0
  76. package/dist/testkit/index-testkit.d.ts +8 -0
  77. package/dist/testkit/index-testkit.d.ts.map +1 -0
  78. package/dist/testkit/index-testkit.js +8 -0
  79. package/dist/testkit/index-testkit.js.map +1 -0
  80. package/dist/testkit/mock/GGHttpInterceptorsServer.d.ts +13 -0
  81. package/dist/testkit/mock/GGHttpInterceptorsServer.d.ts.map +1 -0
  82. package/dist/testkit/mock/GGHttpInterceptorsServer.js +100 -0
  83. package/dist/testkit/mock/GGHttpInterceptorsServer.js.map +1 -0
  84. package/dist/testkit/mock/GGHttpSchema.mock.d.ts +36 -0
  85. package/dist/testkit/mock/GGHttpSchema.mock.d.ts.map +1 -0
  86. package/dist/testkit/mock/GGHttpSchema.mock.js +78 -0
  87. package/dist/testkit/mock/GGHttpSchema.mock.js.map +1 -0
  88. package/dist/testkit/routing/GGApiRoutingSelector.d.ts +8 -0
  89. package/dist/testkit/routing/GGApiRoutingSelector.d.ts.map +1 -0
  90. package/dist/testkit/routing/GGApiRoutingSelector.js +4 -0
  91. package/dist/testkit/routing/GGApiRoutingSelector.js.map +1 -0
  92. package/dist/testkit/routing/GGHttpSchema.routing.d.ts +14 -0
  93. package/dist/testkit/routing/GGHttpSchema.routing.d.ts.map +1 -0
  94. package/dist/testkit/routing/GGHttpSchema.routing.js +21 -0
  95. package/dist/testkit/routing/GGHttpSchema.routing.js.map +1 -0
  96. package/dist/testkit/utils/validateContractResponse.d.ts +8 -0
  97. package/dist/testkit/utils/validateContractResponse.d.ts.map +1 -0
  98. package/dist/testkit/utils/validateContractResponse.js +68 -0
  99. package/dist/testkit/utils/validateContractResponse.js.map +1 -0
  100. package/dist/tsconfig.publish.tsbuildinfo +1 -0
  101. package/package.json +74 -0
  102. package/src/client/GGHttpSchema.createClient.ts +107 -0
  103. package/src/index-browser.ts +12 -0
  104. package/src/index-node.ts +38 -0
  105. package/src/rpc/GGHttpRouteRPC.ts +42 -0
  106. package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +91 -0
  107. package/src/rpc/RpcRequest/GGRpcRequestParser.ts +100 -0
  108. package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +84 -0
  109. package/src/rpc/RpcResponse/GGRpcResponseParser.ts +23 -0
  110. package/src/schema/GGHttpSchema.ts +115 -0
  111. package/src/schema/httpSchema.ts +99 -0
  112. package/src/server/GGHttp.ts +27 -0
  113. package/src/server/GGHttpMetrics.ts +31 -0
  114. package/src/server/GGHttpSchema.startServer.ts +161 -0
  115. package/src/server/GGHttpServer.ts +133 -0
  116. package/src/server/GG_HTTP_REQUEST.ts +12 -0
  117. package/src/server/GG_HTTP_SERVER.ts +4 -0
  118. package/src/tsconfig.json +17 -0
@@ -0,0 +1,91 @@
1
+ import type {HttpMethod} from "@grest-ts/common";
2
+ import {GGContractMethod} from "@grest-ts/schema";
3
+ import {ClientHttpRouteToRpcTransformClientConfig, GGHttpFetchRequest, GGHttpTransportMiddleware} from "../../schema/GGHttpSchema";
4
+
5
+ export class GGRpcRequestBuilder {
6
+
7
+ public readonly contract: GGContractMethod
8
+ public readonly middlewares: readonly GGHttpTransportMiddleware[]
9
+ public readonly method: HttpMethod;
10
+ public readonly pathTemplate: string;
11
+ public readonly pathParams: string[];
12
+ public readonly pathPrefix: string;
13
+ public readonly hasBody: boolean;
14
+
15
+ constructor(
16
+ method: HttpMethod,
17
+ pathTemplate: string,
18
+ config: ClientHttpRouteToRpcTransformClientConfig
19
+ ) {
20
+ this.method = method
21
+ this.pathTemplate = pathTemplate
22
+ this.pathPrefix = config.pathPrefix
23
+ this.contract = config.contract
24
+ this.middlewares = config.middlewares
25
+ this.pathParams = (pathTemplate.match(/:(\w+)/g) || []).map(m => m.slice(1))
26
+ this.hasBody = method === "POST" || method === "PUT" || method === "PATCH"
27
+ }
28
+
29
+ public createRequest = (data: unknown): GGHttpFetchRequest => {
30
+ let result: GGHttpFetchRequest;
31
+ if (this.hasBody) {
32
+ result = {
33
+ url: this.pathPrefix + this.buildPath(data),
34
+ method: this.method,
35
+ headers: {'Content-Type': 'application/json'},
36
+ body: this.buildBody(data)
37
+ }
38
+ } else {
39
+ result = {
40
+ url: this.pathPrefix + this.buildPath(data) + this.buildQueryString(data as Record<string, unknown>),
41
+ method: this.method,
42
+ headers: {},
43
+ body: undefined
44
+ }
45
+ }
46
+ this.middlewares?.forEach(mw => mw.updateRequest?.(result))
47
+ return result
48
+ }
49
+
50
+ private buildPath(data: unknown) {
51
+ let path: string = this.pathTemplate;
52
+ if (this.pathParams.length > 0 && typeof data === "object" && data) {
53
+ for (let i = 0; i < this.pathParams.length; i++) {
54
+ const p = this.pathParams[i]
55
+ const val = (data as Record<string, unknown>)[p]
56
+ path = path.replace(':' + p, encodeURIComponent(val === undefined || val === null ? "" : String(val)))
57
+ }
58
+ }
59
+ return path;
60
+ }
61
+
62
+ private buildBody(data: unknown) {
63
+ if (data === undefined || data === null) {
64
+ return undefined;
65
+ }
66
+ if (this.contract.input?.def.hasNonJsonData) {
67
+ throw new Error("Schema contains non-JSON data (e.g. files). Use GGRpc.MULTIPART_POST instead of GGRpc.POST for this route.")
68
+ } else {
69
+ return JSON.stringify(data)
70
+ }
71
+ }
72
+
73
+ private buildQueryString(data: unknown): string {
74
+ if (data && typeof data === "object") {
75
+ const params = new URLSearchParams()
76
+ for (const [key, value] of Object.entries(data)) {
77
+ if (value !== undefined && value !== null) {
78
+ if (this.pathParams.length > 0 && this.pathParams.includes(key)) {
79
+ continue;
80
+ }
81
+ params.append(key, String(value))
82
+ }
83
+ }
84
+ const query = params.toString()
85
+ if (query) {
86
+ return "?" + query
87
+ }
88
+ }
89
+ return ""
90
+ }
91
+ }
@@ -0,0 +1,100 @@
1
+ import type http from "http";
2
+ import type {HttpMethod} from "@grest-ts/common";
3
+ import {GGContractExecutor, GGContractMethod} from "@grest-ts/schema";
4
+ import {ClientHttpRouteToRpcTransformServerConfig, GGHttpRequest, GGHttpTransportMiddleware} from "../../schema/GGHttpSchema";
5
+ import type {GGHttpServerMiddleware} from "../../server/GGHttpSchema.startServer";
6
+
7
+ export class GGRpcRequestParser {
8
+
9
+ private readonly pathTemplate: string;
10
+ private readonly pathParams: string[];
11
+ private readonly hasBody: boolean;
12
+ protected readonly contract: GGContractMethod
13
+ private readonly apiMiddlewares: readonly GGHttpTransportMiddleware[]
14
+ private readonly serverMiddlewares: readonly GGHttpServerMiddleware[]
15
+
16
+ constructor(
17
+ method: HttpMethod,
18
+ pathTemplate: string,
19
+ config: ClientHttpRouteToRpcTransformServerConfig
20
+ ) {
21
+ this.pathTemplate = pathTemplate
22
+ this.pathParams = (pathTemplate.match(/:(\w+)/g) || []).map(m => m.slice(1))
23
+ this.hasBody = method === "POST" || method === "PUT" || method === "PATCH"
24
+ this.contract = config.contract
25
+ this.apiMiddlewares = config.apiMiddlewares
26
+ this.serverMiddlewares = config.serverMiddlewares
27
+ }
28
+
29
+ public parseRequest = async (req: http.IncomingMessage): Promise<unknown> => {
30
+ const url = req.url || '/'
31
+ const qIndex = url.indexOf('?')
32
+ const queryArgs = this.parseQueryString(qIndex === -1 ? '' : url.substring(qIndex + 1))
33
+ if (this.apiMiddlewares?.length > 0) {
34
+ const mwQuery: GGHttpRequest = {headers: req.headers, queryArgs: queryArgs}
35
+ this.apiMiddlewares?.forEach(mw => mw.parseRequest?.(mwQuery))
36
+ }
37
+ for (const mw of this.serverMiddlewares ?? []) await mw.process?.();
38
+
39
+ let input: unknown;
40
+ if (this.hasBody) {
41
+ input = await this.parseBody(req);
42
+ } else if (this.pathParams.length > 0) {
43
+ input = {
44
+ ...this.extractPathParams(qIndex === -1 ? url : url.substring(0, qIndex)),
45
+ ...queryArgs
46
+ };
47
+ } else {
48
+ input = queryArgs;
49
+ }
50
+ return GGContractExecutor.parseInput(this.contract.input, input);
51
+ }
52
+
53
+ private parseQueryString(rawQuery: string): Record<string, string | string[]> {
54
+ const result: Record<string, string | string[]> = {}
55
+ if (rawQuery) {
56
+ const params = new URLSearchParams(rawQuery)
57
+ for (const [key, value] of params.entries()) {
58
+ result[key] = value
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ private extractPathParams(actualPath: string): Record<string, string> {
65
+ const result: Record<string, string> = {}
66
+ const templateParts = this.pathTemplate.split('/').filter(p => p)
67
+ const actualParts = actualPath.split('/').filter(p => p)
68
+ const offset = actualParts.length - templateParts.length
69
+ if (offset >= 0) {
70
+ for (let i = 0; i < templateParts.length; i++) {
71
+ const templatePart = templateParts[i]
72
+ if (templatePart.startsWith(':')) {
73
+ const paramName = templatePart.slice(1)
74
+ result[paramName] = decodeURIComponent(actualParts[offset + i] || '')
75
+ }
76
+ }
77
+ }
78
+ return result;
79
+ }
80
+
81
+ private async parseBody(req: http.IncomingMessage): Promise<unknown> {
82
+ const contentType = req.headers['content-type'] || ''
83
+ const isMultipart = contentType.toLowerCase().startsWith('multipart/form-data')
84
+ if (isMultipart) {
85
+ throw new Error("Received multipart request on a JSON-only route. Use GGRpc.MULTIPART_POST for routes with file uploads.")
86
+ } else {
87
+ const rawBody: Buffer = await new Promise((resolve, reject) => {
88
+ const chunks: Buffer[] = []
89
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
90
+ req.on('end', () => resolve(Buffer.concat(chunks)))
91
+ req.on('error', reject)
92
+ });
93
+ if (rawBody && rawBody.length > 0) {
94
+ return JSON.parse(rawBody.toString('utf-8'))
95
+ } else {
96
+ return undefined
97
+ }
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,84 @@
1
+ import type http from "http";
2
+ import {ANY_ERROR, ERROR, GGContractExecutor, GGContractMethod, GGDebugData, GGErrorData, GGSchema, OK} from "@grest-ts/schema";
3
+ import {ClientHttpRouteToRpcTransformServerConfig} from "../../schema/GGHttpSchema";
4
+
5
+
6
+ export class GGRpcResponseBuilder {
7
+
8
+ protected readonly contract: GGContractMethod
9
+
10
+ constructor(config: ClientHttpRouteToRpcTransformServerConfig) {
11
+ this.contract = config.contract;
12
+ }
13
+
14
+ public sendResponse = async (res: http.ServerResponse, rpcResult: ERROR<string, unknown> | OK<unknown>): Promise<void> => {
15
+ let json: string;
16
+ let statusCode: number;
17
+ if (rpcResult.success === true) {
18
+ statusCode = 200;
19
+ json = '{' +
20
+ '"success":true,' +
21
+ '"type":"OK"' +
22
+ this.makeDataStr(this.contract.success, rpcResult) +
23
+ "}"
24
+ } else {
25
+ json = this.makeError(GGContractExecutor.getResponseSchema(this.contract, rpcResult), rpcResult);
26
+ statusCode = rpcResult.statusCode;
27
+ }
28
+ res.writeHead(statusCode, {
29
+ 'Content-Type': 'application/json',
30
+ 'Content-Length': Buffer.byteLength(json)
31
+ });
32
+ res.end(json)
33
+ }
34
+
35
+ private makeError(schema: GGSchema<any> | undefined, rpcResult: ERROR<string, unknown>) {
36
+ return '{' +
37
+ '"success":false,' +
38
+ '"type":' + JSON.stringify(rpcResult.type) + ',' +
39
+ '"statusCode":' + Number(rpcResult.statusCode) +
40
+ this.makeErrorCtx(rpcResult.context, rpcResult.getDebugContext()) +
41
+ this.makeDataStr(schema, rpcResult) +
42
+ "}";
43
+ }
44
+
45
+ private makeDataStr(schema: GGSchema<any>, data: OK<any> | ANY_ERROR): string {
46
+ if (schema) {
47
+ GGContractExecutor.assertResponse(schema, data);
48
+ const dataStr = schema.unsafeStringify(data.data)
49
+ return dataStr ? ',"data":' + dataStr + "" : "";
50
+ } else {
51
+ return "";
52
+ }
53
+ }
54
+
55
+ private makeErrorCtx(ctx: GGErrorData, debugContext?: GGDebugData): string {
56
+ if (ctx) {
57
+ let str = "";
58
+ str += ctx?.displayMessage ? '"displayMessage":' + JSON.stringify(ctx.displayMessage) + '' : "";
59
+ str += ctx?.timestamp ? (str ? "," : "") + '"timestamp":' + Number(ctx.timestamp) : "";
60
+ str += ctx?.ref ? (str ? "," : "") + '"ref":' + JSON.stringify(ctx.ref) + '' : "";
61
+ if (process.env.NODE_ENV !== "production" && debugContext) {
62
+ if (debugContext?.debugMessage) {
63
+ str += (str ? "," : "") + '"debugMessage":' + JSON.stringify(debugContext.debugMessage);
64
+ }
65
+ if (debugContext?.debugData !== undefined) {
66
+ str += (str ? "," : "") + '"debugData":' + JSON.stringify(debugContext.debugData);
67
+ }
68
+ if (debugContext?.originalError) {
69
+ const origErr = debugContext.originalError;
70
+ const errInfo = origErr instanceof ERROR
71
+ ? origErr.toJSON()
72
+ : origErr instanceof Error
73
+ ? {message: origErr.message, stack: origErr.stack?.split("\n")}
74
+ : {message: String(origErr)};
75
+ str += (str ? "," : "") + '"originalError":' + JSON.stringify(errInfo);
76
+ }
77
+ }
78
+ if (str) {
79
+ return ',"context":{' + str + '}';
80
+ }
81
+ }
82
+ return "";
83
+ }
84
+ }
@@ -0,0 +1,23 @@
1
+ import {ERROR_JSON, OK, SERVER_ERROR} from "@grest-ts/schema";
2
+ import {ClientHttpRouteToRpcTransformClientConfig} from "../../schema/GGHttpSchema";
3
+
4
+ export class GGRpcResponseParser {
5
+
6
+ constructor(config: ClientHttpRouteToRpcTransformClientConfig) {
7
+
8
+ }
9
+
10
+ parseResponse = async (response: Response): Promise<OK<unknown> | ERROR_JSON<string, unknown>> => {
11
+ const txt = await response.text();
12
+ try {
13
+ const json = txt ? JSON.parse(txt) : {};
14
+ if (typeof json === "object" && "success" in json && "type" in json) {
15
+ return json
16
+ } else {
17
+ return new SERVER_ERROR({displayMessage: "Invalid response format!", debugData: {json: json}});
18
+ }
19
+ } catch (err) {
20
+ return new SERVER_ERROR({displayMessage: "Failed to parse JSON", originalError: err, debugData: {text: txt}});
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,115 @@
1
+ import {ERROR, ERROR_JSON, GGContractApiDefinition, GGContractClass, GGContractMethod, OK} from "@grest-ts/schema";
2
+ import type {HttpMethod} from "@grest-ts/common";
3
+ import type http from "http";
4
+ import type {GGHttpServerMiddleware} from "../server/GGHttpSchema.startServer";
5
+
6
+ export class GGHttpSchema<TContract extends GGContractApiDefinition, TContext> {
7
+
8
+ public readonly name: string
9
+ public readonly pathPrefix: string
10
+ public readonly apiMiddlewares: readonly GGHttpTransportMiddleware[]
11
+ public readonly codec: Record<keyof TContract, GGHttpCodec>
12
+ public readonly contract: GGContractClass<TContract> | null = null
13
+
14
+ constructor(
15
+ pathPrefix: string,
16
+ contract: GGContractClass<TContract>,
17
+ wireCodec: Record<keyof TContract, GGHttpCodec>,
18
+ middlewares: readonly GGHttpTransportMiddleware[] = []
19
+ ) {
20
+ this.name = contract.name
21
+ this.pathPrefix = pathPrefix
22
+ this.apiMiddlewares = middlewares
23
+ this.codec = wireCodec
24
+ this.contract = contract
25
+ Object.freeze(this.apiMiddlewares)
26
+ Object.freeze(this.codec)
27
+ Object.freeze(this)
28
+ }
29
+ }
30
+
31
+ // --------------------------------------------------------------------------------------------------------
32
+ // Client codec
33
+ // --------------------------------------------------------------------------------------------------------
34
+
35
+ export interface ClientHttpRouteToRpcTransformClientConfig {
36
+ pathPrefix: string,
37
+ contract: GGContractMethod,
38
+ middlewares: readonly GGHttpTransportMiddleware[]
39
+ }
40
+
41
+ export interface ClientHttpRouteToRpcTransformClientCodec {
42
+ createRequest: (data: unknown) => GGHttpFetchRequest | Promise<GGHttpFetchRequest>
43
+ parseResponse: (response: Response) => Promise<OK<unknown> | ERROR_JSON<string, unknown>>
44
+ }
45
+
46
+ export interface GGHttpFetchRequest {
47
+ url: string;
48
+ method: HttpMethod;
49
+ headers: Record<string, string>;
50
+ body: string | FormData | undefined;
51
+ }
52
+
53
+ // --------------------------------------------------------------------------------------------------------
54
+ // Server codec
55
+ // --------------------------------------------------------------------------------------------------------
56
+
57
+ export interface ClientHttpRouteToRpcTransformServerConfig {
58
+ contract: GGContractMethod,
59
+ apiMiddlewares: readonly GGHttpTransportMiddleware[],
60
+ serverMiddlewares: readonly GGHttpServerMiddleware[]
61
+ }
62
+
63
+ export interface ClientHttpRouteToRpcTransformServerCodec {
64
+ parseRequest: (req: http.IncomingMessage) => Promise<unknown>,
65
+ sendResponse: (res: http.ServerResponse, rpcResult: ERROR<string, unknown> | OK<unknown>) => Promise<void>
66
+ }
67
+
68
+ // --------------------------------------------------------------------------------------------------------
69
+ // Codec
70
+ // --------------------------------------------------------------------------------------------------------
71
+
72
+ export interface GGHttpCodec {
73
+ readonly method: HttpMethod;
74
+ readonly path: string;
75
+
76
+ createForClient(config: ClientHttpRouteToRpcTransformClientConfig): ClientHttpRouteToRpcTransformClientCodec
77
+
78
+ createForServer(config: ClientHttpRouteToRpcTransformServerConfig): ClientHttpRouteToRpcTransformServerCodec
79
+
80
+ }
81
+
82
+ // --------------------------------------------------------------------------------------------------------
83
+ // Middleware
84
+ // --------------------------------------------------------------------------------------------------------
85
+
86
+ export interface GGHttpTransportMiddleware {
87
+ /**
88
+ * Client-side: modify outgoing request (add headers, etc.)
89
+ */
90
+ updateRequest?(req: GGHttpRequest): void;
91
+
92
+ /**
93
+ * Server-side: parse incoming request (extract context from headers, etc.)
94
+ */
95
+ parseRequest?(req: GGHttpRequest): void;
96
+
97
+ /**
98
+ * Server-side: modify outgoing response
99
+ */
100
+ updateResponse?(res: GGHttpResponse): void;
101
+
102
+ /**
103
+ * Client-side: parse incoming response
104
+ */
105
+ parseResponse?(res: GGHttpResponse): void;
106
+ }
107
+
108
+ export interface GGHttpRequest {
109
+ headers?: Record<string, string | string[]>;
110
+ queryArgs?: Record<string, string | string[]>;
111
+ }
112
+
113
+ export interface GGHttpResponse {
114
+ headers: Record<string, string | string[]>;
115
+ }
@@ -0,0 +1,99 @@
1
+ import type http from "http";
2
+ import {GGHttpCodec, GGHttpSchema} from "./GGHttpSchema";
3
+ import {GGHttpRequest, GGHttpTransportMiddleware} from "./GGHttpSchema";
4
+ import {GGContractApiDefinition, GGContractClass} from "@grest-ts/schema";
5
+ import {GGContextKey} from "@grest-ts/context";
6
+
7
+ /**
8
+ * Create an HTTP API schema builder from a contract.
9
+ *
10
+ * @example
11
+ * export const MyApiContract = defineApi("MyApi", () => ({
12
+ * list: {
13
+ * success: IsArray(IsItem),
14
+ * errors: [NOT_AUTHORIZED, SERVER_ERROR]
15
+ * },
16
+ * create: {
17
+ * input: IsCreateRequest,
18
+ * success: IsItem,
19
+ * errors: [NOT_AUTHORIZED, VALIDATION_ERROR, SERVER_ERROR]
20
+ * }
21
+ * }))
22
+ *
23
+ * export const MyApi = httpApi(MyApiContract)
24
+ * .pathPrefix("api/items")
25
+ * .use(AuthMiddleware)
26
+ * .routes({
27
+ * list: GGRpc.GET("list"),
28
+ * create: GGRpc.POST("create")
29
+ * })
30
+ */
31
+ export function httpSchema<TContract extends GGContractApiDefinition>(
32
+ contract: GGContractClass<TContract>
33
+ ): GGHttpSchemaBuilder<TContract> {
34
+ return new GGHttpSchemaBuilder(contract)
35
+ }
36
+
37
+ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext = undefined> {
38
+
39
+ private readonly _contract: GGContractClass<TContract>
40
+ private _pathPrefix: string = ""
41
+ private _middlewares: GGHttpTransportMiddleware[] = []
42
+
43
+ constructor(contract: GGContractClass<TContract>) {
44
+ this._contract = contract;
45
+ }
46
+
47
+ pathPrefix(prefix: string): this {
48
+ this._pathPrefix = prefix
49
+ return this
50
+ }
51
+
52
+ use<M extends GGHttpTransportMiddleware>(middleware: M): GGHttpSchemaBuilder<TContract, TContext | M> {
53
+ this._middlewares.push(middleware)
54
+ return this as unknown as GGHttpSchemaBuilder<TContract, TContext | M>
55
+ }
56
+
57
+ useHeader<Input>(contextKey: GGContextKey<Input>): GGHttpSchemaBuilder<TContract, TContext | Input> {
58
+ const codec = contextKey.getCodec("http");
59
+ if (!codec) {
60
+ throw new Error(`Context key '${contextKey.name}' does not have an 'http-header' codec registered.`);
61
+ }
62
+
63
+ const middleware: GGHttpTransportMiddleware = {
64
+ updateRequest(req: GGHttpRequest) {
65
+ // Client-side: context -> headers
66
+ const contextValue = contextKey.get();
67
+ if (contextValue !== undefined) {
68
+ const result = codec.decode(contextValue);
69
+ if (result.success) {
70
+ Object.assign(req.headers, result.value);
71
+ }
72
+ } else {
73
+ // Clear any default headers by decoding an empty context
74
+ const emptyResult = codec.decode({} as Input);
75
+ if (emptyResult.success) {
76
+ for (const key of Object.keys(emptyResult.value as object)) {
77
+ delete req.headers[key];
78
+ }
79
+ }
80
+ }
81
+ },
82
+ parseRequest(req: http.IncomingMessage) {
83
+ // Server-side: headers -> context
84
+ const headers = req.headers as Record<string, string>;
85
+ const result = codec.encode(headers);
86
+ if (result.success) {
87
+ contextKey.set(result.value);
88
+ }
89
+ }
90
+ };
91
+
92
+ this._middlewares.push(middleware);
93
+ return this as unknown as GGHttpSchemaBuilder<TContract, TContext | Input>;
94
+ }
95
+
96
+ routes(mapping: { [K in keyof TContract]: GGHttpCodec }): GGHttpSchema<TContract, TContext> {
97
+ return new GGHttpSchema(this._pathPrefix, this._contract, mapping, this._middlewares)
98
+ }
99
+ }
@@ -0,0 +1,27 @@
1
+ import {GGHttpSchema} from "../schema/GGHttpSchema";
2
+ import {GGContractApiDefinition, GGContractImplementation} from "@grest-ts/schema";
3
+ import {GGHttpServerMiddleware} from "./GGHttpSchema.startServer";
4
+ import {GGHttpServer} from "./GGHttpServer";
5
+
6
+ export class GGHttp<TContext = undefined> {
7
+
8
+ private readonly httpServer: GGHttpServer
9
+ private readonly middlewares: GGHttpServerMiddleware[] = [];
10
+
11
+ constructor(httpServer: GGHttpServer) {
12
+ this.httpServer = httpServer;
13
+ }
14
+
15
+ public use<M extends GGHttpServerMiddleware>(middleware: M): GGHttp<TContext | M> {
16
+ this.middlewares.push(middleware);
17
+ return this as any;
18
+ }
19
+
20
+ public http<TContract extends GGContractApiDefinition, TSchemaContext>(
21
+ schema: GGHttpSchema<TContract, TSchemaContext>,
22
+ implementation: GGContractImplementation<TContract>
23
+ ): this {
24
+ schema.register(implementation, {http: this.httpServer, middlewares: this.middlewares});
25
+ return this as any;
26
+ }
27
+ }
@@ -0,0 +1,31 @@
1
+ import {GGCounterKey, GGHistogramKey, GGMetrics} from "@grest-ts/metrics";
2
+ import {EXISTS, FORBIDDEN, NOT_AUTHORIZED, NOT_FOUND, OK, ROUTE_NOT_FOUND, SERVER_ERROR, VALIDATION_ERROR} from "@grest-ts/schema";
3
+
4
+ /**
5
+ * Result type for HTTP operations.
6
+ * Known types listed for documentation, but accepts any string for custom error types.
7
+ */
8
+ type ResultType =
9
+ | typeof OK.TYPE
10
+ | typeof VALIDATION_ERROR.TYPE
11
+ | typeof NOT_AUTHORIZED.TYPE
12
+ | typeof FORBIDDEN.TYPE
13
+ | typeof NOT_FOUND.TYPE
14
+ | typeof ROUTE_NOT_FOUND.TYPE
15
+ | typeof EXISTS.TYPE
16
+ | typeof SERVER_ERROR.TYPE
17
+ | string;
18
+
19
+ export const GGHttpMetrics = GGMetrics.define('/http/', () => ({
20
+ requests: new GGCounterKey<{ api: string, method: string, path: string, result: ResultType }>('requests_total', {
21
+ help: 'Total HTTP requests after contract validation',
22
+ labelNames: ['api', 'method', 'path', 'result'],
23
+ groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
24
+ }),
25
+ requestDuration: new GGHistogramKey<{ api: string, method: string, path: string }>('request_duration_ms', {
26
+ help: 'HTTP request duration in milliseconds',
27
+ labelNames: ['api', 'method', 'path'],
28
+ buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000],
29
+ groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
30
+ }),
31
+ }));