gruber 0.1.0 → 0.3.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +214 -10
  3. package/core/configuration.d.ts +59 -0
  4. package/core/configuration.js +39 -36
  5. package/core/configuration.test.d.ts +1 -0
  6. package/core/configuration.test.js +32 -42
  7. package/core/fetch-router.d.ts +51 -0
  8. package/core/fetch-router.js +36 -23
  9. package/core/fetch-router.test.d.ts +1 -0
  10. package/core/fetch-router.test.js +50 -22
  11. package/core/http.d.ts +54 -0
  12. package/core/http.js +20 -21
  13. package/core/http.test.d.ts +1 -0
  14. package/core/migrator.d.ts +55 -0
  15. package/core/migrator.test.d.ts +1 -0
  16. package/core/migrator.test.js +3 -4
  17. package/core/mod.d.ts +6 -0
  18. package/core/postgres.d.ts +42 -0
  19. package/core/structures.d.ts +78 -0
  20. package/core/structures.js +202 -0
  21. package/core/structures.test.d.ts +1 -0
  22. package/core/structures.test.js +349 -0
  23. package/core/test-deps.d.ts +1 -0
  24. package/core/test-deps.js +1 -1
  25. package/core/types.d.ts +22 -0
  26. package/core/types.ts +41 -0
  27. package/core/utilities.d.ts +12 -0
  28. package/core/utilities.js +10 -4
  29. package/core/utilities.test.d.ts +1 -0
  30. package/package.json +12 -2
  31. package/source/configuration.d.ts +19 -0
  32. package/source/configuration.js +0 -2
  33. package/source/core.d.ts +1 -0
  34. package/source/express-router.d.ts +27 -0
  35. package/source/express-router.js +1 -1
  36. package/source/koa-router.d.ts +32 -0
  37. package/source/mod.d.ts +4 -0
  38. package/source/mod.js +2 -2
  39. package/source/node-router.d.ts +42 -0
  40. package/source/node-router.js +9 -2
  41. package/source/package-lock.json +3 -9
  42. package/source/package.json +8 -1
  43. package/source/polyfill.d.ts +1 -0
  44. package/source/postgres.d.ts +31 -0
  45. package/source/postgres.js +18 -1
  46. package/tsconfig.json +15 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  This file documents notable changes to the project
4
4
 
5
+ ## 0.3.0
6
+
7
+ **new**
8
+
9
+ - Removed use of superstruct in favour of new `structures.js` implementation
10
+ - Added `getJSONSchema` method to `Configuration`
11
+
12
+ **fixed**
13
+
14
+ - Node.js types should work now
15
+ - Node.js types includes a url-pattern polyfil
16
+
17
+ ## 0.2.0
18
+
19
+ **new**
20
+
21
+ - Added and documented `FetchRouter`
22
+ - Generate typings for Node.js
23
+ - Unhandled route errors are logged to the console
24
+
25
+ **fixes**
26
+
27
+ - Configuration is better typed to be able to infer the final structure
28
+ - Add missing NPM package information
29
+
30
+ **docs**
31
+
32
+ - Add a section on installation
33
+ - Add a section on the releaes process
34
+ - Add information about `loader` and `formatMarkdownTable`
35
+
5
36
  ## 0.1.0
6
37
 
7
38
  🎉 Everything is new!
package/README.md CHANGED
@@ -77,6 +77,27 @@ A Gruber app should be run behind a reverse proxy and that can do those things f
77
77
  - Minimal — start small, carefully add features and consider removing them
78
78
  - No magic — it's confusing when you don't know whats going on
79
79
 
