Files
clan-core/lib/jsonschema/default.nix
Valentin Gagarin e28681c4f2 lib/jsonschema: make attrs required
Before the change, modules of the form

```nix
{ lib, ... }: {
  foo.bar = lib.mkOption {
    # ...
  };
}
```

or

```nix
{ lib, ... }: {
  foo = lib.mkOption {
    type = lib.types.subModule {
      bar = lib.mkOption {
        # ...
      };
    };
  };
}
```

would not render with `foo` as required, which is not faithful to the
module system's semantics.

This change also tests that fields with defaults are not marked required.
Note that submodule options cannot have their defaults rendered to JSON
schema, and are therefore always marked required.

Architecturally this change is rather unfortunate: So far the checks for
defaults happen in the rendering (using `isDefault`) and not in the parsing,
but here we're adding a field to `$exportedModuleInfo`. While strictly
speaking we probably don't want to consider requiredness as module-level
information, it seems more reasonable to me to do it that way since at
the JSON schema level we have lost the distinction between `attrs`,
`attrsOf`, `submodule`.
2025-04-16 13:20:00 +02:00

433 lines
13 KiB
Nix

{
lib ? import <nixpkgs/lib>,
}:
{
excludedTypes ? [
"functionTo"
"package"
],
includeDefaults ? true,
header ? {
"$schema" = "http://json-schema.org/draft-07/schema#";
},
specialArgs ? { },
}:
let
# remove _module attribute from options
clean = opts: builtins.removeAttrs opts [ "_module" ];
# throw error if option type is not supported
notSupported =
option:
lib.trace option throw ''
option type '${option.type.name}' ('${option.type.description}') not supported by jsonschema converter
location: ${lib.concatStringsSep "." option.loc}
'';
# Exclude the option if its type is in the excludedTypes list
# or if the option has a defaultText attribute
isExcludedOption = option: (lib.elem (option.type.name or null) excludedTypes);
filterExcluded = lib.filter (opt: !isExcludedOption opt);
excludedOptionNames = [ "_freeformOptions" ];
filterExcludedAttrs = lib.filterAttrs (
name: opt: !isExcludedOption opt && !builtins.elem name excludedOptionNames
);
# Filter out options where the visible attribute is set to false
filterInvisibleOpts = lib.filterAttrs (_name: opt: opt.visible or true);
# Constant: Used for the 'any' type
allBasicTypes = [
"boolean"
"integer"
"number"
"string"
"array"
"object"
"null"
];
in
rec {
# parses a nixos module to a jsonschema
parseModule =
module:
let
evaled = lib.evalModules {
modules = [ module ];
inherit specialArgs;
};
in
parseOptions evaled.options { };
# get default value from option
# Returns '{ default = Value; }'
# - '{}' if no default is present.
# - Value is "<thunk>" (string literal) if the option has a defaultText attribute. This means we cannot evaluate default safely
getDefaultFrom =
opt:
if !includeDefaults then
{ }
else if opt ? defaultText then
{
# dont add default to jsonschema. It seems to alter the type
# default = "<thunk>";
}
else
lib.optionalAttrs (opt ? default) {
default = opt.default;
};
parseSubOptions =
{
option,
prefix ? [ ],
}:
let
subOptions = option.type.getSubOptions option.loc;
in
parseOptions subOptions {
addHeader = false;
path = option.loc ++ prefix;
};
makeModuleInfo =
{
path,
defaultText ? null,
}:
{
"$exportedModuleInfo" =
{
inherit path;
}
// lib.optionalAttrs (defaultText != null) {
inherit defaultText;
};
};
# parses a set of evaluated nixos options to a jsonschema
parseOptions =
options:
{
# The top-level header object should specify at least the schema version
# Can be customized if needed
# By default the header is not added to the schema
addHeader ? true,
path ? [ ],
}:
let
options' = filterInvisibleOpts (filterExcludedAttrs (clean options));
# parse options to jsonschema properties
properties = lib.mapAttrs (_name: option: (parseOption' (path ++ [ _name ]) option)) options';
# TODO: figure out how to handle if prop.anyOf is used
isRequired = prop: !(prop ? default || prop."$exportedModuleInfo".required or false);
requiredProps = lib.filterAttrs (_: prop: isRequired prop) properties;
required = lib.optionalAttrs (requiredProps != { }) { required = lib.attrNames requiredProps; };
header' = if addHeader then header else { };
# freeformType is a special type
freeformDefs = options._module.freeformType.definitions or [ ];
checkFreeformDefs =
defs:
if (builtins.length defs) != 1 then
throw "parseOptions: freeformType definitions not supported"
else
defs;
# It seems that freeformType has [ null ]
freeformProperties =
if freeformDefs != [ ] && builtins.head freeformDefs != null then
# freeformType has only one definition
parseOption {
# options._module.freeformType.definitions
type = builtins.head (checkFreeformDefs freeformDefs);
_type = "option";
loc = path;
}
else
{ };
# Metadata about the module that is made available to the schema via '$propagatedModuleInfo'
exportedModuleInfo = makeModuleInfo {
inherit path;
};
in
# return jsonschema
header'
// exportedModuleInfo
// required
// {
type = "object";
inherit properties;
additionalProperties = false;
}
// freeformProperties;
# parses and evaluated nixos option to a jsonschema property definition
parseOption = parseOption' [ ];
parseOption' =
currentPath: option:
let
default = getDefaultFrom option;
example = lib.optionalAttrs (option ? example) {
examples =
if (builtins.typeOf option.example) == "list" then option.example else [ option.example ];
};
description = lib.optionalAttrs (option ? description) {
description = option.description.text or option.description;
};
exposedModuleInfo = makeModuleInfo {
path = option.loc;
defaultText = option.defaultText or null;
};
in
# either type
# TODO: if all nested options are excluded, the parent should be excluded too
if
option.type.name or null == "either" || option.type.name or null == "coercedTo"
# return jsonschema property definition for either
then
let
optionsList' = [
{
type = option.type.nestedTypes.left or option.type.nestedTypes.coercedType;
_type = "option";
loc = option.loc;
}
{
type = option.type.nestedTypes.right or option.type.nestedTypes.finalType;
_type = "option";
loc = option.loc;
}
];
optionsList = filterExcluded optionsList';
in
exposedModuleInfo // default // example // description // { oneOf = map parseOption optionsList; }
# handle nested options (not a submodule)
# foo.bar = mkOption { type = str; };
else if !option ? _type then
(parseOptions option {
addHeader = false;
path = currentPath;
})
# throw if not an option
else if option._type != "option" && option._type != "option-type" then
throw "parseOption: not an option"
# parse nullOr
else if
option.type.name == "nullOr"
# return jsonschema property definition for nullOr
then
let
nestedOption = {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
};
in
default
// exposedModuleInfo
// example
// description
// {
oneOf = [
{ type = "null"; }
] ++ (lib.optional (!isExcludedOption nestedOption) (parseOption nestedOption));
}
# parse bool
else if
option.type.name == "bool"
# return jsonschema property definition for bool
then
exposedModuleInfo // default // example // description // { type = "boolean"; }
# parse float
else if
option.type.name == "float"
# return jsonschema property definition for float
then
exposedModuleInfo // default // example // description // { type = "number"; }
# parse int
else if
(option.type.name == "int" || option.type.name == "positiveInt")
# return jsonschema property definition for int
then
exposedModuleInfo // default // example // description // { type = "integer"; }
# TODO: Add support for intMatching in jsonschema
# parse port type aka. "unsignedInt16"
else if
option.type.name == "unsignedInt16"
|| option.type.name == "unsignedInt"
|| option.type.name == "pkcs11"
|| option.type.name == "intBetween"
then
exposedModuleInfo // default // example // description // { type = "integer"; }
# parse string
# TODO: parse more precise string types
else if
option.type.name == "str"
|| option.type.name == "singleLineStr"
|| option.type.name == "passwdEntry str"
|| option.type.name == "passwdEntry path"
# return jsonschema property definition for string
then
exposedModuleInfo // default // example // description // { type = "string"; }
# TODO: Add support for stringMatching in jsonschema
# parse stringMatching
else if lib.strings.hasPrefix "strMatching" option.type.name then
exposedModuleInfo // default // example // description // { type = "string"; }
# TODO: Add support for separatedString in jsonschema
else if lib.strings.hasPrefix "separatedString" option.type.name then
exposedModuleInfo // default // example // description // { type = "string"; }
# parse string
else if
option.type.name == "path"
# return jsonschema property definition for path
then
exposedModuleInfo // default // example // description // { type = "string"; }
# parse anything
else if
option.type.name == "anything"
# return jsonschema property definition for anything
then
exposedModuleInfo // default // example // description // { type = allBasicTypes; }
# parse unspecified
else if
option.type.name == "unspecified"
# return jsonschema property definition for unspecified
then
exposedModuleInfo // default // example // description // { type = allBasicTypes; }
# parse raw
else if
option.type.name == "raw"
# return jsonschema property definition for raw
then
exposedModuleInfo // default // example // description // { type = allBasicTypes; }
# parse enum
else if
option.type.name == "enum"
# return jsonschema property definition for enum
then
exposedModuleInfo
// default
// example
// description
// {
enum = option.type.functor.payload.values;
}
# parse listOf submodule
else if
option.type.name == "listOf" && option.type.nestedTypes.elemType.name == "submodule"
# return jsonschema property definition for listOf submodule
then
default
// exposedModuleInfo
// example
// description
// {
type = "array";
items = parseSubOptions { inherit option; };
}
# parse list
else if
(option.type.name == "listOf")
# return jsonschema property definition for list
then
let
nestedOption = {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
};
in
default
// exposedModuleInfo
// example
// description
// {
type = "array";
}
// (lib.optionalAttrs (!isExcludedOption nestedOption) { items = parseOption nestedOption; })
# parse list of unspecified
else if
(option.type.name == "listOf") && (option.type.nestedTypes.elemType.name == "unspecified")
# return jsonschema property definition for list
then
exposedModuleInfo // default // example // description // { type = "array"; }
# parse attrsOf submodule
else if
option.type.name == "attrsOf" && option.type.nestedTypes.elemType.name == "submodule"
# return jsonschema property definition for attrsOf submodule
then
default
// exposedModuleInfo
// example
// description
// {
type = "object";
additionalProperties = parseSubOptions {
inherit option;
prefix = [ "<name>" ];
};
}
# parse attrs
else if
option.type.name == "attrs"
# return jsonschema property definition for attrs
then
default
// (lib.recursiveUpdate exposedModuleInfo (
lib.optionalAttrs (!default ? default) {
"$exportedModuleInfo" = {
required = true;
};
}
))
// example
// description
// {
type = "object";
additionalProperties = true;
}
# parse attrsOf
# TODO: if nested option is excluded, the parent should be excluded too
else if
option.type.name == "attrsOf" || option.type.name == "lazyAttrsOf"
# return jsonschema property definition for attrs
then
let
nestedOption = {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
};
in
default
// exposedModuleInfo
// example
// description
// {
type = "object";
additionalProperties =
if !isExcludedOption nestedOption then
parseOption {
type = option.type.nestedTypes.elemType;
_type = "option";
loc = option.loc;
}
else
false;
}
# parse submodule
else if
option.type.name == "submodule"
# return jsonschema property definition for submodule
# then (lib.attrNames (option.type.getSubOptions option.loc).opt)
then
exposedModuleInfo // example // description // parseSubOptions { inherit option; }
# throw error if option type is not supported
else
notSupported option;
}