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.
@@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports._initModuleClasses = exports.installServiceOnExpressApp = exports.Service = exports.RawResponse = exports.ServiceClient = exports._EnumBase = exports._FrozenBase = exports._toFrozenArray = exports._EMPTY_ARRAY = exports.parseTypeDescriptorFromJsonCode = exports.parseTypeDescriptorFromJson = exports.optionalSerializer = exports.arraySerializer = exports.primitiveSerializer = exports.ByteString = exports.Timestamp = void 0;
12
+ exports._initModuleClasses = exports.installServiceOnExpressApp = exports.Service = exports.ServiceError = exports.ServiceClient = exports._EnumBase = exports._FrozenBase = exports._toFrozenArray = exports._EMPTY_ARRAY = exports.parseTypeDescriptorFromJsonCode = exports.parseTypeDescriptorFromJson = exports.optionalSerializer = exports.arraySerializer = exports.primitiveSerializer = exports.ByteString = exports.Timestamp = void 0;
13
13
  /**
14
14
  * A single moment in time represented in a platform-independent format, with a
15
15
  * precision of one millisecond.
@@ -1867,7 +1867,6 @@ class ServiceClient {
1867
1867
  /** Invokes the given method on the remote server through an RPC. */
1868
1868
  invokeRemote(method, request, httpMethod = "POST") {
1869
1869
  return __awaiter(this, void 0, void 0, function* () {
1870
- this.lastRespHeaders = undefined;
1871
1870
  const requestJson = method.requestSerializer.toJsonCode(request);
1872
1871
  const requestBody = [method.name, method.number, "", requestJson].join(":");
1873
1872
  const requestInit = Object.assign({}, (yield Promise.resolve(this.getRequestMetadata(method))));
@@ -1880,7 +1879,6 @@ class ServiceClient {
1880
1879
  url.search = requestBody.replace(/%/g, "%25");
1881
1880
  }
1882
1881
  const httpResponse = yield fetch(url, requestInit);
1883
- this.lastRespHeaders = httpResponse.headers;
1884
1882
  const responseData = yield httpResponse.blob();
1885
1883
  if (httpResponse.ok) {
1886
1884
  const jsonCode = yield responseData.text();
@@ -1895,77 +1893,135 @@ class ServiceClient {
1895
1893
  }
1896
1894
  });
1897
1895
  }
1898
- get lastResponseHeaders() {
1899
- return this.lastRespHeaders;
1900
- }
1901
1896
  }
1902
1897
  exports.ServiceClient = ServiceClient;
