skir-client 0.1.0 → 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.
@@ -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,134 @@ 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;
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
+ studioAppJsUrl: new URL((_c = options === null || options === void 0 ? void 0 : options.studioAppJsUrl) !== null && _c !== void 0 ? _c : DEFAULT_SERVICE_OPTIONS.studioAppJsUrl).toString(),
2023
+ };
1969
2024
  }
1970
2025
  addMethod(method, impl) {
1971
2026
  const { number } = method;
@@ -1978,20 +2033,7 @@ class Service {
1978
2033
  };
1979
2034
  return this;
1980
2035
  }
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) {
2036
+ handleRequest(reqBody, reqMeta) {
1995
2037
  return __awaiter(this, void 0, void 0, function* () {
1996
2038
  if (reqBody === "" || reqBody === "list") {
1997
2039
  const json = {
@@ -2004,10 +2046,11 @@ class Service {
2004
2046
  })),
2005
2047
  };
2006
2048
  const jsonCode = JSON.stringify(json, undefined, " ");
2007
- return new RawResponse(jsonCode, "ok-json");
2049
+ return makeOkHtmlResponse(jsonCode);
2008
2050
  }
2009
- else if (reqBody === "debug" || reqBody === "restudio") {
2010
- return new RawResponse(RESTUDIO_HTML, "ok-html");
2051
+ else if (reqBody === "studio") {
2052
+ const studioHtml = getStudioHtml(this.options.studioAppJsUrl);
2053
+ return makeOkHtmlResponse(studioHtml);
2011
2054
  }
2012
2055
  // Parse request
2013
2056
  let methodName;
@@ -2022,11 +2065,11 @@ class Service {
2022
2065
  reqBodyJson = JSON.parse(reqBody);
2023
2066
  }
2024
2067
  catch (_e) {
2025
- return new RawResponse("bad request: invalid JSON", "bad-request");
2068
+ return makeBadRequestResponse("bad request: invalid JSON");
2026
2069
  }
2027
2070
  const methodField = reqBodyJson["method"];
2028
2071
  if (methodField === undefined) {
2029
- return new RawResponse("bad request: missing 'method' field in JSON", "bad-request");
2072
+ return makeBadRequestResponse("bad request: missing 'method' field in JSON");
2030
2073
  }
2031
2074
  if (typeof methodField === "string") {
2032
2075
  methodName = methodField;
@@ -2037,12 +2080,12 @@ class Service {
2037
2080
  methodNumber = methodField;
2038
2081
  }
2039
2082
  else {
2040
- return new RawResponse("bad request: 'method' field must be a string or a number", "bad-request");
2083
+ return makeBadRequestResponse("bad request: 'method' field must be a string or a number");
2041
2084
  }
2042
2085
  format = "readable";
2043
2086
  const requestField = reqBodyJson["request"];
2044
2087
  if (requestField === undefined) {
2045
- return new RawResponse("bad request: missing 'request' field in JSON", "bad-request");
2088
+ return makeBadRequestResponse("bad request: missing 'request' field in JSON");
2046
2089
  }
2047
2090
  requestData = ["json", requestField];
2048
2091
  }
@@ -2050,7 +2093,7 @@ class Service {
2050
2093
  // A colon-separated string
2051
2094
  const match = reqBody.match(/^([^:]*):([^:]*):([^:]*):([\S\s]*)$/);
2052
2095
  if (!match) {
2053
- return new RawResponse("bad request: invalid request format", "bad-request");
2096
+ return makeBadRequestResponse("bad request: invalid request format");
2054
2097
  }
2055
2098
  methodName = match[1];
2056
2099
  const methodNumberStr = match[2];
@@ -2058,7 +2101,7 @@ class Service {
2058
2101
  requestData = ["json-code", match[4]];
2059
2102
  if (methodNumberStr) {
2060
2103
  if (!/^-?[0-9]+$/.test(methodNumberStr)) {
2061
- return new RawResponse("bad request: can't parse method number", "bad-request");
2104
+ return makeBadRequestResponse("bad request: can't parse method number");
2062
2105
  }
2063
2106
  methodNumber = parseInt(methodNumberStr);
2064
2107
  }
@@ -2072,35 +2115,47 @@ class Service {
2072
2115
  const allMethods = Object.values(this.methodImpls);
2073
2116
  const nameMatches = allMethods.filter((m) => m.method.name === methodName);
2074
2117
  if (nameMatches.length === 0) {
2075
- return new RawResponse(`bad request: method not found: ${methodName}`, "bad-request");
2118
+ return makeBadRequestResponse(`bad request: method not found: ${methodName}`);
2076
2119
  }
2077
2120
  else if (nameMatches.length > 1) {
2078
- return new RawResponse(`bad request: method name '${methodName}' is ambiguous; use method number instead`, "bad-request");
2121
+ return makeBadRequestResponse(`bad request: method name '${methodName}' is ambiguous; use method number instead`);
2079
2122
  }
2080
2123
  methodNumber = nameMatches[0].method.number;
2081
2124
  }
2082
2125
  const methodImpl = this.methodImpls[methodNumber];
2083
2126
  if (!methodImpl) {
2084
- return new RawResponse(`bad request: method not found: ${methodName}; number: ${methodNumber}`, "bad-request");
2127
+ return makeBadRequestResponse(`bad request: method not found: ${methodName}; number: ${methodNumber}`);
2085
2128
  }
2086
2129
  let req;
2087
2130
  try {
2088
2131
  if (requestData[0] == "json") {
2089
- req = methodImpl.method.requestSerializer.fromJson(requestData[1], keepUnrecognizedValues);
2132
+ req = methodImpl.method.requestSerializer.fromJson(requestData[1], this.options.keepUnrecognizedValues
2133
+ ? "keep-unrecognized-values"
2134
+ : undefined);
2090
2135
  }
2091
2136
  else {
2092
- req = methodImpl.method.requestSerializer.fromJsonCode(requestData[1], keepUnrecognizedValues);
2137
+ req = methodImpl.method.requestSerializer.fromJsonCode(requestData[1], this.options.keepUnrecognizedValues
2138
+ ? "keep-unrecognized-values"
2139
+ : undefined);
2093
2140
  }
2094
2141
  }
2095
2142
  catch (e) {
2096
- return new RawResponse(`bad request: can't parse JSON: ${e}`, "bad-request");
2143
+ return makeBadRequestResponse(`bad request: can't parse JSON: ${e}`);
2097
2144
  }
2098
2145
  let res;
2099
2146
  try {
2100
- res = yield methodImpl.impl(req, reqMeta, resMeta);
2147
+ res = yield methodImpl.impl(req, reqMeta);
2101
2148
  }
2102
2149
  catch (e) {
2103
- return new RawResponse(`server error: ${e}`, "server-error");
2150
+ if (e instanceof ServiceError) {
2151
+ return e.toRawResponse();
2152
+ }
2153
+ else {
2154
+ const message = this.options.canCopyUnknownErrorMessageToResponse(reqMeta)
2155
+ ? `server error: ${e}`
2156
+ : "server error";
2157
+ return makeServerErrorResponse(message);
2158
+ }
2104
2159
  }
2105
2160
  let resJson;
2106
2161
  try {
@@ -2108,14 +2163,59 @@ class Service {
2108
2163
  resJson = methodImpl.method.responseSerializer.toJsonCode(res, flavor);
2109
2164
  }
2110
2165
  catch (e) {
2111
- return new RawResponse(`server error: can't serialize response to JSON: ${e}`, "server-error");
2166
+ return makeServerErrorResponse(`server error: can't serialize response to JSON: ${e}`);
2112
2167
  }
2113
- return new RawResponse(resJson, "ok-json");
2168
+ return makeOkJsonResponse(resJson);
2114
2169
  });
2115
2170
  }
2171
+ /**
2172
+ * Creates a request handler that extracts simplified request metadata from
2173
+ * framework-specific request objects before passing it to this service.
2174
+ *
2175
+ * This decouples your service implementation from the HTTP framework, making
2176
+ * it easier to unit test (tests don't need to mock framework objects) and
2177
+ * making the service implementation clearer by explicitly declaring exactly
2178
+ * what request data it needs.
2179
+ *
2180
+ * @param transformFn Function that extracts the necessary data from the
2181
+ * framework-specific request object. Can be async or sync.
2182
+ * @returns A request handler that accepts the framework-specific request type.
2183
+ *
2184
+ * @example
2185
+ * ```typescript
2186
+ * // Define a service that only needs to know if the user is an admin
2187
+ *
2188
+ * const service = new Service<{ isAdmin: boolean }>();
2189
+ *
2190
+ * service.addMethod(myMethod, async (req, { isAdmin }) => {
2191
+ * // Implementation is framework-agnostic and easy to test
2192
+ * if (!isAdmin) throw new ServiceError({ statusCode: 403, desc: "Forbidden" });
2193
+ * // ...
2194
+ * });
2195
+ *
2196
+ * // Adapt it to work with Express
2197
+ * const expressHandler = service.withRequestMeta((req: ExpressRequest) => ({
2198
+ * isAdmin: req.user?.role === 'admin'
2199
+ * }));
2200
+ * installServiceOnExpressApp(app, '/api', expressHandler, text, json);
2201
+ * ```
2202
+ */
2203
+ withRequestMeta(transformFn) {
2204
+ return {
2205
+ handleRequest: (reqBody, reqMeta) => __awaiter(this, void 0, void 0, function* () {
2206
+ const transformedMeta = yield Promise.resolve(transformFn(reqMeta));
2207
+ return this.handleRequest(reqBody, transformedMeta);
2208
+ }),
2209
+ };
2210
+ }
2116
2211
  }
2117
2212
  exports.Service = Service;
2118
- function installServiceOnExpressApp(app, queryPath, service, text, json, keepUnrecognizedValues) {
2213
+ const DEFAULT_SERVICE_OPTIONS = {
2214
+ keepUnrecognizedValues: false,
2215
+ canCopyUnknownErrorMessageToResponse: () => false,
2216
+ studioAppJsUrl: "https://cdn.jsdelivr.net/npm/skir-studio/dist/skir-studio-standalone.js",
2217
+ };
2218
+ function installServiceOnExpressApp(app, queryPath, service, text, json) {
2119
2219
  const callback = (req, res) => __awaiter(this, void 0, void 0, function* () {
2120
2220
  let body;
2121
2221
  const indexOfQuestionMark = req.originalUrl.indexOf("?");
@@ -2131,7 +2231,7 @@ function installServiceOnExpressApp(app, queryPath, service, text, json, keepUnr
2131
2231
  ? JSON.stringify(req.body)
2132
2232
  : "";
2133
2233
  }
2134
- const rawResponse = yield service.handleRequest(body, req, res, keepUnrecognizedValues);
2234
+ const rawResponse = yield service.handleRequest(body, req);
2135
2235
  res
2136
2236
  .status(rawResponse.statusCode)
2137
2237
  .contentType(rawResponse.contentType)