skir-client 0.1.0 → 1.0.1

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.
@@ -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,428 @@ 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
+ }
3109
+ /** Configuration options for a Skir service. */
3110
+ export interface ServiceOptions<RequestMeta = ExpressRequest> {
3111
+ /**
3112
+ * Whether to keep unrecognized values when deserializing requests.
3113
+ *
3114
+ * **WARNING:** Only enable this for data from trusted sources. Malicious
3115
+ * actors could inject fields with IDs not yet defined in your schema. If you
3116
+ * preserve this data and later define those IDs in a future schema version,
3117
+ * the injected data could be deserialized as valid fields, leading to
3118
+ * security vulnerabilities or data corruption.
3119
+ *
3120
+ * Defaults to `false`.
3121
+ */
3122
+ keepUnrecognizedValues: boolean;
3123
+
3124
+ /**
3125
+ * Predicate that determines whether the message of an unknown error (i.e. not
3126
+ * a `ServiceError`) should be sent to the client.
3127
+ *
3128
+ * By default, unknown errors are masked and the client receives a generic
3129
+ * 'server error' message with status 500. This is to prevent leaking
3130
+ * sensitive information to the client.
3131
+ *
3132
+ * You can enable this for debugging purposes or if you are sure that your
3133
+ * error messages are safe to expose.
3134
+ */
3135
+ canCopyUnknownErrorMessageToResponse: (reqMeta: RequestMeta) => boolean;
3136
+
3137
+ /**
3138
+ * Callback invoked whenever an error is thrown during method execution.
3139
+ *
3140
+ * Use this to log errors for monitoring, debugging, or alerting purposes.
3141
+ * The callback receives the error object, the method being executed, the
3142
+ * request that triggered the error, and the request metadata.
3143
+ *
3144
+ * Defaults to a no-op function.
3145
+ */
3146
+ errorLogger: <Request>(
3147
+ throwable: any,
3148
+ method: Method<Request, unknown>,
3149
+ req: Request,
3150
+ reqMeta: RequestMeta,
3151
+ ) => void;
3152
+
3153
+ /**
3154
+ * URL to the JavaScript file for the Skir Studio app.
3155
+ *
3156
+ * Skir Studio is a web interface for exploring and testing your Skir service.
3157
+ * It is served when the service receives a request at '${serviceUrl}?studio'.
3158
+ */
3159
+ studioAppJsUrl: string;
3160
+ }
2880
3161
 
2881
3162
  /**
2882
3163
  * Implementation of a skir service.
2883
3164
  *
2884
3165
  * Usage: call `.addMethod()` to register methods, then install the service on
2885
3166
  * an HTTP server either by:
2886
- * - calling the `installServiceOnExpressApp()` top-level function if you are
3167
+ * - calling the `installServiceOnExpressApp()` top-level function if you are
2887
3168
  * using ExpressJS
2888
3169
  * - writing your own implementation of `installServiceOn*()` which calls
2889
3170
  * `.handleRequest()` if you are using another web application framework
3171
+ *
3172
+ * ## Handling Request Metadata
3173
+ *
3174
+ * The `RequestMeta` type parameter specifies what metadata (authentication,
3175
+ * headers, etc.) your method implementations receive. There are two approaches:
3176
+ *
3177
+ * ### Approach 1: Use the framework's request type directly
3178
+ *
3179
+ * Set `RequestMeta` to your framework's request type (e.g., `ExpressRequest`).
3180
+ * All method implementations will receive the full framework request object.
3181
+ *
3182
+ * ```typescript
3183
+ * const service = new Service<ExpressRequest>();
3184
+ * service.addMethod(myMethod, async (req, expressReq) => {
3185
+ * const isAdmin = expressReq.user?.role === 'admin';
3186
+ * // ...
3187
+ * });
3188
+ * installServiceOnExpressApp(app, '/api', service, text, json);
3189
+ * ```
3190
+ *
3191
+ * ### Approach 2: Use a simplified custom type (recommended for testing)
3192
+ *
3193
+ * Set `RequestMeta` to a minimal type containing only what your service needs.
3194
+ * Use `withRequestMeta()` to extract this data from the framework request when
3195
+ * installing the service.
3196
+ *
3197
+ * ```typescript
3198
+ * const service = new Service<{ isAdmin: boolean }>();
3199
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
3200
+ * // Implementation is framework-agnostic and easy to unit test
3201
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
3202
+ * // ...
3203
+ * });
3204
+ *
3205
+ * // Adapt to Express when installing
3206
+ * const handler = service.withRequestMeta((req: ExpressRequest) => ({
3207
+ * isAdmin: req.user?.role === 'admin'
3208
+ * }));
3209
+ * installServiceOnExpressApp(app, '/api', handler, text, json);
3210
+ * ```
3211
+ *
3212
+ * This approach decouples your service from the HTTP framework, making it easier
3213
+ * to test and clearer about what request data it actually uses.
2890
3214
  */