1903
- /** Raw response returned by the server. */
1904
- class RawResponse {
1905
- constructor(data, type) {
1906
- this.data = data;
1907
- this.type = type;
1908
- }
1909
- get statusCode() {
1910
- switch (this.type) {
1911
- case "ok-json":
1912
- case "ok-html":
1913
- return 200;
1914
- case "bad-request":
1915
- return 400;
1916
- case "server-error":
1917
- return 500;
1918
- default: {
1919
- const _ = this.type;
1920
- throw new Error(_);
1921
- }
1922
- }
1923
- }
1924
- get contentType() {
1925
- switch (this.type) {
1926
- case "ok-json":
1927
- return "application/json";
1928
- case "ok-html":
1929
- return "text/html; charset=utf-8";
1930
- case "bad-request":
1931
- case "server-error":
1932
- return "text/plain; charset=utf-8";
1933
- default: {
1934
- const _ = this.type;
1935
- throw new Error(_);
1936
- }
1937
- }
1938
- }
1898
+ function makeOkJsonResponse(data) {
1899
+ return {
1900
+ data: data,
1901
+ statusCode: 200,
1902
+ contentType: "application/json",
1903
+ };
1904
+ }
1905
+ function makeOkHtmlResponse(data) {
1906
+ return {
1907
+ data: data,
1908
+ statusCode: 200,
1909
+ contentType: "text/html; charset=utf-8",
1910
+ };
1939
1911
  }
1940
- exports.RawResponse = RawResponse;
1941
- // Copied from
1942
- // https://github.com/gepheum/restudio/blob/main/index.jsdeliver.html
1943
- const RESTUDIO_HTML = `<!DOCTYPE html>
1912
+ function makeBadRequestResponse(data) {
1913
+ return {
1914
+ data: data,
1915
+ statusCode: 400,
1916
+ contentType: "text/plain; charset=utf-8",
1917
+ };
1918
+ }
1919
+ function makeServerErrorResponse(data, statusCode = 500) {
1920
+ return {
1921
+ data: data,
1922
+ statusCode: statusCode,
1923
+ contentType: "text/plain; charset=utf-8",
1924
+ };
1925
+ }
1926
+ function getStudioHtml(studioAppJsUrl) {
1927
+ // Copied from
1928
+ // https://github.com/gepheum/skir-studio/blob/main/index.jsdeliver.html
1929
+ return `<!DOCTYPE html>
1944
1930
 
1945
1931
  <html>
1946
1932
  <head>
1947
1933
  <meta charset="utf-8" />
1948
1934
  <title>RESTudio</title>
1949
- <script src="https://cdn.jsdelivr.net/npm/restudio/dist/restudio-standalone.js"></script>
1935
+ <script src="${studioAppJsUrl}"></script>
1950
1936
  </head>
1951
1937
  <body style="margin: 0; padding: 0;">
1952
1938
  <restudio-app></restudio-app>
1953
1939
  </body>
1954
1940
  </html>
1955
1941
  `;
1942
+ }
1943
+ /**
1944
+ * If this error is thrown from a method implementation, the specified status
1945
+ * code and message will be returned in the HTTP response.
1946
+ *
1947
+ * If any other type of exception is thrown, the response status code will be
1948
+ * 500 (Internal Server Error).
1949
+ */
1950
+ class ServiceError extends Error {
1951
+ constructor(spec) {
1952
+ var _a;
1953
+ super((_a = spec.message) !== null && _a !== void 0 ? _a : spec.desc);
1954
+ this.spec = spec;
1955
+ }
1956
+ toRawResponse() {
1957
+ var _a;
1958
+ return makeServerErrorResponse((_a = this.spec.message) !== null && _a !== void 0 ? _a : this.spec.desc, this.spec.statusCode);
1959
+ }
1960
+ }
1961
+ exports.ServiceError = ServiceError;
1956
1962
  /**
1957
1963
  * Implementation of a skir service.
1958
1964
  *
1959
1965
  * Usage: call `.addMethod()` to register methods, then install the service on
1960
1966
  * an HTTP server either by:
1961
- * - calling the `installServiceOnExpressApp()` top-level function if you are
1967
+ * - calling the `installServiceOnExpressApp()` top-level function if you are
1962
1968
  * using ExpressJS
1963
1969
  * - writing your own implementation of `installServiceOn*()` which calls
1964
1970
  * `.handleRequest()` if you are using another web application framework
1971
+ *
1972
+ * ## Handling Request Metadata
1973
+ *
1974
+ * The `RequestMeta` type parameter specifies what metadata (authentication,
1975
+ * headers, etc.) your method implementations receive. There are two approaches:
1976
+ *
1977
+ * ### Approach 1: Use the framework's request type directly
1978
+ *
1979
+ * Set `RequestMeta` to your framework's request type (e.g., `ExpressRequest`).
1980
+ * All method implementations will receive the full framework request object.
1981
+ *
1982
+ * ```typescript
1983
+ * const service = new Service<ExpressRequest>();
1984
+ * service.addMethod(myMethod, async (req, expressReq) => {
1985
+ * const isAdmin = expressReq.user?.role === 'admin';
1986
+ * // ...
1987
+ * });
1988
+ * installServiceOnExpressApp(app, '/api', service, text, json);
1989
+ * ```
1990
+ *
1991
+ * ### Approach 2: Use a simplified custom type (recommended for testing)
1992
+ *
1993
+ * Set `RequestMeta` to a minimal type containing only what your service needs.
1994
+ * Use `withRequestMeta()` to extract this data from the framework request when
1995
+ * installing the service.
1996
+ *
1997
+ * ```typescript
1998
+ * const service = new Service<{ isAdmin: boolean }>();
1999
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
2000
+ * // Implementation is framework-agnostic and easy to unit test
2001
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
2002
+ * // ...
2003
+ * });
2004
+ *
2005
+ * // Adapt to Express when installing
2006
+ * const handler = service.withRequestMeta((req: ExpressRequest) => ({
2007
+ * isAdmin: req.user?.role === 'admin'
2008
+ * }));
2009
+ * installServiceOnExpressApp(app, '/api', handler, text, json);
2010
+ * ```
2011
+ *
2012
+ * This approach decouples your service from the HTTP framework, making it easier
2013
+ * to test and clearer about what request data it actually uses.
1965
2014
  */
1966
2015
  class Service {
1967
- constructor() {
2016
+ constructor(options) {
2017
+ var _a, _b, _c, _d;
1968
2018
  this.methodImpls = {};
2019
+ this.options = {
2020
+ keepUnrecognizedValues: (_a = options === null || options === void 0 ? void 0 : options.keepUnrecognizedValues) !== null && _a !== void 0 ? _a : DEFAULT_SERVICE_OPTIONS.keepUnrecognizedValues,
2021
+ canCopyUnknownErrorMessageToResponse: (_b = options === null || options === void 0 ? void 0 : options.canCopyUnknownErrorMessageToResponse) !== null && _b !== void 0 ? _b : DEFAULT_SERVICE_OPTIONS.canCopyUnknownErrorMessageToResponse,
2022
+ errorLogger: (_c = options === null || options === void 0 ? void 0 : options.errorLogger) !== null && _c !== void 0 ? _c : DEFAULT_SERVICE_OPTIONS.errorLogger,
2023
+ studioAppJsUrl: new URL((_d = options === null || options === void 0 ? void 0 : options.studioAppJsUrl) !== null && _d !== void 0 ? _d : DEFAULT_SERVICE_OPTIONS.studioAppJsUrl).toString(),
2024
+ };
1969
2025
  }
1970
2026
  addMethod(method, impl) {
1971
2027
  const { number } = method;
@@ -1978,20 +2034,7 @@ class Service {
1978
2034
  };
1979
2035
  return this;
1980
2036
  }
1981
- /**
1982
- * Parses the content of a user request and invokes the appropriate method.
1983
- * If you are using ExpressJS as your web application framework, you don't
1984
- * need to call this method, you can simply call the
1985
- * `installServiceOnExpressApp()` top-level function.
1986
- *
1987
- * If the request is a GET request, pass in the decoded query string as the
1988
- * request's body. The query string is the part of the URL after '?', and it
1989
- * can be decoded with DecodeURIComponent.
1990
- *
1991
- * Pass in "keep-unrecognized-values" if the request cannot come from a
1992
- * malicious user.
1993
- */
1994
- handleRequest(reqBody, reqMeta, resMeta, keepUnrecognizedValues) {
2037
+ handleRequest(reqBody, reqMeta) {
1995
2038
  return __awaiter(this, void 0, void 0, function* () {
1996
2039
  if (reqBody === "" || reqBody === "list") {
1997
2040
  const json = {
@@ -2004,10 +2047,11 @@ class Service {
2004
2047
  })),
2005
2048
  };
2006
2049
  const jsonCode = JSON.stringify(json, undefined, " ");
2007
- return new RawResponse(jsonCode, "ok-json");
2050
+ return makeOkHtmlResponse(jsonCode);
2008
2051
  }
2009
- else if (reqBody === "debug" || reqBody === "restudio") {
2010
- return new RawResponse(RESTUDIO_HTML, "ok-html");
2052
+ else if (reqBody === "studio") {
2053
+ const studioHtml = getStudioHtml(this.options.studioAppJsUrl);
2054
+ return makeOkHtmlResponse(studioHtml);
2011
2055
  }
2012
2056
  // Parse request
2013
2057
  let methodName;
@@ -2022,11 +2066,11 @@ class Service {
2022
2066
  reqBodyJson = JSON.parse(reqBody);
2023
2067
  }
2024
2068
  catch (_e) {
2025
- return new RawResponse("bad request: invalid JSON", "bad-request");
2069
+ return makeBadRequestResponse("bad request: invalid JSON");
2026
2070
  }
2027
2071
  const methodField = reqBodyJson["method"];
2028
2072
  if (methodField === undefined) {
2029
- return new RawResponse("bad request: missing 'method' field in JSON", "bad-request");
2073
+ return makeBadRequestResponse("bad request: missing 'method' field in JSON");
2030
2074
  }
2031
2075
  if (typeof methodField === "string") {
2032
2076
  methodName = methodField;
@@ -2037,12 +2081,12 @@ class Service {
2037
2081
  methodNumber = methodField;
2038
2082
  }
2039
2083
  else {
2040
- return new RawResponse("bad request: 'method' field must be a string or a number", "bad-request");
2084
+ return makeBadRequestResponse("bad request: 'method' field must be a string or a number");
2041
2085
  }
2042
2086
  format = "readable";
2043
2087
  const requestField = reqBodyJson["request"];
2044
2088
  if (requestField === undefined) {
2045
- return new RawResponse("bad request: missing 'request' field in JSON", "bad-request");
2089
+ return makeBadRequestResponse("bad request: missing 'request' field in JSON");
2046
2090
  }
2047
2091
  requestData = ["json", requestField];
2048
2092
  }
@@ -2050,7 +2094,7 @@ class Service {
2050
2094
  // A colon-separated string
2051
2095
  const match = reqBody.match(/^([^:]*):([^:]*):([^:]*):([\S\s]*)$/);
2052
2096
  if (!match) {
2053
- return new RawResponse("bad request: invalid request format", "bad-request");
2097
+ return makeBadRequestResponse("bad request: invalid request format");
2054
2098
  }
2055
2099
  methodName = match[1];
2056
2100
  const methodNumberStr = match[2];
@@ -2058,7 +2102,7 @@ class Service {
2058
2102
  requestData = ["json-code", match[4]];
2059
2103
  if (methodNumberStr) {
2060
2104
  if (!/^-?[0-9]+$/.test(methodNumberStr)) {
2061
- return new RawResponse("bad request: can't parse method number", "bad-request");
2105
+ return makeBadRequestResponse("bad request: can't parse method number");
2062
2106
  }
2063
2107
  methodNumber = parseInt(methodNumberStr);
2064
2108
  }
@@ -2072,35 +2116,48 @@ class Service {
2072
2116
  const allMethods = Object.values(this.methodImpls);
2073
2117
  const nameMatches = allMethods.filter((m) => m.method.name === methodName);
2074
2118
  if (nameMatches.length === 0) {
2075
- return new RawResponse(`bad request: method not found: ${methodName}`, "bad-request");
2119
+ return makeBadRequestResponse(`bad request: method not found: ${methodName}`);
2076
2120
  }
2077
2121
  else if (nameMatches.length > 1) {
2078
- return new RawResponse(`bad request: method name '${methodName}' is ambiguous; use method number instead`, "bad-request");
2122
+ return makeBadRequestResponse(`bad request: method name '${methodName}' is ambiguous; use method number instead`);
2079
2123
  }
2080
2124
  methodNumber = nameMatches[0].method.number;
2081
2125
  }
2082
2126
  const methodImpl = this.methodImpls[methodNumber];
2083
2127
  if (!methodImpl) {
2084
- return new RawResponse(`bad request: method not found: ${methodName}; number: ${methodNumber}`, "bad-request");
2128
+ return makeBadRequestResponse(`bad request: method not found: ${methodName}; number: ${methodNumber}`);
2085
2129
  }
2086
2130
  let req;
2087
2131
  try {
2088
2132
  if (requestData[0] == "json") {
2089
- req = methodImpl.method.requestSerializer.fromJson(requestData[1], keepUnrecognizedValues);
2133
+ req = methodImpl.method.requestSerializer.fromJson(requestData[1], this.options.keepUnrecognizedValues
2134
+ ? "keep-unrecognized-values"
2135
+ : undefined);
2090
2136
  }
2091
2137
  else {
2092
- req = methodImpl.method.requestSerializer.fromJsonCode(requestData[1], keepUnrecognizedValues);
2138
+ req = methodImpl.method.requestSerializer.fromJsonCode(requestData[1], this.options.keepUnrecognizedValues
2139
+ ? "keep-unrecognized-values"
2140
+ : undefined);
2093
2141
  }
2094
2142
  }
2095
2143
  catch (e) {
2096
- return new RawResponse(`bad request: can't parse JSON: ${e}`, "bad-request");
2144
+ return makeBadRequestResponse(`bad request: can't parse JSON: ${e}`);
2097
2145
  }
