Skip to content

Commit

Permalink
(feat) Add support for extension config schemas (#358)
Browse files Browse the repository at this point in the history
  • Loading branch information
brandones authored Mar 17, 2022
1 parent 90d8c49 commit caac917
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 108 deletions.
175 changes: 122 additions & 53 deletions packages/framework/esm-config/src/module-config/module-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,42 +937,30 @@ describe("extension slot config", () => {
describe("extension config", () => {
beforeEach(() => {
console.error = jest.fn();
Config.defineConfigSchema("ext-mod", {
bar: { _default: "barry" },
baz: { _default: "bazzy" },
});
});

afterEach(resetAll);

it("returns the module config", async () => {
Config.defineConfigSchema("ext-mod", {
bar: { _default: "barry" },
baz: { _default: "bazzy" },
});
const testConfig = { "ext-mod": { bar: "qux" } };
Config.provide(testConfig);
configExtensionStore.setState({
mountedExtensions: [
{
slotModuleName: "slot-mod",
extensionModuleName: "ext-mod",
slotName: "barSlot",
extensionId: "fooExt",
},
],
});
const config = getExtensionConfigStore(
const moduleLevelConfig = { "ext-mod": { bar: "qux" } };
updateConfigExtensionStore();
Config.provide(moduleLevelConfig);
const result = getExtensionConfigStore(
"slot-mod",
"barSlot",
"fooExt"
).getState().config;
expect(config).toStrictEqual({ bar: "qux", baz: "bazzy" });
expect(result).toStrictEqual({ bar: "qux", baz: "bazzy" });
expect(console.error).not.toHaveBeenCalled();
});

it("uses the 'configure' config if one is present", async () => {
Config.defineConfigSchema("ext-mod", {
bar: { _default: "barry" },
baz: { _default: "bazzy" },
});
const testConfig = {
it("uses the 'configure' config if one is present, with module config schema", () => {
updateConfigExtensionStore("fooExt#id0");
const configureConfig = {
"ext-mod": { bar: "qux" },
"slot-mod": {
extensionSlots: {
Expand All @@ -982,54 +970,135 @@ describe("extension config", () => {
},
},
};
Config.provide(testConfig);
configExtensionStore.setState({
mountedExtensions: [
{
slotModuleName: "slot-mod",
extensionModuleName: "ext-mod",
slotName: "barSlot",
extensionId: "fooExt#id0",
Config.provide(configureConfig);
const result = getExtensionConfigStore(
"slot-mod",
"barSlot",
"fooExt#id0"
).getState().config;
expect(result).toStrictEqual({ bar: "qux", baz: "quiz" });
expect(console.error).not.toHaveBeenCalled();
});

it("validates the extension configure config, with module config schema", () => {
updateConfigExtensionStore("fooExt#id1");
const badConfig = {
"ext-mod": { bar: "qux" },
"slot-mod": {
extensionSlots: {
barSlot: {
configure: { "fooExt#id1": { beef: "bad" } },
},
},
],
},
};
Config.provide(badConfig);
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/unknown config key 'ext-mod.beef' provided.*/i)
);
});

it("returns the extension config if an extension config schema is defined", async () => {
updateConfigExtensionStore("fooExt");
Config.defineExtensionConfigSchema("fooExt", {
qux: { _default: "quxxy" },
});
const config = getExtensionConfigStore(
const extensionAtBaseConfig = { fooExt: { qux: "quxolotl" } };
Config.provide(extensionAtBaseConfig);
const result = getExtensionConfigStore(
"slot-mod",
"barSlot",
"fooExt#id0"
"fooExt"
).getState().config;
expect(config).toStrictEqual({ bar: "qux", baz: "quiz" });
expect(result).toStrictEqual({ qux: "quxolotl" });
expect(console.error).not.toHaveBeenCalled();
});

it("validates the extension slot config", async () => {
Config.defineConfigSchema("ext-mod", {
bar: { _default: "barry" },
baz: { _default: "bazzy" },
it("uses the 'configure' config if one is present, with extension config schema", () => {
updateConfigExtensionStore("fooExt#id2");
Config.defineExtensionConfigSchema("fooExt", {
qux: { _default: "quxxy" },
});
const testConfig = {
"ext-mod": { bar: "qux" },
const configureConfig = {
fooExt: { qux: "quxolotl" },
"slot-mod": {
extensionSlots: {
barSlot: {
configure: { "fooExt#id0": { beef: "bad" } },
configure: { "fooExt#id2": { qux: "quxotic" } },
},
},
},
};
Config.provide(testConfig);
configExtensionStore.setState({
mountedExtensions: [
{
slotModuleName: "slot-mod",
extensionModuleName: "ext-mod",
slotName: "barSlot",
extensionId: "fooExt#id0",
Config.provide(configureConfig);
const result = getExtensionConfigStore(
"slot-mod",
"barSlot",
"fooExt#id2"
).getState().config;
expect(result).toStrictEqual({ qux: "quxotic" });
});

it("validates the extension configure config, with extension config schema", () => {
updateConfigExtensionStore("fooExt#id3");
Config.defineExtensionConfigSchema("fooExt", {
qux: { _default: "quxxy" },
});
const configureConfig = {
"slot-mod": {
extensionSlots: {
barSlot: {
configure: { "fooExt#id3": { no: "bad" } },
},
},
],
},
};
Config.provide(configureConfig);
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/unknown config key 'fooExt.no' provided.*/i)
);
});

it("does not accept module config parameters for extension if extension config schema is defined", () => {
Config.defineExtensionConfigSchema("fooExt", {
qux: { _default: "quxxy" },
});
const badConfigNoModuleConfigsForExtension = {
fooExt: {
bar: "no good",
},
};
Config.provide(badConfigNoModuleConfigsForExtension);
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/unknown config key 'ext-mod.beef' provided.*/i)
expect.stringMatching(/unknown config key 'fooExt.bar' provided.*/i)
);
});

it("does not accept extension config parameters for the module", () => {
updateConfigExtensionStore("fooExt#id5");
Config.defineExtensionConfigSchema("fooExt", {
qux: { _default: "quxxy" },
});
const badConfigNoExtensionConfigsForModule = {
"ext-mod": {
qux: "also bad",
},
};
Config.provide(badConfigNoExtensionConfigsForModule);
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/unknown config key 'ext-mod.qux' provided.*/i)
);
});
});

function updateConfigExtensionStore(extensionId = "fooExt") {
configExtensionStore.setState({
mountedExtensions: [
{
slotModuleName: "slot-mod",
extensionModuleName: "ext-mod",
slotName: "barSlot",
extensionId,
},
],
});
}
82 changes: 71 additions & 11 deletions packages/framework/esm-config/src/module-config/module-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ import { TemporaryConfigStore } from "..";
* the correct set of output stores are updated.
*
* All `compute...` functions except `computeExtensionConfigs` are pure
* (or are supposed to be). `computeExtensionConfigs` calls `getGlobalStore`,
* (or are supposed to be), other than the fact that they all `setState`
* store values at the end. `computeExtensionConfigs` calls `getGlobalStore`,
* which creates stores.
*/
computeModuleConfig(
Expand Down Expand Up @@ -175,6 +176,18 @@ function computeExtensionConfigs(
*
*/

/**
* This defines a configuration schema for a module. The schema tells the
* configuration system how the module can be configured. It specifies
* what makes configuration valid or invalid.
*
* See [Configuration System](http://o3-dev.docs.openmrs.org/#/main/config)
* for more information about defining a config schema.
*
* @param moduleName Name of the module the schema is being defined for. Generally
* should be the one in which the `defineConfigSchema` call takes place.
* @param schema The config schema for the module
*/
export function defineConfigSchema(moduleName: string, schema: ConfigSchema) {
validateConfigSchema(moduleName, schema);
const state = configInternalStore.getState();
Expand All @@ -183,6 +196,39 @@ export function defineConfigSchema(moduleName: string, schema: ConfigSchema) {
});
}

/**
* This defines a configuration schema for an extension. When a schema is defined
* for an extension, that extension will receive the configuration corresponding
* to that schema, rather than the configuration corresponding to the module
* in which it is defined.
*
* The schema tells the configuration system how the module can be configured.
* It specifies what makes configuration valid or invalid.
*
* See [Configuration System](http://o3-dev.docs.openmrs.org/#/main/config)
* for more information about defining a config schema.
*
* @param extensionName Name of the extension the schema is being defined for.
* Should match the `name` of one of the `extensions` entries being returned
* by `setupOpenMRS`.
* @param schema The config schema for the extension
*/
export function defineExtensionConfigSchema(
extensionName: string,
schema: ConfigSchema
) {
validateConfigSchema(extensionName, schema);
const state = configInternalStore.getState();
if (state.schemas[extensionName]) {
console.warn(
`Config schema for extension ${extensionName} already exists. If there are multiple extensions with this same name, one will probably crash.`
);
}
configInternalStore.setState({
schemas: { ...state.schemas, [extensionName]: schema },
});
}

export function provide(config: Config, sourceName = "provided") {
const state = configInternalStore.getState();
configInternalStore.setState({
Expand Down Expand Up @@ -245,8 +291,9 @@ export function processConfig(
* Returns the configuration for an extension. This configuration is specific
* to the slot in which it is mounted, and its ID within that slot.
*
* The schema for that configuration is the schema for the module in which the
* extension is defined.
* The schema for that configuration is the extension schema. If no extension
* schema has been provided, the schema used is the schema of the module in
* which the extension is defined.
*
* @param slotModuleName The name of the module which defines the extension slot
* @param extensionModuleName The name of the module which defines the extension (and therefore the config schema)
Expand All @@ -261,21 +308,25 @@ function getExtensionConfig(
configState: ConfigInternalStore,
tempConfigState: TemporaryConfigStore
) {
const extensionName = getExtensionNameFromId(extensionId);
const extensionConfigSchema = configState.schemas[extensionName];
const nameOfSchemaSource = extensionConfigSchema
? extensionName
: extensionModuleName;
const providedConfigs = getProvidedConfigs(configState, tempConfigState);
const slotModuleConfig = mergeConfigsFor(slotModuleName, providedConfigs);
const configOverride =
slotModuleConfig?.extensionSlots?.[slotName]?.configure?.[extensionId] ??
{};
const extensionModuleConfig = mergeConfigsFor(
extensionModuleName,
providedConfigs
);
const extensionConfig = mergeConfigs([extensionModuleConfig, configOverride]);
const schema = configState.schemas[extensionModuleName]; // TODO: validate that a schema exists for the module
validateConfig(schema, extensionConfig, extensionModuleName);
const extensionConfig = mergeConfigsFor(nameOfSchemaSource, providedConfigs);
const combinedConfig = mergeConfigs([extensionConfig, configOverride]);
// TODO: validate that a schema exists for the module
const schema =
extensionConfigSchema ?? configState.schemas[extensionModuleName];
validateConfig(schema, combinedConfig, nameOfSchemaSource);
const config = setDefaults(
schema,
extensionConfig,
combinedConfig,
configState.devDefaultsAreOn
);
delete config.extensionSlots;
Expand Down Expand Up @@ -741,3 +792,12 @@ function hasObjectSchema(
function isOrdinaryObject(value) {
return typeof value === "object" && !Array.isArray(value) && value !== null;
}

/**
* Copied over from esm-extensions. It rightly belongs to that module, but esm-config
* cannot depend on esm-extensions.
*/
function getExtensionNameFromId(extensionId: string) {
const [extensionName] = extensionId.split("#");
return extensionName;
}
Loading

0 comments on commit caac917

Please sign in to comment.