skir-client 0.0.10 → 1.0.0

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.
@@ -505,7 +505,7 @@ export interface PrimitiveTypes {
505
505
  bool: boolean;
506
506
  int32: number;
507
507
  int64: bigint;
508
- uint64: bigint;
508
+ hash64: bigint;
509
509
  float32: number;
510
510
  float64: number;
511
511
  timestamp: Timestamp;
@@ -938,7 +938,7 @@ class OutputStream implements BinaryForm {
938
938
  dataView.setInt32((this.offset += 4) - 4, value, true);
939
939
  }
940
940
 
941
- writeUint64(value: bigint): void {
941
+ writeHash64(value: bigint): void {
942
942
  const dataView = this.reserve(8);
943
943
  dataView.setBigUint64((this.offset += 8) - 8, value, true);
944
944
  }
@@ -1438,7 +1438,7 @@ class Float64Serializer extends FloatSerializer<"float64"> {
1438
1438
  }
1439
1439
 
1440
1440
  abstract class AbstractBigIntSerializer<
1441
- P extends "int64" | "uint64",
1441
+ P extends "int64" | "hash64",
1442
1442
  > extends AbstractPrimitiveSerializer<P> {
1443
1443
  readonly defaultValue = BigInt(0);
1444
1444
 
@@ -1499,17 +1499,17 @@ class Int64Serializer extends AbstractBigIntSerializer<"int64"> {
1499
1499
  }
1500
1500
  }
1501
1501
 
1502
- const MAX_UINT64 = BigInt("18446744073709551615");
1502
+ const MAX_HASH64 = BigInt("18446744073709551615");
1503
1503
 
1504
- class Uint64Serializer extends AbstractBigIntSerializer<"uint64"> {
1505
- readonly primitive = "uint64";
1504
+ class Hash64Serializer extends AbstractBigIntSerializer<"hash64"> {
1505
+ readonly primitive = "hash64";
1506
1506
 
1507
1507
  toJson(input: bigint): number | string {
1508
1508
  if (input <= 9007199254740991) {
1509
1509
  return input <= 0 ? 0 : Number(input);
1510
1510
  }
1511
1511
  input = BigInt(input);
1512
- return MAX_UINT64 < input ? MAX_UINT64.toString() : input.toString();
1512
+ return MAX_HASH64 < input ? MAX_HASH64.toString() : input.toString();
1513
1513
  }
1514
1514
 
1515
1515
  encode(input: bigint, stream: OutputStream): void {
@@ -1525,7 +1525,7 @@ class Uint64Serializer extends AbstractBigIntSerializer<"uint64"> {
1525
1525
  }
1526
1526
  } else {
1527
1527
  stream.writeUint8(234);
1528
- stream.writeUint64(input <= MAX_UINT64 ? input : MAX_UINT64);
1528
+ stream.writeHash64(input <= MAX_HASH64 ? input : MAX_HASH64);
1529
1529
  }
1530
1530
  }
1531
1531
 
@@ -1884,7 +1884,7 @@ const primitiveSerializers: {
1884
1884
  bool: new BoolSerializer(),
1885
1885
  int32: int32_Serializer,
1886
1886
  int64: new Int64Serializer(),
1887
- uint64: new Uint64Serializer(),
1887
+ hash64: new Hash64Serializer(),
1888
1888
  float32: new Float32Serializer(),
1889
1889
  float64: new Float64Serializer(),
1890
1890
  timestamp: new TimestampSerializer(),
@@ -1911,9 +1911,9 @@ function decodeUnused(stream: InputStream): void {
1911
1911
  case 8: // float32
1912
1912
  stream.offset += 4;
1913
1913
  break;
1914
- case 2: // uint64
1914
+ case 2: // hash64
1915
1915
  case 6: // int64
1916
- case 7: // uint64 timestamp
1916
+ case 7: // hash64 timestamp
1917
1917
  case 9: // float64
1918
1918
  stream.offset += 8;
1919
1919
  break;
@@ -2784,7 +2784,6 @@ export class ServiceClient {
2784
2784
  request: Request,
2785
2785
  httpMethod: "GET" | "POST" = "POST",
2786
2786
  ): Promise<Response> {
2787
- this.lastRespHeaders = undefined;
2788
2787
  const requestJson = method.requestSerializer.toJsonCode(request);
2789
2788
  const requestBody = [method.name, method.number, "", requestJson].join(":");
2790
2789
  const requestInit: RequestInit = {
@@ -2798,7 +2797,6 @@ export class ServiceClient {
2798
2797
  url.search = requestBody.replace(/%/g, "%25");
2799
2798
  }
2800
2799
  const httpResponse = await fetch(url, requestInit);
2801
- this.lastRespHeaders = httpResponse.headers;
2802
2800
  const responseData = await httpResponse.blob();
2803
2801
  if (httpResponse.ok) {
2804
2802
  const jsonCode = await responseData.text();
@@ -2814,92 +2812,375 @@ export class ServiceClient {
2814
2812
  throw new Error(`HTTP status ${httpResponse.status}${message}`);
2815
2813
  }
2816
2814
  }
2815
+ }
2817
2816
 
2818
- get lastResponseHeaders(): Headers | undefined {
2819
- return this.lastRespHeaders;
2820
- }
2817
+ /** Raw response returned by the server. */
2818
+ export interface RawResponse {
2819
+ readonly data: string;
2820
+ readonly statusCode: number;
2821
+ readonly contentType: string;
2822
+ }
2821
2823
 
2822
- private lastRespHeaders: Headers | undefined;
2824
+ function makeOkJsonResponse(data: string): RawResponse {
2825
+ return {
2826
+ data: data,
2827
+ statusCode: 200,
2828
+ contentType: "application/json",
2829
+ };
2823
2830
  }
2824
2831
 
2825
- /** Raw response returned by the server. */
2826
- export class RawResponse {
2827
- constructor(
2828
- readonly data: string,
2829
- readonly type: "ok-json" | "ok-html" | "bad-request" | "server-error",
2830
- ) {}
2832
+ function makeOkHtmlResponse(data: string): RawResponse {
2833
+ return {
2834
+ data: data,
2835
+ statusCode: 200,
2836
+ contentType: "text/html; charset=utf-8",
2837
+ };
2838
+ }
2831
2839
 
2832
- get statusCode(): number {
2833
- switch (this.type) {
2834
- case "ok-json":
2835
- case "ok-html":
2836
- return 200;
2837
- case "bad-request":
2838
- return 400;
2839
- case "server-error":
2840
- return 500;
2841
- default: {
2842
- const _: never = this.type;
2843
- throw new Error(_);
2844
- }
2845
- }
2846
- }
2840
+ function makeBadRequestResponse(data: string): RawResponse {
2841
+ return {
2842
+ data: data,
2843
+ statusCode: 400,
2844
+ contentType: "text/plain; charset=utf-8",
2845
+ };
2846
+ }
2847
2847
 
2848
- get contentType(): string {
2849
- switch (this.type) {
2850
- case "ok-json":
2851
- return "application/json";
2852
- case "ok-html":
2853
- return "text/html; charset=utf-8";
2854
- case "bad-request":
2855
- case "server-error":
2856
- return "text/plain; charset=utf-8";
2857
- default: {
2858
- const _: never = this.type;
2859
- throw new Error(_);
2860
- }
2861
- }
2862
- }
2848
+ function makeServerErrorResponse(data: string, statusCode = 500): RawResponse {
2849
+ return {
2850
+ data: data,
2851
+ statusCode: statusCode,
2852
+ contentType: "text/plain; charset=utf-8",
2853
+ };
2863
2854
  }
2864
2855
 
2865
- // Copied from
2866
- // https://github.com/gepheum/restudio/blob/main/index.jsdeliver.html
2867
- const RESTUDIO_HTML = `<!DOCTYPE html>
2856
+ function getStudioHtml(studioAppJsUrl: string): string {
2857
+ // Copied from
2858
+ // https://github.com/gepheum/skir-studio/blob/main/index.jsdeliver.html
2859
+ return `<!DOCTYPE html>
2868
2860
 
2869
2861
  <html>
2870
2862
  <head>
2871
2863
  <meta charset="utf-8" />
2872
2864
  <title>RESTudio</title>
2873
- <script src="https://cdn.jsdelivr.net/npm/restudio/dist/restudio-standalone.js"></script>
2865
+ <script src="${studioAppJsUrl}"></script>
2874
2866
  </head>
2875
2867
  <body style="margin: 0; padding: 0;">
2876
2868
  <restudio-app></restudio-app>
2877
2869
  </body>
2878
2870
  </html>
2879
2871
  `;
2872
+ }
2873
+
2874
+ /**
2875
+ * If this error is thrown from a method implementation, the specified status
2876
+ * code and message will be returned in the HTTP response.
2877
+ *
2878
+ * If any other type of exception is thrown, the response status code will be
2879
+ * 500 (Internal Server Error).
2880
+ */
2881
+ export class ServiceError extends Error {
2882
+ constructor(
2883
+ private readonly spec:
2884
+ | {
2885
+ statusCode: 400;
2886
+ desc: "Bad Request";
2887
+ message?: string;
2888
+ }
2889
+ | {
2890
+ statusCode: 401;
2891
+ desc: "Unauthorized";
2892
+ message?: string;
2893
+ }
2894
+ | {
2895
+ statusCode: 402;
2896
+ desc: "Payment Required";
2897
+ message?: string;
2898
+ }
2899
+ | {
2900
+ statusCode: 403;
2901
+ desc: "Forbidden";
2902
+ message?: string;
2903
+ }
2904
+ | {
2905
+ statusCode: 404;
2906
+ desc: "Not Found";
2907
+ message?: string;
2908
+ }
2909
+ | {
2910
+ statusCode: 405;
2911
+ desc: "Method Not Allowed";
2912
+ message?: string;
2913
+ }
2914
+ | {
2915
+ statusCode: 406;
2916
+ desc: "Not Acceptable";
2917
+ message?: string;
2918
+ }
2919
+ | {
2920
+ statusCode: 407;
2921
+ desc: "Proxy Authentication Required";
2922
+ message?: string;
2923
+ }
2924
+ | {
2925
+ statusCode: 408;
2926
+ desc: "Request Timeout";
2927
+ message?: string;
2928
+ }
2929
+ | {
2930
+ statusCode: 409;
2931
+ desc: "Conflict";
2932
+ message?: string;
2933
+ }
2934
+ | {
2935
+ statusCode: 410;
2936
+ desc: "Gone";
2937
+ message?: string;
2938
+ }
2939
+ | {
2940
+ statusCode: 411;
2941
+ desc: "Length Required";
2942
+ message?: string;
2943
+ }
2944
+ | {
2945
+ statusCode: 412;
2946
+ desc: "Precondition Failed";
2947
+ message?: string;
2948
+ }
2949
+ | {
2950
+ statusCode: 413;
2951
+ desc: "Content Too Large";
2952
+ message?: string;
2953
+ }
2954
+ | {
2955
+ statusCode: 414;
2956
+ desc: "URI Too Long";
2957
+ message?: string;
2958
+ }
2959
+ | {
2960
+ statusCode: 415;
2961
+ desc: "Unsupported Media Type";
2962
+ message?: string;
2963
+ }
2964
+ | {
2965
+ statusCode: 416;
2966
+ desc: "Range Not Satisfiable";
2967
+ message?: string;
2968
+ }
2969
+ | {
2970
+ statusCode: 417;
2971
+ desc: "Expectation Failed";
2972
+ message?: string;
2973
+ }
2974
+ | {
2975
+ statusCode: 418;
2976
+ desc: "I'm a teapot";
2977
+ message?: string;
2978
+ }
2979
+ | {
2980
+ statusCode: 421;
2981
+ desc: "Misdirected Request";
2982
+ message?: string;
2983
+ }
2984
+ | {
2985
+ statusCode: 422;
2986
+ desc: "Unprocessable Content";
2987
+ message?: string;
2988
+ }
2989
+ | {
2990
+ statusCode: 423;
2991
+ desc: "Locked";
2992
+ message?: string;
2993
+ }
2994
+ | {
2995
+ statusCode: 424;
2996
+ desc: "Failed Dependency";
2997
+ message?: string;
2998
+ }
2999
+ | {
3000
+ statusCode: 425;
3001
+ desc: "Too Early";
3002
+ message?: string;
3003
+ }
3004
+ | {
3005
+ statusCode: 426;
3006
+ desc: "Upgrade Required";
3007
+ message?: string;
3008
+ }
3009
+ | {
3010
+ statusCode: 428;
3011
+ desc: "Precondition Required";
3012
+ message?: string;
3013
+ }
3014
+ | {
3015
+ statusCode: 429;
3016
+ desc: "Too Many Requests";
3017
+ message?: string;
3018
+ }
3019
+ | {
3020
+ statusCode: 431;
3021
+ desc: "Request Header Fields Too Large";
3022
+ message?: string;
3023
+ }
3024
+ | {
3025
+ statusCode: 451;
3026
+ desc: "Unavailable For Legal Reasons";
3027
+ message?: string;
3028
+ }
3029
+ | {
3030
+ statusCode: 500;
3031
+ desc: "Internal Server Error";
3032
+ message?: string;
3033
+ }
3034
+ | {
3035
+ statusCode: 501;
3036
+ desc: "Not Implemented";
3037
+ message?: string;
3038
+ }
3039
+ | {
3040
+ statusCode: 502;
3041
+ desc: "Bad Gateway";
3042
+ message?: string;
3043
+ }
3044
+ | {
3045
+ statusCode: 503;
3046
+ desc: "Service Unavailable";
3047
+ message?: string;
3048
+ }
3049
+ | {
3050
+ statusCode: 504;
3051
+ desc: "Gateway Timeout";
3052
+ message?: string;
3053
+ }
3054
+ | {
3055
+ statusCode: 505;
3056
+ desc: "HTTP Version Not Supported";
3057
+ message?: string;
3058
+ }
3059
+ | {
3060
+ statusCode: 506;
3061
+ desc: "Variant Also Negotiates";
3062
+ message?: string;
3063
+ }
3064
+ | {
3065
+ statusCode: 507;
3066
+ desc: "Insufficient Storage";
3067
+ message?: string;
3068
+ }
3069
+ | {
3070
+ statusCode: 508;
3071
+ desc: "Loop Detected";
3072
+ message?: string;
3073
+ }
3074
+ | {
3075
+ statusCode: 510;
3076
+ desc: "Not Extended";
3077
+ message?: string;
3078
+ }
3079
+ | {
3080
+ statusCode: 511;
3081
+ desc: "Network Authentication Required";
3082
+ message?: string;
3083
+ },
3084
+ ) {
3085
+ super(spec.message ?? spec.desc);
3086
+ }
3087
+
3088
+ toRawResponse(): RawResponse {
3089
+ return makeServerErrorResponse(
3090
+ this.spec.message ?? this.spec.desc,
3091
+ this.spec.statusCode,
3092
+ );
3093
+ }
3094
+ }
3095
+
3096
+ export interface RequestHandler<RequestMeta = ExpressRequest> {
3097
+ /**
3098
+ * Parses the content of a user request and invokes the appropriate method.
3099
+ * If you are using ExpressJS as your web application framework, you don't
3100
+ * need to call this method, you can simply call the
3101
+ * `installServiceOnExpressApp()` top-level function.
3102
+ *
3103
+ * If the request is a GET request, pass in the decoded query string as the
3104
+ * request's body. The query string is the part of the URL after '?', and it
3105
+ * can be decoded with DecodeURIComponent.
3106
+ */
3107
+ handleRequest(reqBody: string, reqMeta: RequestMeta): Promise<RawResponse>;
3108
+ }
2880
3109
 
2881
3110
  /**
2882
3111
  * Implementation of a skir service.
2883
3112
  *
2884
3113
  * Usage: call `.addMethod()` to register methods, then install the service on
2885
3114
  * an HTTP server either by:
2886
- * - calling the `installServiceOnExpressApp()` top-level function if you are
3115
+ * - calling the `installServiceOnExpressApp()` top-level function if you are
2887
3116
  * using ExpressJS
2888
3117
  * - writing your own implementation of `installServiceOn*()` which calls
2889
3118
  * `.handleRequest()` if you are using another web application framework
3119
+ *
3120
+ * ## Handling Request Metadata
3121
+ *
3122
+ * The `RequestMeta` type parameter specifies what metadata (authentication,
3123
+ * headers, etc.) your method implementations receive. There are two approaches:
3124
+ *
3125
+ * ### Approach 1: Use the framework's request type directly
3126
+ *
3127
+ * Set `RequestMeta` to your framework's request type (e.g., `ExpressRequest`).
3128
+ * All method implementations will receive the full framework request object.
3129
+ *
3130
+ * ```typescript
3131
+ * const service = new Service<ExpressRequest>();
3132
+ * service.addMethod(myMethod, async (req, expressReq) => {
3133
+ * const isAdmin = expressReq.user?.role === 'admin';
3134
+ * // ...
3135
+ * });
3136
+ * installServiceOnExpressApp(app, '/api', service, text, json);
3137
+ * ```
3138
+ *
3139
+ * ### Approach 2: Use a simplified custom type (recommended for testing)
3140
+ *
3141
+ * Set `RequestMeta` to a minimal type containing only what your service needs.
3142
+ * Use `withRequestMeta()` to extract this data from the framework request when
3143
+ * installing the service.
3144
+ *
3145
+ * ```typescript
3146
+ * const service = new Service<{ isAdmin: boolean }>();
3147
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
3148
+ * // Implementation is framework-agnostic and easy to unit test
3149
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
3150
+ * // ...
3151
+ * });
3152
+ *
3153
+ * // Adapt to Express when installing
3154
+ * const handler = service.withRequestMeta((req: ExpressRequest) => ({
3155
+ * isAdmin: req.user?.role === 'admin'
3156
+ * }));
3157
+ * installServiceOnExpressApp(app, '/api', handler, text, json);
3158
+ * ```
3159
+ *
3160
+ * This approach decouples your service from the HTTP framework, making it easier
3161
+ * to test and clearer about what request data it actually uses.
2890
3162
  */
2891
- export class Service<
2892
- RequestMeta = ExpressRequest,
2893
- ResponseMeta = ExpressResponse,
2894
- > {
3163
+ export class Service<RequestMeta = ExpressRequest>
3164
+ implements RequestHandler<RequestMeta>
3165
+ {
3166
+ constructor(options?: Partial<ServiceOptions<RequestMeta>>) {
3167
+ this.options = {
3168
+ keepUnrecognizedValues:
3169
+ options?.keepUnrecognizedValues ??
3170
+ DEFAULT_SERVICE_OPTIONS.keepUnrecognizedValues,
3171
+ canCopyUnknownErrorMessageToResponse:
3172
+ options?.canCopyUnknownErrorMessageToResponse ??
3173
+ DEFAULT_SERVICE_OPTIONS.canCopyUnknownErrorMessageToResponse,
3174
+ studioAppJsUrl: new URL(
3175
+ options?.studioAppJsUrl ?? DEFAULT_SERVICE_OPTIONS.studioAppJsUrl,
3176
+ ).toString(),
3177
+ };
3178
+ }
3179
+
2895
3180
  addMethod<Request, Response>(
2896
3181
  method: Method<Request, Response>,
2897
- impl: (
2898
- req: Request,
2899
- reqMeta: RequestMeta,
2900
- resMeta: ResponseMeta,
2901
- ) => Promise<Response>,
2902
- ): Service<RequestMeta, ResponseMeta> {
3182
+ impl: (req: Request, reqMeta: RequestMeta) => Promise<Response>,
3183
+ ): Service<RequestMeta> {
2903
3184
  const { number } = method;
2904
3185
  if (this.methodImpls[number]) {
2905
3186
  throw new Error(
@@ -2909,28 +3190,13 @@ export class Service<
2909
3190
  this.methodImpls[number] = {
2910
3191
  method: method,
2911
3192
  impl: impl,
2912
- } as MethodImpl<unknown, unknown, RequestMeta, ResponseMeta>;
3193
+ } as MethodImpl<unknown, unknown, RequestMeta>;
2913
3194
  return this;
2914
3195
  }
2915
3196
 
2916
- /**
2917
- * Parses the content of a user request and invokes the appropriate method.
2918
- * If you are using ExpressJS as your web application framework, you don't
2919
- * need to call this method, you can simply call the
2920
- * `installServiceOnExpressApp()` top-level function.
2921
- *
2922
- * If the request is a GET request, pass in the decoded query string as the
2923
- * request's body. The query string is the part of the URL after '?', and it
2924
- * can be decoded with DecodeURIComponent.
2925
- *
2926
- * Pass in "keep-unrecognized-values" if the request cannot come from a
2927
- * malicious user.
2928
- */
2929
3197
  async handleRequest(
2930
3198
  reqBody: string,
2931
3199
  reqMeta: RequestMeta,
2932
- resMeta: ResponseMeta,
2933
- keepUnrecognizedValues?: "keep-unrecognized-values",
2934
3200
  ): Promise<RawResponse> {
2935
3201
  if (reqBody === "" || reqBody === "list") {
2936
3202
  const json = {
@@ -2944,9 +3210,10 @@ export class Service<
2944
3210
  })),
2945
3211
  };
2946
3212
  const jsonCode = JSON.stringify(json, undefined, " ");
2947
- return new RawResponse(jsonCode, "ok-json");
2948
- } else if (reqBody === "debug" || reqBody === "restudio") {
2949
- return new RawResponse(RESTUDIO_HTML, "ok-html");
3213
+ return makeOkHtmlResponse(jsonCode);
3214
+ } else if (reqBody === "studio") {
3215
+ const studioHtml = getStudioHtml(this.options.studioAppJsUrl);
3216
+ return makeOkHtmlResponse(studioHtml);
2950
3217
  }
2951
3218
 
2952
3219
  // Parse request
@@ -2961,14 +3228,13 @@ export class Service<
2961
3228
  let reqBodyJson: Json;
2962
3229
  try {
2963
3230
  reqBodyJson = JSON.parse(reqBody);
2964
- } catch (e) {
2965
- return new RawResponse("bad request: invalid JSON", "bad-request");
3231
+ } catch (_e) {
3232
+ return makeBadRequestResponse("bad request: invalid JSON");
2966
3233
  }
2967
3234
  const methodField = (reqBodyJson as AnyRecord)["method"];
2968
3235
  if (methodField === undefined) {
2969
- return new RawResponse(
3236
+ return makeBadRequestResponse(
2970
3237
  "bad request: missing 'method' field in JSON",
2971
- "bad-request",
2972
3238
  );
2973
3239
  }
2974
3240
  if (typeof methodField === "string") {
@@ -2978,17 +3244,15 @@ export class Service<
2978
3244
  methodName = "?";
2979
3245
  methodNumber = methodField;
2980
3246
  } else {
2981
- return new RawResponse(
3247
+ return makeBadRequestResponse(
2982
3248
  "bad request: 'method' field must be a string or a number",
2983
- "bad-request",
2984
3249
  );
2985
3250
  }
2986
3251
  format = "readable";
2987
3252
  const requestField = (reqBodyJson as AnyRecord)["request"];
2988
3253
  if (requestField === undefined) {
2989
- return new RawResponse(
3254
+ return makeBadRequestResponse(
2990
3255
  "bad request: missing 'request' field in JSON",
2991
- "bad-request",
2992
3256
  );
2993
3257
  }
2994
3258
  requestData = ["json", requestField as Json];
@@ -2996,10 +3260,7 @@ export class Service<
2996
3260
  // A colon-separated string
2997
3261
  const match = reqBody.match(/^([^:]*):([^:]*):([^:]*):([\S\s]*)$/);
2998
3262
  if (!match) {
2999
- return new RawResponse(
3000
- "bad request: invalid request format",
3001
- "bad-request",
3002
- );
3263
+ return makeBadRequestResponse("bad request: invalid request format");
3003
3264
  }
3004
3265
  methodName = match[1]!;
3005
3266
  const methodNumberStr = match[2]!;
@@ -3008,9 +3269,8 @@ export class Service<
3008
3269
 
3009
3270
  if (methodNumberStr) {
3010
3271
  if (!/^-?[0-9]+$/.test(methodNumberStr)) {
3011
- return new RawResponse(
3272
+ return makeBadRequestResponse(
3012
3273
  "bad request: can't parse method number",
3013
- "bad-request",
3014
3274
  );
3015
3275
  }
3016
3276
  methodNumber = parseInt(methodNumberStr);
@@ -3027,14 +3287,12 @@ export class Service<
3027
3287
  (m) => m.method.name === methodName,
3028
3288
  );
3029
3289
  if (nameMatches.length === 0) {
3030
- return new RawResponse(
3290
+ return makeBadRequestResponse(
3031
3291
  `bad request: method not found: ${methodName}`,
3032
- "bad-request",
3033
3292
  );
3034
3293
  } else if (nameMatches.length > 1) {
3035
- return new RawResponse(
3294
+ return makeBadRequestResponse(
3036
3295
  `bad request: method name '${methodName}' is ambiguous; use method number instead`,
3037
- "bad-request",
3038
3296
  );
3039
3297
  }
3040
3298
  methodNumber = nameMatches[0]!.method.number;
@@ -3042,9 +3300,8 @@ export class Service<
3042
3300
 
3043
3301
  const methodImpl = this.methodImpls[methodNumber];
3044
3302
  if (!methodImpl) {
3045
- return new RawResponse(
3303
+ return makeBadRequestResponse(
3046
3304
  `bad request: method not found: ${methodName}; number: ${methodNumber}`,
3047
- "bad-request",
3048
3305
  );
3049
3306
  }
3050
3307
 
@@ -3053,26 +3310,36 @@ export class Service<
3053
3310
  if (requestData[0] == "json") {
3054
3311
  req = methodImpl.method.requestSerializer.fromJson(
3055
3312
  requestData[1],
3056
- keepUnrecognizedValues,
3313
+ this.options.keepUnrecognizedValues
3314
+ ? "keep-unrecognized-values"
3315
+ : undefined,
3057
3316
  );
3058
3317
  } else {
3059
3318
  req = methodImpl.method.requestSerializer.fromJsonCode(
3060
3319
  requestData[1],
3061
- keepUnrecognizedValues,
3320
+ this.options.keepUnrecognizedValues
3321
+ ? "keep-unrecognized-values"
3322
+ : undefined,
3062
3323
  );
3063
3324
  }
3064
3325
  } catch (e) {
3065
- return new RawResponse(
3066
- `bad request: can't parse JSON: ${e}`,
3067
- "bad-request",
3068
- );
3326
+ return makeBadRequestResponse(`bad request: can't parse JSON: ${e}`);
3069
3327
  }
3070
3328
 
3071
3329
  let res: unknown;
3072
3330
  try {
3073
- res = await methodImpl.impl(req, reqMeta, resMeta);
3331
+ res = await methodImpl.impl(req, reqMeta);
3074
3332
  } catch (e) {
3075
- return new RawResponse(`server error: ${e}`, "server-error");
3333
+ if (e instanceof ServiceError) {
3334
+ return e.toRawResponse();
3335
+ } else {
3336
+ const message = this.options.canCopyUnknownErrorMessageToResponse(
3337
+ reqMeta,
3338
+ )
3339
+ ? `server error: ${e}`
3340
+ : "server error";
3341
+ return makeServerErrorResponse(message);
3342
+ }
3076
3343
  }
3077
3344
 
3078
3345
  let resJson: string;
@@ -3080,36 +3347,121 @@ export class Service<
3080
3347
  const flavor = format === "readable" ? "readable" : "dense";
3081
3348
  resJson = methodImpl.method.responseSerializer.toJsonCode(res, flavor);
3082
3349
  } catch (e) {
3083
- return new RawResponse(
3350
+ return makeServerErrorResponse(
3084
3351
  `server error: can't serialize response to JSON: ${e}`,
3085
- "server-error",
3086
3352
  );
3087
3353
  }
3088
3354
 
3089
- return new RawResponse(resJson, "ok-json");
3355
+ return makeOkJsonResponse(resJson);
3356
+ }
3357
+
3358
+ /**
3359
+ * Creates a request handler that extracts simplified request metadata from
3360
+ * framework-specific request objects before passing it to this service.
3361
+ *
3362
+ * This decouples your service implementation from the HTTP framework, making
3363
+ * it easier to unit test (tests don't need to mock framework objects) and
3364
+ * making the service implementation clearer by explicitly declaring exactly
3365
+ * what request data it needs.
3366
+ *
3367
+ * @param transformFn Function that extracts the necessary data from the
3368
+ * framework-specific request object. Can be async or sync.
3369
+ * @returns A request handler that accepts the framework-specific request type.
3370
+ *
3371
+ * @example
3372
+ * ```typescript
3373
+ * // Define a service that only needs to know if the user is an admin
3374
+ *
3375
+ * const service = new Service<{ isAdmin: boolean }>();
3376
+ *
3377
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
3378
+ * // Implementation is framework-agnostic and easy to test
3379
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
3380
+ * // ...
3381
+ * });
3382
+ *
3383
+ * // Adapt it to work with Express
3384
+ * const expressHandler = service.withRequestMeta((req: ExpressRequest) => ({
3385
+ * isAdmin: req.user?.role === 'admin'
3386
+ * }));
3387
+ * installServiceOnExpressApp(app, '/api', expressHandler, text, json);
3388
+ * ```
3389
+ */
3390
+ withRequestMeta<NewRequestMeta>(
3391
+ transformFn: (
3392
+ reqMeta: NewRequestMeta,
3393
+ ) => Promise<RequestMeta> | RequestMeta,
3394
+ ): RequestHandler<NewRequestMeta> {
3395
+ return {
3396
+ handleRequest: async (
3397
+ reqBody: string,
3398
+ reqMeta: NewRequestMeta,
3399
+ ): Promise<RawResponse> => {
3400
+ const transformedMeta = await Promise.resolve(transformFn(reqMeta));
3401
+ return this.handleRequest(reqBody, transformedMeta);
3402
+ },
3403
+ };
3090
3404
  }
3091
3405
 
3406
+ private readonly options: ServiceOptions<RequestMeta>;
3092
3407
  private readonly methodImpls: {
3093
- [number: number]: MethodImpl<unknown, unknown, RequestMeta, ResponseMeta>;
3408
+ [number: number]: MethodImpl<unknown, unknown, RequestMeta>;
3094
3409
  } = {};
3095
3410
  }
3096
3411
 
3097
- interface MethodImpl<Request, Response, RequestMeta, ResponseMeta> {
3412
+ /** Configuration options for a Skir service. */
3413
+ export interface ServiceOptions<RequestMeta = ExpressRequest> {
3414
+ /**
3415
+ * Whether to keep unrecognized values when deserializing requests.
3416
+ *
3417
+ * **WARNING:** Only enable this for data from trusted sources. Malicious
3418
+ * actors could inject fields with IDs not yet defined in your schema. If you
3419
+ * preserve this data and later define those IDs in a future schema version,
3420
+ * the injected data could be deserialized as valid fields, leading to
3421
+ * security vulnerabilities or data corruption.
3422
+ *
3423
+ * Defaults to `false`.
3424
+ */
3425
+ keepUnrecognizedValues: boolean;
3426
+ /**
3427
+ * Predicate that determines whether the message of an unknown error (i.e. not
3428
+ * a `ServiceError`) should be sent to the client.
3429
+ *
3430
+ * By default, unknown errors are masked and the client receives a generic
3431
+ * 'server error' message with status 500. This is to prevent leaking
3432
+ * sensitive information to the client.
3433
+ *
3434
+ * You can enable this for debugging purposes or if you are sure that your
3435
+ * error messages are safe to expose.
3436
+ */
3437
+ canCopyUnknownErrorMessageToResponse: (reqMeta: RequestMeta) => boolean;
3438
+ /**
3439
+ * URL to the JavaScript file for the Skir Studio app.
3440
+ *
3441
+ * Skir Studio is a web interface for exploring and testing your Skir service.
3442
+ * It is served when the service receives a request at '${serviceUrl}?studio'.
3443
+ */
3444
+ studioAppJsUrl: string;
3445
+ }
3446
+
3447
+ const DEFAULT_SERVICE_OPTIONS: ServiceOptions<unknown> = {
3448
+ keepUnrecognizedValues: false,
3449
+ canCopyUnknownErrorMessageToResponse: () => false,
3450
+ studioAppJsUrl:
3451
+ "https://cdn.jsdelivr.net/npm/skir-studio/dist/skir-studio-standalone.js",
3452
+ };
3453
+
3454
+ interface MethodImpl<Request, Response, RequestMeta> {
3098
3455
  method: Method<Request, Response>;
3099
- impl: (
3100
- req: Request,
3101
- reqMeta: RequestMeta,
3102
- resMeta: ResponseMeta,
3103
- ) => Promise<Response>;
3456
+ impl: (req: Request, reqMeta: RequestMeta) => Promise<Response>;
3104
3457
  }
3105
3458
 
3106
3459
  export function installServiceOnExpressApp(
3107
3460
  app: ExpressApp,
3108
3461
  queryPath: string,
3109
- service: Service<ExpressRequest, ExpressResponse>,
3462
+ service: RequestHandler<ExpressRequest>,
3110
3463
  text: typeof ExpressText,
3111
3464
  json: typeof ExpressJson,
3112
- keepUnrecognizedValues?: "keep-unrecognized-values",
3113
3465
  ): void {
3114
3466
  const callback = async (
3115
3467
  req: ExpressRequest,
@@ -3128,12 +3480,7 @@ export function installServiceOnExpressApp(
3128
3480
  ? JSON.stringify(req.body)
3129
3481
  : "";
3130
3482
  }
3131
- const rawResponse = await service.handleRequest(
3132
- body,
3133
- req,
3134
- res,
3135
- keepUnrecognizedValues,
3136
- );
3483
+ const rawResponse = await service.handleRequest(body, req);
3137
3484
  res
3138
3485
  .status(rawResponse.statusCode)
3139
3486
  .contentType(rawResponse.contentType)