2098
2146
  let res;
2099
2147
  try {
2100
- res = yield methodImpl.impl(req, reqMeta, resMeta);
2148
+ res = yield methodImpl.impl(req, reqMeta);
2101
2149
  }
2102
2150
  catch (e) {
2103
- return new RawResponse(`server error: ${e}`, "server-error");
2151
+ this.options.errorLogger(e, methodImpl.method, req, reqMeta);
2152
+ if (e instanceof ServiceError) {
2153
+ return e.toRawResponse();
2154
+ }
2155
+ else {
2156
+ const message = this.options.canCopyUnknownErrorMessageToResponse(reqMeta)
2157
+ ? `server error: ${e}`
2158
+ : "server error";
2159
+ return makeServerErrorResponse(message);
2160
+ }
2104
2161
  }
2105
2162
  let resJson;
2106
2163
  try {
@@ -2108,14 +2165,60 @@ class Service {
2108
2165
  resJson = methodImpl.method.responseSerializer.toJsonCode(res, flavor);
2109
2166
  }
2110
2167
  catch (e) {
2111
- return new RawResponse(`server error: can't serialize response to JSON: ${e}`, "server-error");
2168
+ return makeServerErrorResponse(`server error: can't serialize response to JSON: ${e}`);
2112
2169
  }
2113
- return new RawResponse(resJson, "ok-json");
2170
+ return makeOkJsonResponse(resJson);
2114
2171
  });
