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.
- package/README.md +1 -1
- package/dist/cjs/skir-client.d.ts +303 -23
- package/dist/cjs/skir-client.d.ts.map +1 -1
- package/dist/cjs/skir-client.js +196 -96
- package/dist/cjs/skir-client.js.map +1 -1
- package/dist/esm/skir-client.d.ts +303 -23
- package/dist/esm/skir-client.d.ts.map +1 -1
- package/dist/esm/skir-client.js +194 -94
- package/dist/esm/skir-client.js.map +1 -1
- package/package.json +9 -7
- package/src/skir-client.ts +480 -133
package/src/skir-client.ts
CHANGED
|
@@ -505,7 +505,7 @@ export interface PrimitiveTypes {
|
|
|
505
505
|
bool: boolean;
|
|
506
506
|
int32: number;
|
|
507
507
|
int64: bigint;
|
|
508
|
-
|
|
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
|
-
|
|
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" | "
|
|
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
|
|
1502
|
+
const MAX_HASH64 = BigInt("18446744073709551615");
|
|
1503
1503
|
|
|
1504
|
-
class
|
|
1505
|
-
readonly primitive = "
|
|
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
|
|
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.
|
|
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
|
-
|
|
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: //
|
|
1914
|
+
case 2: // hash64
|
|
1915
1915
|
case 6: // int64
|
|
1916
|
-
case 7: //
|
|
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
|
-
|
|
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
|
+
}
|
|
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
|
|
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
|
|
2893
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2948
|
-
} else if (reqBody === "
|
|
2949
|
-
|
|
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 (
|
|
2965
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3331
|
+
res = await methodImpl.impl(req, reqMeta);
|
|
3074
3332
|
} catch (e) {
|
|
3075
|
-
|
|
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
|
|
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
|
|
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
|
|
3408
|
+
[number: number]: MethodImpl<unknown, unknown, RequestMeta>;
|
|
3094
3409
|
} = {};
|
|
3095
3410
|
}
|
|
3096
3411
|
|
|
3097
|
-
|
|
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:
|
|
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)
|