80
+ ## Install
81
+
82
+ **Node.js**
83
+
84
+ Gruber is available on NPM as [gruber](https://www.npmjs.com/package/gruber).
85
+
86
+ ```bash
87
+ # cd to/your/project
88
+ npm install gruber
89
+ ```
90
+
91
+ **Deno**
92
+
93
+ > WORK IN PROGRESS
94
+
95
+ Gruber is available at [esm.r0b.io/gruber@0.1.0/mod.ts](https://esm.r0b.io/gruber@0.1.0/mod.ts).
96
+
97
+ ```js
98
+ import { defineRoute } from "https://esm.r0b.io/gruber@0.1.0/mod.ts";
99
+ ```
100
+
80
101
  ## HTTP server
81
102
 
82
103
  First a HTTP route to do something:
@@ -214,11 +235,11 @@ Building on the [HTTP server](#http-server) above, we'll setup configuration. St
214
235
  **config.js**
215
236
 
216
237
  ```js
217
- import superstruct from "superstruct";
238
+ import fs from "node:fs";
218
239
  import { getNodeConfiguration } from "gruber";
219
240
 
220
241
  const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
221
- const config = getNodeConfiguration({ superstruct });
242
+ const config = getNodeConfiguration();
222
243
 
223
244
  export function getSpecification() {
224
245
  return config.object({
@@ -265,6 +286,11 @@ export const appConfig = await loadConfiguration(
265
286
  export function getConfigurationUsage() {
266
287
  return config.getUsage(getSpecification());
267
288
  }
289
+
290
+ // Export a method to generate a JSON Schema for the configuration
291
+ export function getConfigurationSchema() {
292
+ return config.getJSONSchema(getSpecification());
293
+ }
268
294
  ```
269
295
 
270
296
  ### Usage info
@@ -306,11 +332,11 @@ You can provide a configuration file like **config.json** to load through the co
306
332
  "selfUrl": "http://localhost:3000",
307
333
  "meta": {
308
334
  "name": "gruber-app",
309
- "version": "1.2.3",
335
+ "version": "1.2.3"
310
336
  },
311
337
  "database": {
312
- "url": "postgres://user:secret@localhost:5432/database",
313
- },
338
+ "url": "postgres://user:secret@localhost:5432/database"
339
+ }
314
340
  }
315
341
  ```
316
342
 
@@ -651,7 +677,6 @@ To see how it works, look at the [Node](./node/source/configuration.js) and [Den
651
677
  You can use the static `getOptions` method both subclasses provide and override the parts you want.
652
678
  These are the options:
653
679
 
654
- - `superstruct` — Configuration is based on [superstruct](https://docs.superstructjs.org/), you can pass your own instance if you like.
655
680
  - `readTextFile(url)` — How to load a text file from the file system
656
681
  - `getEnvironmentVariable(key)` — Return a matching environment "variable" for a key
657
682
  - `getCommandArgument(key)` — Get the corresponding "flag" from a CLI argument
@@ -663,10 +688,9 @@ For example, to override in Node:
663
688
  ```js
664
689
  import { Configuration, getNodeConfigOptions } from "gruber";
665
690
  import Yaml from "yaml";
666
- import superstruct from "superstruct";
667
691
 
668
692
  const config = new Configuration({
669
- ...getNodeConfigOptions({ superstruct }),
693
+ ...getNodeConfigOptions(),
670
694
  getEnvionmentVariable: () => undefined,
671
695
  stringify: (v) => Yaml.stringify(v),
672
696
  parse: (v) => Yaml.parse(v),
@@ -797,6 +821,49 @@ teapot.toResponse();
797
821
  Currently, you can't set the body of the generated Response objects.
798
822
  This would be nice to have in the future, but the API should be thoughtfully designed first.
799
823
 
824
+ ### FetchRouter
825
+
826
+ `FetchRouter` is a web-native router for routes defined with `defineRoute`.
827
+
828
+ ```js
829
+ import { FetchRouter, defineRoute } from "gruber";
830
+
831
+ const routes = [defineRoute("..."), defineRoute("..."), defineRoute("...")];
832
+
833
+ const router = new FetchRouter({
834
+ routes,
835
+ errorHandler(error, request) {
836
+ console.log("Route error", error);
837
+ },
838
+ });
839
+ ```
840
+
841
+ All options to the `FetchRouter` constructor are optional and you can create a router without any options if you want.
842
+
843
+ `routes` are the route definitions you want the router to processes, the router will handle a request based on the first route that matches.
844
+ So order is important.
845
+
846
+ `errorHandler` is called if a non-`HTTPError` or a 5xx `HTTPError` is thrown.
847
+ It is called with the offending error and the request it is associated with.
848
+
849
+ > NOTE: The `errorHandler` could do more in the future, like create it's own Response or mutate the existing response.
850
+ > This has not been design and is left open to future development if it becomes important.
851
+
852
+ **getResponse**
853
+
854
+ `getResponse` is the main method on a router.
855
+ Use it to get a `Response` from the provided request, based on the router's route definitions.
856
+
857
+ ```js
858
+ const response = await router.getResponse(new Request("http://localhost"));
859
+ ```
860
+
861
+ There are some unstable internal methods too:
862
+
863
+ - `findMatchingRoutes(request)` is a generator function to get the first route definition that matches the supplied request. It's a generator so as few routes are matched as possible and execution can be stopped if you like.
864
+ - `processMatches(request, matches)` attempts to get a `Response` from a request and an Iterator of route definitions.
865
+ - `handleError(error, request)` converts a error into a Response and triggers the `errorHandler`
866
+
800
867
  ### Postgres
801
868
 
802
869
  #### getPostgresMigratorOptions
@@ -804,6 +871,135 @@ This would be nice to have in the future, but the API should be thoughtfully des
804
871
  `getPostgresMigratorOptions` generates the default options for a `PostgresMigrator`.
805
872
  You can use it and override parts of it to customise how the postgres migrator works.
806
873
 
874
+ ### Utilities
875
+
876
+ #### loader
877
+
878
+ `loader` let's you memoize the result of a function to create a singleton from it.
879
+ It works synchronously or with promises.
880
+
881
+ ```js
882
+ import { loader } from "gruber";
883
+
884
+ const useRedis = loader(async () => {
885
+ return "connect to the database somehow...";
886
+ });
887
+
888
+ // Then elsewhere
889
+ const redis = await useRedis();
890
+ ```
891
+
892
+ #### formatMarkdownTable
893
+
894
+ `formatMarkdownTable` generates a pretty markdown table based on an array of rows and the desired column names.
895
+
896
+ ```js
897
+ import { formatMarkdownTable } from "gruber";
898
+
899
+ const table = formatMarkdownTable(
900
+ [
901
+ { name: "Geoff Testington", age: 42 },
902
+ { name: "Jess Smith", age: 32 },
903
+ { name: "Tyler Rockwell" },
904
+ ],
905
+ ["name", "age"],
906
+ "~",
907
+ );
908
+ ```
909
+
910
+ This will generate the table:
911
+
912
+ ```
913
+ | name | age |
914
+ | ================ | === |
915
+ | Geoff Testington | 42 |
916
+ | Jess Smith | 32 |
917
+ | Tyler Rockwell | ~ |
918
+ ```
919
+
920
+ #### Structure
921
+
922
+ This is an internal primative for validating objects, strings, numbers and URLs for use in [Configuration](#configuration).
923
+ It is based on a very specific use of [superstruct](https://github.com/ianstormtaylor/superstruct) which it made sense to internalise to make the code base more portable.
924
+ A `Structure` is a type that validates a value is correct by throwing an error if validation fails, i.e. the wrong type is passed.
925
+ Every struct has an intrinsic `fallback` so that if no value (`undefined`) is passed, that is used instead.
926
+
927
+ ```js
928
+ import { Structure } from "gruber/structures.js";
929
+
930
+ // A string primative, or use "Geoff Testington" if no value is passed.
931
+ const name = Structure.string("Geoff Testington");
932
+
933
+ // A URL instance or a string that contains a valid URL, always converting to a URL
934
+ const website = Structure.url("https://example.com");
935
+
936
+ // A number primative, falling back to 42
937
+ const age = Structure.number(42);
938
+
939
+ // An object with all of the fields above and nothing else
940
+ // defaulting to create { name: "Geoff..", age: 42, website: "https..." } with the same fallback values
941
+ const person = Structure.object({ name, age, website });
942
+
943
+ // Process the Structure and get a value out. The returned value is strongly typed!
944
+ // This will throw if the value passed does not match the schema.
945
+ const value = person.process(/* ... */);
946
+ ```
947
+
948
+ Those static Structure methods return a `Structure` instance. You can also create your own types with the constructor. This example shows how to do that, and also starts to unveil how the internals work a bit with [StructError](#structerror).
949
+
950
+ ```js
951
+ import { Structure, StructError } from "gruber/structures.js";
952
+
953
+ // Create a new boolean structure (this should probably be included as Structure.boolean tbh)
954
+ const boolean = new Structure(
955
+ { type: "boolean", default: false },
956
+ (input, context) => {
957
+ if (input === undefined) return false;
958
+ if (typeof input !== "boolean") {
959
+ throw new StructError("Expected a boolean", context?.path);
960
+ }
961
+ return input;
962
+ },
963
+ );
964
+ ```
965
+
966
+ To create a custom Structure, you give it a [JSON schema](https://json-schema.org/) and a "process" function.
967
+ The function is called to validate a value against the structure. It should return the processed value or throw a `StructError`.
968
+
969
+ The `context` object might not be set and this means the struct is at the root level. If it is nested in an `object` then the context contains the path that the struct is located at, all the way from the root object. That path is expressed as an array of strings. That path is used to generate friendlier error messages to explain which nested field failed.
970
+
971
+ With a Structure, you can generate a JSON Schema:
972
+
973
+ ```js
974
+ import { Structure } from "gruber/structures.js";
975
+
976
+ const person = Structure.object({
977
+ name: Structure.string("Geoff Testington"),
978
+ age: Structure.number(42),
979
+ website: Structure.url("https://example.com"),
980
+ });
981
+
982
+ console.log(JSON.stringify(person.getSchema(), null, 2));
983
+ ```
984
+
985
+ This is a bit WIP, but you could use this to generate a JSON schema to lint configurations in your IDE.
986
+
987
+ #### StructError
988
+
989
+ This Error subclass contains extra information about why parsing a `Structure` failed.
990
+
991
+ - The `message` field is a description of what went wrong, in the context of the structure.
992
+ - An extra `path` field exists to describe the path from the root object to get to this failed structure
993
+ - `children` is also available to let a structure have multiple child errors, i.e. for an object to have failures for each of the fields that have failed.
994
+
995
+ On the error, there are also methods to help use it:
996
+
997
+ - `toFriendlyString` goes through all nested failures and outputs a single message to describe everything that went wrong.
998
+ - `getOneLiner` converts the error to a succint one-line error message, concatentating the path and message
999
+ - `[Symbol.iterator]` is also available if you want to loop through all children nodes, only those that do not have children themselves.
1000
+
1001
+ There is also the static method `StructError.chain(error, context)` which is useful for catching errors and applying a context to them (if they are not already a StructError).
1002
+
807
1003
  ## Node.js library
808
1004
 
809
1005
  There are some specific helpers to help use Gruber in Node.js apps.
@@ -859,7 +1055,7 @@ For older version of Node.js that don't support the latest web-standards,
859
1055
  there is a polyfil import you can use to add support for them to your runtime.
860
1056
 
861
1057
  ```js
862
- import "gruber/polyfil";
1058
+ import "gruber/polyfill.js";
863
1059
  ```
864
1060
 
865
1061
  This currently polyfils these APIs:
@@ -913,6 +1109,15 @@ const server = http.createServer((req) => {
913
1109
  });
914
1110
  ```
915
1111
 
1112
+ ## Release process
1113
+
1114
+ 1. Generate a new version at the root with `npm version <version>`
1115
+ 2. Run the bundle `./bundle.js`
1116
+ 3. Publish the node module
1117
+ 1. `cd bundle/node`
1118
+ 2. `npm publish`
1119
+ 4. Copy the deno source to the S3 bucket — `bundle/deno` → `esm.r0b.io/gruber@VERSION/`
1120
+
916
1121
  ---
917
1122
 
918
1123
  <!-- -->
@@ -992,7 +1197,6 @@ retryWithBackoff({
992
1197
 
993
1198
  ## Rob's notes
994
1199
 
995
- - should exposing `appConfig` be a best practice?
996
1200
  - `core` tests are deno because it's hard to do both and Deno is more web-standards based
997
1201
  - json schema for configuration specs?
998
1202
  - note or info about loading dot-env files
@@ -0,0 +1,59 @@
1
+ export class Configuration {
2
+ static spec: symbol;
3
+ /** @param {ConfigurationOptions} options */
4
+ constructor(options: ConfigurationOptions);
5
+ /** @type {ConfigurationOptions} */ options: ConfigurationOptions;
6
+ /**
7
+ * @template {Record<string, Structure<any>>} T
8
+ * @param {T} spec
9
+ * @returns {Structure<{ [K in keyof T]: import("./structures.js").Infer<T[K]> }>}
10
+ */
11
+ object<T extends Record<string, Structure<any>>>(spec: T): Structure<{ [K in keyof T]: import("./structures.js").Infer<T[K]>; }>;
12
+ /**
13
+ * @template {SpecOptions} Spec @param {Spec} spec
14
+ * @returns {Structure<string>}
15
+ */
16
+ string<Spec extends SpecOptions>(spec?: Spec): Structure<string>;
17
+ /**
18
+ * @template {SpecOptions} Spec @param {Spec} spec
19
+ * @returns {Structure<URL>}
20
+ */
21
+ url<Spec_1 extends SpecOptions>(spec: Spec_1): Structure<URL>;
22
+ /** @param {SpecOptions} spec */
23
+ _getValue(spec: SpecOptions): string;
24
+ /**
25
+ * @template T
26
+ * @param {URL} url
27
+ * @param {Structure<T>} spec
28
+ * @returns {Promise<T>}
29
+ */
30
+ load<T_1>(url: URL, spec: Structure<T_1>): Promise<T_1>;
31
+ /** @template T @param {T} config */
32
+ getUsage<T_2>(spec: any): string;
33
+ /**
34
+ * @template T @param {T} config
35
+ * @param {string} [prefix]
36
+ * @returns {{ config: any, fields: [string, string] }}
37
+ */
38
+ describeSpecification<T_3>(spec: any, prefix?: string): {
39
+ config: any;
40
+ fields: [string, string];
41
+ };
42
+ /** @param {Structure<any>} spec */
43
+ getJSONSchema(spec: Structure<any>): {
44
+ $schema: string;
45
+ };
46
+ }
47
+ export type SpecOptions = {
48
+ variable?: string;
49
+ flag?: string;
50
+ fallback: string;
51
+ };
52
+ export type ConfigurationOptions = {
53
+ readTextFile: (url: URL) => Promise<string | null>;
54
+ getEnvironmentVariable: (key: string) => (string | undefined);
55
+ getCommandArgument: (key: string) => (string | undefined);
56
+ stringify: (value: any) => (string | Promise<string>);
57
+ parse: (value: string) => (any);
58
+ };
59
+ import { Structure } from "./structures.js";
@@ -1,4 +1,9 @@
1
1
  import { formatMarkdownTable } from "./utilities.js";
2
+ import { Structure, StructError } from "./structures.js";
3
+
4
+ // NOTE: it would be nice to reverse the object/string/url methods around so they return the "spec" value, then the "struct" is stored under a string. This could mean the underlying architecture could change in the future. I'm not sure if that is possible with the structure nesting in play.
5
+
6
+ // NOTE: the schema generation will include whatever value is passed to the structure, in the context of configuration it will be whatever is configured and may be something secret
2
7
 
3
8
  /**
4
9
  * @typedef {object} SpecOptions
@@ -9,7 +14,6 @@ import { formatMarkdownTable } from "./utilities.js";
9
14
 
10
15
  /**
11
16
  * @typedef {object} ConfigurationOptions
12
- * @property {import("superstruct")} superstruct
13
17
  * @property {(url: URL) => Promise<string | null>} readTextFile
14
18
  * @property {(key: string) => (string | undefined)} getEnvironmentVariable
15
19
  * @property {(key: string) => (string | undefined)} getCommandArgument
@@ -18,7 +22,6 @@ import { formatMarkdownTable } from "./utilities.js";
18
22
  */
19
23
 
20
24
  const _requiredOptions = [
21
- "superstruct",
22
25
  "readTextFile",
23
26
  "getEnvironmentVariable",
24
27
  "getCommandArgument",
@@ -29,7 +32,7 @@ const _requiredOptions = [
29
32
  export class Configuration {
30
33
  static spec = Symbol("Configuration.spec");
31
34
 
32
- options /** @type {ConfigurationOptions} */;
35
+ /** @type {ConfigurationOptions} */ options;
33
36
 
34
37
  /** @param {ConfigurationOptions} options */
35
38
  constructor(options) {
@@ -39,51 +42,41 @@ export class Configuration {
39
42
  this.options = options;
40
43
  }
41
44
 
42
- /** @template T @param {T} spec */
45
+ /**
46
+ * @template {Record<string, Structure<any>>} T
47
+ * @param {T} spec
48
+ * @returns {Structure<{ [K in keyof T]: import("./structures.js").Infer<T[K]> }>}
49
+ */
43
50
  object(spec) {
44
- return Object.assign(
45
- this.options.superstruct.defaulted(
46
- this.options.superstruct.object(spec),
47
- {},
48
- ),
49
- { [Configuration.spec]: { type: "object", value: spec } },
50
- );
51
+ const struct = Structure.object(spec);
52
+ struct[Configuration.spec] = { type: "object", value: spec };
53
+ return struct;
51
54
  }
52
55
 
53
56
  /**
54
57
  * @template {SpecOptions} Spec @param {Spec} spec
58
+ * @returns {Structure<string>}
55
59
  */
56
60
  string(spec = {}) {
57
61
  if (typeof spec.fallback !== "string") {
58
62
  throw new TypeError("spec.fallback must be a string: " + spec.fallback);
59
63
  }
60
- return Object.assign(
61
- this.options.superstruct.defaulted(
62
- this.options.superstruct.string(),
63
- this._getValue(spec),
64
- ),
65
- { [Configuration.spec]: { type: "string", value: spec } },
66
- );
64
+ const struct = Structure.string(this._getValue(spec));
65
+ struct[Configuration.spec] = { type: "string", value: spec };
66
+ return struct;
67
67
  }
68
68
 
69
69
  /**
70
70
  * @template {SpecOptions} Spec @param {Spec} spec
71
+ * @returns {Structure<URL>}
71
72
  */
72
73
  url(spec) {
73
74
  if (typeof spec.fallback !== "string") {
74
75
  throw new TypeError("spec.fallback must be a string");
75
76
  }
76
- return Object.assign(
77
- this.options.superstruct.defaulted(
78
- this.options.superstruct.coerce(
79
- this.options.superstruct.instance(URL),
80
- this.options.superstruct.string(),
81
- (value) => new URL(value),
82
- ),
83
- this._getValue(spec),
84
- ),
85
- { [Configuration.spec]: { type: "url", value: spec } },
86
- );
77
+ const struct = Structure.url(this._getValue(spec));
78
+ struct[Configuration.spec] = { type: "url", value: spec };
79
+ return struct;
87
80
  }
88
81
 
89
82
  /** @param {SpecOptions} spec */
@@ -102,22 +95,27 @@ export class Configuration {
102
95
  /**
103
96
  * @template T
104
97
  * @param {URL} url
105
- * @param {import("superstruct").Struct<T>} spec
98
+ * @param {Structure<T>} spec
99
+ * @returns {Promise<T>}
106
100
  */
107
101
  async load(url, spec) {
108
102
  const file = await this.options.readTextFile(url);
109
103
 
110
104
  // Catch missing files and create a default configuration
111
105
  if (!file) {
112
- return this.options.superstruct.create({}, spec);
106
+ return spec.process({});
113
107
  }
114
108
 
115
109
  // Fail outside the try-catch to surface structure errors
116
- return this.options.superstruct.create(
117
- await this.options.parse(file),
118
- spec,
119
- "Configuration failed to parse",
120
- );
110
+ try {
111
+ return spec.process(await this.options.parse(file));
112
+ } catch (error) {
113
+ console.error("Configuration failed to parse");
114
+ if (error instanceof StructError) {
115
+ error.message = error.toFriendlyString();
116
+ }
117
+ throw error;
118
+ }
121
119
  }
122
120
 
123
121
  /** @template T @param {T} config */
@@ -179,4 +177,9 @@ export class Configuration {
179
177
  }
180
178
  throw new TypeError("Invalid [Configuration.spec].type '" + type + "'");
181
179
  }
180
+
181
+ /** @param {Structure<any>} spec */
182
+ getJSONSchema(spec) {
183
+ return spec.getSchema();
184
+ }
182
185
  }
@@ -0,0 +1 @@
1
+ export {};