2891
- export class Service<
2892
- RequestMeta = ExpressRequest,
2893
- ResponseMeta = ExpressResponse,
2894
- > {
3215
+ export class Service<RequestMeta = ExpressRequest>
3216
+ implements RequestHandler<RequestMeta>
3217
+ {
3218
+ constructor(options?: Partial<ServiceOptions<RequestMeta>>) {
3219
+ this.options = {
3220
+ keepUnrecognizedValues:
3221
+ options?.keepUnrecognizedValues ??
3222
+ DEFAULT_SERVICE_OPTIONS.keepUnrecognizedValues,
3223
+ canCopyUnknownErrorMessageToResponse:
3224
+ options?.canCopyUnknownErrorMessageToResponse ??
3225
+ DEFAULT_SERVICE_OPTIONS.canCopyUnknownErrorMessageToResponse,
3226
+ errorLogger: options?.errorLogger ?? DEFAULT_SERVICE_OPTIONS.errorLogger,
3227
+ studioAppJsUrl: new URL(
3228
+ options?.studioAppJsUrl ?? DEFAULT_SERVICE_OPTIONS.studioAppJsUrl,
3229
+ ).toString(),
3230
+ };
3231
+ }
3232
+
2895
3233
  addMethod<Request, Response>(
2896
3234
  method: Method<Request, Response>,
2897
- impl: (
2898
- req: Request,
2899
- reqMeta: RequestMeta,
2900
- resMeta: ResponseMeta,
2901
- ) => Promise<Response>,
2902
- ): Service<RequestMeta, ResponseMeta> {
3235
+ impl: (req: Request, reqMeta: RequestMeta) => Promise<Response>,
3236
+ ): Service<RequestMeta> {
2903
3237
  const { number } = method;
2904
3238
  if (this.methodImpls[number]) {
2905
3239
  throw new Error(
@@ -2909,28 +3243,13 @@ export class Service<
2909
3243
  this.methodImpls[number] = {
2910
3244
  method: method,
2911
3245
  impl: impl,
2912
- } as MethodImpl<unknown, unknown, RequestMeta, ResponseMeta>;
3246
+ } as MethodImpl<unknown, unknown, RequestMeta>;
2913
3247
  return this;
2914
3248
  }
2915
3249
 
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
3250
  async handleRequest(
2930
3251
  reqBody: string,
2931
3252
  reqMeta: RequestMeta,
2932
- resMeta: ResponseMeta,
2933
- keepUnrecognizedValues?: "keep-unrecognized-values",
2934
3253
  ): Promise<RawResponse> {
2935
3254
  if (reqBody === "" || reqBody === "list") {
2936
3255
  const json = {
@@ -2944,9 +3263,10 @@ export class Service<
2944
3263
  })),
2945
3264
  };
2946
3265
  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");
3266
+ return makeOkHtmlResponse(jsonCode);
3267
+ } else if (reqBody === "studio") {
3268
+ const studioHtml = getStudioHtml(this.options.studioAppJsUrl);
3269
+ return makeOkHtmlResponse(studioHtml);
2950
3270
  }
2951
3271
 
2952
3272
  // Parse request
@@ -2962,13 +3282,12 @@ export class Service<
2962
3282
  try {
2963
3283
  reqBodyJson = JSON.parse(reqBody);
2964
3284
  } catch (_e) {
2965
- return new RawResponse("bad request: invalid JSON", "bad-request");
3285
+ return makeBadRequestResponse("bad request: invalid JSON");
2966
3286
  }
2967
3287
  const methodField = (reqBodyJson as AnyRecord)["method"];
2968
3288
  if (methodField === undefined) {
2969
- return new RawResponse(
3289
+ return makeBadRequestResponse(
2970
3290
  "bad request: missing 'method' field in JSON",
2971
- "bad-request",
2972
3291
  );
2973
3292
  }
2974
3293
  if (typeof methodField === "string") {
@@ -2978,17 +3297,15 @@ export class Service<
2978
3297
  methodName = "?";
2979
3298
  methodNumber = methodField;
2980
3299
  } else {
2981
- return new RawResponse(
3300
+ return makeBadRequestResponse(
2982
3301
  "bad request: 'method' field must be a string or a number",
2983
- "bad-request",
2984
3302
  );
2985
3303
  }
2986
3304
  format = "readable";
2987
3305
  const requestField = (reqBodyJson as AnyRecord)["request"];
2988
3306
  if (requestField === undefined) {
2989
- return new RawResponse(
3307
+ return makeBadRequestResponse(
2990
3308
  "bad request: missing 'request' field in JSON",
2991
- "bad-request",
2992
3309
  );
2993
3310
  }
2994
3311
  requestData = ["json", requestField as Json];
@@ -2996,10 +3313,7 @@ export class Service<
2996
3313
  // A colon-separated string
2997
3314
  const match = reqBody.match(/^([^:]*):([^:]*):([^:]*):([\S\s]*)$/);
2998
3315
  if (!match) {
2999
- return new RawResponse(
3000
- "bad request: invalid request format",
3001
- "bad-request",
3002
- );
3316
+ return makeBadRequestResponse("bad request: invalid request format");
3003
3317
  }
3004
3318
  methodName = match[1]!;
3005
3319
  const methodNumberStr = match[2]!;
@@ -3008,9 +3322,8 @@ export class Service<
3008
3322
 
3009
3323
  if (methodNumberStr) {
3010
3324
  if (!/^-?[0-9]+$/.test(methodNumberStr)) {
3011
- return new RawResponse(
3325
+ return makeBadRequestResponse(
3012
3326
  "bad request: can't parse method number",
3013
- "bad-request",
3014
3327
  );
3015
3328
  }
3016
3329
  methodNumber = parseInt(methodNumberStr);
@@ -3027,14 +3340,12 @@ export class Service<
3027
3340
  (m) => m.method.name === methodName,
3028
3341
  );
3029
3342
  if (nameMatches.length === 0) {
3030
- return new RawResponse(
3343
+ return makeBadRequestResponse(
3031
3344
  `bad request: method not found: ${methodName}`,
3032
- "bad-request",
3033
3345
  );
3034
3346
  } else if (nameMatches.length > 1) {
3035
- return new RawResponse(
3347
+ return makeBadRequestResponse(
3036
3348
  `bad request: method name '${methodName}' is ambiguous; use method number instead`,
3037
- "bad-request",
3038
3349
  );
3039
3350
  }
3040
3351
  methodNumber = nameMatches[0]!.method.number;
@@ -3042,9 +3353,8 @@ export class Service<
3042
3353
 
3043
3354
  const methodImpl = this.methodImpls[methodNumber];
3044
3355
  if (!methodImpl) {
3045
- return new RawResponse(
3356
+ return makeBadRequestResponse(
3046
3357
  `bad request: method not found: ${methodName}; number: ${methodNumber}`,
3047
- "bad-request",
3048
3358
  );
3049
3359
  }
3050
3360
 
@@ -3053,26 +3363,37 @@ export class Service<
3053
3363
  if (requestData[0] == "json") {
3054
3364
  req = methodImpl.method.requestSerializer.fromJson(
3055
3365
  requestData[1],
3056
- keepUnrecognizedValues,
3366
+ this.options.keepUnrecognizedValues
3367
+ ? "keep-unrecognized-values"
3368
+ : undefined,
3057
3369
  );
3058
3370
  } else {
3059
3371
  req = methodImpl.method.requestSerializer.fromJsonCode(
3060
3372
  requestData[1],
3061
- keepUnrecognizedValues,
3373
+ this.options.keepUnrecognizedValues
3374
+ ? "keep-unrecognized-values"
3375
+ : undefined,
3062
3376
  );
3063
3377
  }
3064
3378
  } catch (e) {
3065
- return new RawResponse(
3066
- `bad request: can't parse JSON: ${e}`,
3067
- "bad-request",
3068
- );
3379
+ return makeBadRequestResponse(`bad request: can't parse JSON: ${e}`);
3069
3380
  }
3070
3381
 
3071
3382
  let res: unknown;
3072
3383
  try {
3073
- res = await methodImpl.impl(req, reqMeta, resMeta);
3384
+ res = await methodImpl.impl(req, reqMeta);
3074
3385
  } catch (e) {
3075
- return new RawResponse(`server error: ${e}`, "server-error");
3386
+ this.options.errorLogger(e, methodImpl.method, req, reqMeta);
3387
+ if (e instanceof ServiceError) {
3388
+ return e.toRawResponse();
3389
+ } else {
3390
+ const message = this.options.canCopyUnknownErrorMessageToResponse(
3391
+ reqMeta,
3392
+ )
3393
+ ? `server error: ${e}`
3394
+ : "server error";
3395
+ return makeServerErrorResponse(message);
3396
+ }
3076
3397
  }
3077
3398
 
3078
3399
  let resJson: string;
@@ -3080,36 +3401,87 @@ export class Service<
3080
3401
  const flavor = format === "readable" ? "readable" : "dense";
3081
3402
  resJson = methodImpl.method.responseSerializer.toJsonCode(res, flavor);
3082
3403
  } catch (e) {
3083
- return new RawResponse(
3404
+ return makeServerErrorResponse(
3084
3405
  `server error: can't serialize response to JSON: ${e}`,
3085
- "server-error",
3086
3406
  );
3087
3407
  }
3088
3408
 
3089
- return new RawResponse(resJson, "ok-json");
3409
+ return makeOkJsonResponse(resJson);
3090
3410
  }
3091
3411
 
3412
+ /**
3413
+ * Creates a request handler that extracts simplified request metadata from
3414
+ * framework-specific request objects before passing it to this service.
3415
+ *
3416
+ * This decouples your service implementation from the HTTP framework, making
3417
+ * it easier to unit test (tests don't need to mock framework objects) and
3418
+ * making the service implementation clearer by explicitly declaring exactly
3419
+ * what request data it needs.
3420
+ *
3421
+ * @param transformFn Function that extracts the necessary data from the
3422
+ * framework-specific request object. Can be async or sync.
3423
+ * @returns A request handler that accepts the framework-specific request type.
3424
+ *
3425
+ * @example
3426
+ * ```typescript
3427
+ * // Define a service that only needs to know if the user is an admin
3428
+ *
3429
+ * const service = new Service<{ isAdmin: boolean }>();
3430
+ *
3431
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
3432
+ * // Implementation is framework-agnostic and easy to test
3433
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
3434
+ * // ...
3435
+ * });
3436
+ *
3437
+ * // Adapt it to work with Express
3438
+ * const expressHandler = service.withRequestMeta((req: ExpressRequest) => ({
3439
+ * isAdmin: req.user?.role === 'admin'
3440
+ * }));
3441
+ * installServiceOnExpressApp(app, '/api', expressHandler, text, json);
3442
+ * ```
3443
+ */
3444
+ withRequestMeta<NewRequestMeta>(
3445
+ transformFn: (
3446
+ reqMeta: NewRequestMeta,
3447
+ ) => Promise<RequestMeta> | RequestMeta,
3448
+ ): RequestHandler<NewRequestMeta> {
3449
+ return {
3450
+ handleRequest: async (
3451
+ reqBody: string,
3452
+ reqMeta: NewRequestMeta,
3453
+ ): Promise<RawResponse> => {
3454
+ const transformedMeta = await Promise.resolve(transformFn(reqMeta));
3455
+ return this.handleRequest(reqBody, transformedMeta);
3456
+ },
3457
+ };
3458
+ }
3459
+
3460
+ private readonly options: ServiceOptions<RequestMeta>;
3092
3461
  private readonly methodImpls: {
3093
- [number: number]: MethodImpl<unknown, unknown, RequestMeta, ResponseMeta>;
3462
+ [number: number]: MethodImpl<unknown, unknown, RequestMeta>;
3094
3463
  } = {};
3095
3464
  }
3096
3465
 
3097
- interface MethodImpl<Request, Response, RequestMeta, ResponseMeta> {
3466
+ const DEFAULT_SERVICE_OPTIONS: ServiceOptions<unknown> = {
3467
+ keepUnrecognizedValues: false,
3468
+ canCopyUnknownErrorMessageToResponse: () => false,
3469
+ errorLogger: () => {},
3470
+ studioAppJsUrl:
3471
+ "https://cdn.jsdelivr.net/npm/skir-studio/dist/skir-studio-standalone.js",
3472
+ };
3473
+
3474
+ interface MethodImpl<Request, Response, RequestMeta> {
3098
3475
  method: Method<Request, Response>;
3099
- impl: (
3100
- req: Request,
3101
- reqMeta: RequestMeta,
3102
- resMeta: ResponseMeta,
3103
- ) => Promise<Response>;
3476
+ impl: (req: Request, reqMeta: RequestMeta) => Promise<Response>;
3104
3477
  }
3105
3478
 
3106
3479
  export function installServiceOnExpressApp(
3107
3480
  app: ExpressApp,
3108
3481
  queryPath: string,
3109
- service: Service<ExpressRequest, ExpressResponse>,
3482
+ service: RequestHandler<ExpressRequest>,
3110
3483
  text: typeof ExpressText,
3111
3484
  json: typeof ExpressJson,
3112
- keepUnrecognizedValues?: "keep-unrecognized-values",
3113
3485
  ): void {
3114
3486
  const callback = async (
3115
3487
  req: ExpressRequest,
@@ -3128,12 +3500,7 @@ export function installServiceOnExpressApp(
3128
3500
  ? JSON.stringify(req.body)
3129
3501
  : "";
3130
3502
  }
3131
- const rawResponse = await service.handleRequest(
3132
- body,
3133
- req,
3134
- res,
3135
- keepUnrecognizedValues,
3136
- );
3503
+ const rawResponse = await service.handleRequest(body, req);
3137
3504
  res
3138
3505
  .status(rawResponse.statusCode)
3139
3506
  .contentType(rawResponse.contentType)