2115
2172
  }
2173
+ /**
2174
+ * Creates a request handler that extracts simplified request metadata from
2175
+ * framework-specific request objects before passing it to this service.
2176
+ *
2177
+ * This decouples your service implementation from the HTTP framework, making
2178
+ * it easier to unit test (tests don't need to mock framework objects) and
2179
+ * making the service implementation clearer by explicitly declaring exactly
2180
+ * what request data it needs.
2181
+ *
2182
+ * @param transformFn Function that extracts the necessary data from the
2183
+ * framework-specific request object. Can be async or sync.
2184
+ * @returns A request handler that accepts the framework-specific request type.
2185
+ *
2186
+ * @example
2187
+ * ```typescript
2188
+ * // Define a service that only needs to know if the user is an admin
2189
+ *
2190
+ * const service = new Service<{ isAdmin: boolean }>();
2191
+ *
2192
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
2193
+ * // Implementation is framework-agnostic and easy to test
2194
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
2195
+ * // ...
2196
+ * });
2197
+ *
2198
+ * // Adapt it to work with Express
2199
+ * const expressHandler = service.withRequestMeta((req: ExpressRequest) => ({
2200
+ * isAdmin: req.user?.role === 'admin'
2201
+ * }));
2202
+ * installServiceOnExpressApp(app, '/api', expressHandler, text, json);
2203
+ * ```
2204
+ */
2205
+ withRequestMeta(transformFn) {
2206
+ return {
2207
+ handleRequest: (reqBody, reqMeta) => __awaiter(this, void 0, void 0, function* () {
2208
+ const transformedMeta = yield Promise.resolve(transformFn(reqMeta));
2209
+ return this.handleRequest(reqBody, transformedMeta);
2210
+ }),
2211
+ };
2212
+ }
2116
2213
  }
