Skip to content

Commit 8efb110

Browse files
authored
[JS] Add getOptions method with schema validation (#177)
JS: * Add `getOptions<T>` to `OptionsProvider` and `OptionsWatcher` * Fix `CI with sudo pkg install -y -f ca_root_nss`
1 parent 10d5686 commit 8efb110

File tree

8 files changed

+154
-8
lines changed

8 files changed

+154
-8
lines changed

.github/workflows/JS_build_test_publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ jobs:
179179
path: ./js/optify-config/${{ env.APP_NAME }}.*.node
180180
if-no-files-found: error
181181
build-freebsd:
182-
# Disabled for now because it gives too many installation errors such as when trying to install `yarn`.
183182
if: github.event.pull_request.draft == false || github.event_name != 'pull_request'
184183
runs-on: ubuntu-latest
185184
name: "[FreeBSD] Build"
@@ -210,7 +209,8 @@ jobs:
210209
set -ex
211210
212211
cd js/optify-config
213-
sudo pkg install -y -f curl node libnghttp2 npm
212+
# ca_root_nss: Helps with downloading Rust toolchains and Node.js binaries.
213+
sudo pkg install -y -f ca_root_nss curl node libnghttp2 npm
214214
sudo npm install -g yarn --ignore-scripts
215215
curl https://sh.rustup.rs -sSf --output rustup.sh
216216
sh rustup.sh -y --profile minimal --default-toolchain stable

js/optify-config/README.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,41 @@ See the [homepage] for details about how feature files are combined to build the
55

66
# Usage
77

8+
```TypeScript
9+
import { z } from 'zod'
10+
import { OptionsProvider } from '@optify/config'
11+
12+
const MyConfigSchema = z.object({
13+
rootString: z.string(),
14+
myArray: z.array(z.string()),
15+
myObject: z.object({
16+
key: z.string(),
17+
deeper: z.object({
18+
new: z.string(),
19+
num: z.number(),
20+
}),
21+
}),
22+
})
23+
24+
const provider = OptionsProvider.build('<configs folder path>')
25+
const config = provider.getOptions('myConfig', ['feature_A', 'feature_B'], MyConfigSchema)
26+
// config is typed and validated at runtime
27+
console.log(config.rootString)
28+
console.log(config.myObject.deeper.num)
29+
```
30+
31+
The `getOptions` method accepts any object with a `parse(data: unknown): T` method, making it compatible with [Zod](https://zod.dev/) and other schema validation libraries.
32+
This package does not depend on Zod; it only requires the schema to have a `parse` method.
33+
If desired, install Zod (or your preferred validation library) separately in your project:
34+
35+
```shell
36+
npm install zod
37+
```
38+
39+
### Using `getOptionsJson`
40+
41+
If you don't need schema validation, you can use `getOptionsJson` to get the raw JSON string:
42+
843
```TypeScript
944
import { OptionsProvider } from '@optify/config'
1045

@@ -25,20 +60,18 @@ Outputs:
2560
"new": "new value",
2661
"num": 3333
2762
},
28-
"key": "val",
63+
"key": "val"
2964
},
3065
"rootString": "root string same"
3166
}
3267
```
3368

34-
Multiple directories can be used as well:
69+
### Multiple directories
3570

3671
```TypeScript
3772
import { OptionsProvider } from '@optify/config'
3873

3974
const provider = OptionsProvider.buildFromDirectories(['<configs folder path>', '<another folder path>'])
40-
const options = JSON.parse(provider.getOptionsJson('myConfig', ['feature_A', 'feature_B']))
41-
console.log(JSON.stringify(options, null, 2))
4275
```
4376

4477
# Development

js/optify-config/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@optify/config",
3-
"version": "1.1.7",
3+
"version": "1.2.0",
44
"description": "Simplifies **configuration driven development**: getting the right configuration options for a process or request using pre-loaded configurations from files (JSON, YAML, etc.) to manage options for feature flags, experiments, or flights.",
55
"repository": {
66
"type": "git",
@@ -38,7 +38,8 @@
3838
"jest": "^29.7.0",
3939
"tinybench": "^5.1.0",
4040
"ts-jest": "^29.3.4",
41-
"typescript": "^5.8.3"
41+
"typescript": "^5.8.3",
42+
"zod": "^4.3.6"
4243
},
4344
"engines": {
4445
"node": ">= 20"

js/optify-config/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,25 @@ export {
1717
export type OptionsProvider = nativeBinding.OptionsProvider;
1818
export type OptionsWatcher = nativeBinding.OptionsWatcher;
1919

20+
/** Any object with a parse method, compatible with Zod schemas. */
21+
export interface TypeSchema<T> {
22+
parse(data: unknown): T;
23+
}
24+
2025
// Augment the native class interfaces to include our new method
2126
declare module '../index' {
2227
interface OptionsProvider {
2328
/** Returns a map of all the canonical feature names to their metadata. */
2429
featuresWithMetadata(): Record<string, OptionsMetadata>;
30+
/** Gets options for the specified key and feature names, validated against a schema. */
31+
getOptions<T>(key: string, featureNames: Array<string>, schema: TypeSchema<T>, preferences?: GetOptionsPreferences | null): T;
2532
}
2633

2734
interface OptionsWatcher {
2835
/** Returns a map of all the canonical feature names to their metadata. */
2936
featuresWithMetadata(): Record<string, OptionsMetadata>;
37+
/** Gets options for the specified key and feature names, validated against a schema. */
38+
getOptions<T>(key: string, featureNames: Array<string>, schema: TypeSchema<T>, preferences?: GetOptionsPreferences | null): T;
3039
}
3140
}
3241

@@ -44,6 +53,9 @@ export const OptionsProvider = nativeBinding.OptionsProvider;
4453

4554
return this[CACHE_KEY] = this._featuresWithMetadata();
4655
};
56+
(OptionsProvider.prototype as any).getOptions = function (this: any, key: string, featureNames: string[], schema: any, preferences?: nativeBinding.GetOptionsPreferences | null): any {
57+
return schema.parse(this._getOptions(key, featureNames, preferences));
58+
};
4759

4860
// Extend OptionsWatcher prototype with extra methods.
4961
export const OptionsWatcher = nativeBinding.OptionsWatcher;
@@ -56,4 +68,7 @@ export const OptionsWatcher = nativeBinding.OptionsWatcher;
5668

5769
this[CACHE_TIME_KEY] = lastModifiedTime;
5870
return this[CACHE_KEY] = this._featuresWithMetadata();
71+
};
72+
(OptionsWatcher.prototype as any).getOptions = function (this: any, key: string, featureNames: string[], schema: any, preferences?: nativeBinding.GetOptionsPreferences | null): any {
73+
return schema.parse(this._getOptions(key, featureNames, preferences));
5974
};

js/optify-config/src/provider.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ impl JsOptionsProvider {
117117
.ok()
118118
}
119119

120+
#[napi(js_name = "_getOptions")]
121+
pub fn get_options(
122+
&self,
123+
key: String,
124+
feature_names: Vec<String>,
125+
preferences: Option<&JsGetOptionsPreferences>,
126+
) -> napi::Result<serde_json::Value> {
127+
let preferences = preferences.map(|p| &p.inner);
128+
self
129+
.inner
130+
.as_ref()
131+
.unwrap()
132+
.get_options_with_preferences(&key, &feature_names, None, preferences)
133+
.map_err(|e| napi::Error::from_reason(e.to_string()))
134+
}
135+
120136
#[napi]
121137
pub fn get_options_json(
122138
&self,

js/optify-config/src/watcher.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,22 @@ impl JsOptionsWatcher {
178178
.ok()
179179
}
180180

181+
#[napi(js_name = "_getOptions")]
182+
pub fn get_options(
183+
&self,
184+
key: String,
185+
feature_names: Vec<String>,
186+
preferences: Option<&JsGetOptionsPreferences>,
187+
) -> napi::Result<serde_json::Value> {
188+
let preferences = preferences.map(|p| &p.inner);
189+
self
190+
.inner
191+
.as_ref()
192+
.unwrap()
193+
.get_options_with_preferences(&key, &feature_names, None, preferences)
194+
.map_err(|e| napi::Error::from_reason(e.to_string()))
195+
}
196+
181197
#[napi]
182198
pub fn get_options_json(
183199
&self,

js/optify-config/tests/optify.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from '@jest/globals';
22
import fs from 'fs';
33
import path from 'path';
4+
import { z } from 'zod';
45
import { GetOptionsPreferences, OptionsProvider, OptionsWatcher } from "../dist/index";
56

67
const runSuite = (suitePath: string) => {
@@ -33,6 +34,62 @@ const runSuite = (suitePath: string) => {
3334
}
3435
}
3536

37+
const DeeperObjectSchema = z.object({
38+
wtv: z.number(),
39+
list: z.array(z.number()),
40+
});
41+
42+
const MyObjectSchema = z.object({
43+
one: z.number(),
44+
two: z.number(),
45+
string: z.string(),
46+
deeper: DeeperObjectSchema,
47+
});
48+
49+
const MyConfigSchema = z.object({
50+
rootString: z.string(),
51+
rootString2: z.string(),
52+
myArray: z.array(z.string()),
53+
myObject: MyObjectSchema,
54+
});
55+
56+
describe('getOptions', () => {
57+
const configsPath = path.join(__dirname, '../../../tests/test_suites/simple/configs');
58+
const providers = [{
59+
name: "OptionsProvider",
60+
provider: OptionsProvider.build(configsPath),
61+
}, {
62+
name: "OptionsWatcher",
63+
provider: OptionsWatcher.build(configsPath),
64+
}];
65+
66+
for (const { name, provider } of providers) {
67+
test(`${name} validates and returns a typed object with a schema`, () => {
68+
const config = provider.getOptions('myConfig', ['A'], MyConfigSchema);
69+
expect(config.rootString).toBe('root string same');
70+
expect(config.rootString2).toBe('gets overridden');
71+
expect(config.myArray).toEqual(['example item 1']);
72+
expect(config.myObject.one).toBe(1);
73+
expect(config.myObject.deeper.list).toEqual([1, 2]);
74+
});
75+
76+
test(`${name} getOptions with schema matches getOptionsJson`, () => {
77+
const preferences = new GetOptionsPreferences();
78+
preferences.enableConfigurableStrings();
79+
const fromJson = JSON.parse(provider.getOptionsJson('myConfig', ['A'], preferences));
80+
const fromGetOptions = provider.getOptions('myConfig', ['A'], MyConfigSchema, preferences);
81+
expect(fromGetOptions).toEqual(fromJson);
82+
});
83+
84+
test(`${name} getOptions with schema rejects invalid data`, () => {
85+
const StrictSchema = z.object({
86+
rootString: z.number(),
87+
});
88+
expect(() => provider.getOptions('myConfig', ['A'], StrictSchema)).toThrow();
89+
});
90+
}
91+
});
92+
3693
const testSuitesDir = path.join(__dirname, '../../../tests/test_suites');
3794
for (const suite of fs.readdirSync(testSuitesDir)) {
3895
describe(`Suite: ${suite}`, () => {

js/optify-config/yarn.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,7 @@ __metadata:
17001700
tinybench: "npm:^5.1.0"
17011701
ts-jest: "npm:^29.3.4"
17021702
typescript: "npm:^5.8.3"
1703+
zod: "npm:^4.3.6"
17031704
languageName: unknown
17041705
linkType: soft
17051706

@@ -4740,3 +4741,10 @@ __metadata:
47404741
checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f
47414742
languageName: node
47424743
linkType: hard
4744+
4745+
"zod@npm:^4.3.6":
4746+
version: 4.3.6
4747+
resolution: "zod@npm:4.3.6"
4748+
checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307
4749+
languageName: node
4750+
linkType: hard

0 commit comments

Comments
 (0)