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.
- package/dist/cjs/skir-client.d.ts +311 -21
- package/dist/cjs/skir-client.d.ts.map +1 -1
- package/dist/cjs/skir-client.js +187 -84
- package/dist/cjs/skir-client.js.map +1 -1
- package/dist/esm/skir-client.d.ts +311 -21
- package/dist/esm/skir-client.d.ts.map +1 -1
- package/dist/esm/skir-client.js +185 -82
- package/dist/esm/skir-client.js.map +1 -1
- package/package.json +1 -1
- package/src/skir-client.ts +488 -121
package/src/skir-client.ts
CHANGED
|
@@ -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
|
-
|
|
2819
|
-
|
|
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
|
-
|
|
2824
|
+
function makeOkJsonResponse(data: string): RawResponse {
|
|
2825
|
+
return {
|
|
2826
|
+
data: data,
|
|
2827
|
+
statusCode: 200,
|
|
2828
|
+
contentType: "application/json",
|
|
2829
|
+
};
|
|
2823
2830
|
}
|
|
2824
2831
|
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
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
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
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
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
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
|
-
|
|
2866
|
-
//
|
|
2867
|
-
|
|
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="
|
|
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
|
|
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
|
|
2893
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2948
|
-
} else if (reqBody === "
|
|
2949
|
-
|
|
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
|
|
3285
|
+
return makeBadRequestResponse("bad request: invalid JSON");
|
|
2966
3286
|
}
|
|
2967
3287
|
const methodField = (reqBodyJson as AnyRecord)["method"];
|
|
2968
3288
|
if (methodField === undefined) {
|
|
2969
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3384
|
+
res = await methodImpl.impl(req, reqMeta);
|
|
3074
3385
|
} catch (e) {
|
|
3075
|
-
|
|
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
|
|
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
|
|
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
|
|
3462
|
+
[number: number]: MethodImpl<unknown, unknown, RequestMeta>;
|
|
3094
3463
|
} = {};
|
|
3095
3464
|
}
|
|
3096
3465
|
|
|
3097
|
-
|
|
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:
|
|
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)
|