2117
2214
  exports.Service = Service;
2118
- function installServiceOnExpressApp(app, queryPath, service, text, json, keepUnrecognizedValues) {
2215
+ const DEFAULT_SERVICE_OPTIONS = {
2216
+ keepUnrecognizedValues: false,
2217
+ canCopyUnknownErrorMessageToResponse: () => false,
2218
+ errorLogger: () => { },
2219
+ studioAppJsUrl: "https://cdn.jsdelivr.net/npm/skir-studio/dist/skir-studio-standalone.js",
2220
+ };
2221
+ function installServiceOnExpressApp(app, queryPath, service, text, json) {
2119
2222
  const callback = (req, res) => __awaiter(this, void 0, void 0, function* () {
2120
2223
  let body;
2121
2224
  const indexOfQuestionMark = req.originalUrl.indexOf("?");
@@ -2131,7 +2234,7 @@ function installServiceOnExpressApp(app, queryPath, service, text, json, keepUnr
2131
2234
  ? JSON.stringify(req.body)
2132
2235
  : "";
2133
2236
  }
2134
- const rawResponse = yield service.handleRequest(body, req, res, keepUnrecognizedValues);
2237
+ const rawResponse = yield service.handleRequest(body, req);
2135
2238
  res
2136
2239
  .status(rawResponse.statusCode)
2137
2240
  .contentType(rawResponse.contentType)