diff --git a/dev/lib/jsonschema.nix b/dev/lib/jsonschema.nix index 53a4c06..c923b66 100644 --- a/dev/lib/jsonschema.nix +++ b/dev/lib/jsonschema.nix @@ -6,6 +6,11 @@ "functionTo" "package" ], + includeDefaults ? true, + header ? { + "$schema" = "http://json-schema.org/draft-07/schema#"; + }, + specialArgs ? { }, }: let # remove _module attribute from options @@ -21,16 +26,19 @@ let # 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) || (option ? defaultText)); + isExcludedOption = option: (lib.elem (option.type.name or null) excludedTypes); filterExcluded = lib.filter (opt: !isExcludedOption opt); - filterExcludedAttrs = lib.filterAttrs (_name: 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" @@ -42,39 +50,127 @@ let ]; in rec { - # parses a nixos module to a jsonschema parseModule = module: let - evaled = lib.evalModules { modules = [ module ]; }; + evaled = lib.evalModules { + modules = [ module ]; + inherit specialArgs; + }; in - parseOptions evaled.options; + parseOptions evaled.options { }; + + # get default value from option + + # Returns '{ default = Value; }' + # - '{}' if no default is present. + # - Value is "" (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 = ""; + } + 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': + 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')); + options' = filterInvisibleOpts (filterExcludedAttrs (clean options)); # parse options to jsonschema properties - properties = lib.mapAttrs (_name: option: parseOption option) options; + 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.type or null == "object"); 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 = lib.optionalAttrs true (makeModuleInfo { + inherit path; + }); in # return jsonschema - required + header' + // exportedModuleInfo + // required // { type = "object"; inherit properties; - }; + additionalProperties = false; + } + // freeformProperties; # parses and evaluated nixos option to a jsonschema property definition - parseOption = - option: + parseOption = parseOption' [ ]; + parseOption' = + currentPath: option: let - default = lib.optionalAttrs (option ? default) { inherit (option) default; }; + default = getDefaultFrom option; example = lib.optionalAttrs (option ? example) { examples = if (builtins.typeOf option.example) == "list" then option.example else [ option.example ]; @@ -82,8 +178,11 @@ rec { 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 @@ -105,16 +204,17 @@ rec { ]; optionsList = filterExcluded optionsList'; in - default // example // description // { anyOf = map parseOption optionsList; } - + 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 - + (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" @@ -128,35 +228,32 @@ rec { }; in default + // exposedModuleInfo // example // description // { - anyOf = [ + oneOf = [ { type = "null"; } ] ++ (lib.optional (!isExcludedOption nestedOption) (parseOption nestedOption)); } - # parse bool else if option.type.name == "bool" # return jsonschema property definition for bool then - default // example // description // { type = "boolean"; } - + exposedModuleInfo // default // example // description // { type = "boolean"; } # parse float else if option.type.name == "float" # return jsonschema property definition for float then - default // example // description // { type = "number"; } - + 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 - default // example // description // { type = "integer"; } - + exposedModuleInfo // default // example // description // { type = "integer"; } # TODO: Add support for intMatching in jsonschema # parse port type aka. "unsignedInt16" else if @@ -165,8 +262,7 @@ rec { || option.type.name == "pkcs11" || option.type.name == "intBetween" then - default // example // description // { type = "integer"; } - + exposedModuleInfo // default // example // description // { type = "integer"; } # parse string # TODO: parse more precise string types else if @@ -176,65 +272,57 @@ rec { || option.type.name == "passwdEntry path" # return jsonschema property definition for string then - default // example // description // { type = "string"; } - + 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 - default // example // description // { type = "string"; } - + exposedModuleInfo // default // example // description // { type = "string"; } # TODO: Add support for separatedString in jsonschema else if lib.strings.hasPrefix "separatedString" option.type.name then - default // example // description // { type = "string"; } - + exposedModuleInfo // default // example // description // { type = "string"; } # parse string else if option.type.name == "path" # return jsonschema property definition for path then - default // example // description // { type = "string"; } - + exposedModuleInfo // default // example // description // { type = "string"; } # parse anything else if option.type.name == "anything" # return jsonschema property definition for anything then - default // example // description // { type = allBasicTypes; } - + exposedModuleInfo // default // example // description // { type = allBasicTypes; } # parse unspecified else if option.type.name == "unspecified" # return jsonschema property definition for unspecified then - default // example // description // { type = allBasicTypes; } - + exposedModuleInfo // default // example // description // { type = allBasicTypes; } # parse raw else if option.type.name == "raw" # return jsonschema property definition for raw then - default // example // description // { type = allBasicTypes; } - + exposedModuleInfo // default // example // description // { type = allBasicTypes; } # parse enum else if option.type.name == "enum" # return jsonschema property definition for enum then - default // example // description // { enum = option.type.functor.payload; } - + exposedModuleInfo // default // example // description // { enum = option.type.functor.payload; } # parse listOf submodule else if - option.type.name == "listOf" && option.type.functor.wrapped.name == "submodule" + 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 = parseOptions (option.type.functor.wrapped.getSubOptions option.loc); + items = parseSubOptions { inherit option; }; } - # parse list else if (option.type.name == "listOf") @@ -242,54 +330,56 @@ rec { then let nestedOption = { - type = option.type.functor.wrapped; + 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.functor.wrapped.name == "unspecified") + (option.type.name == "listOf") && (option.type.nestedTypes.elemType.name == "unspecified") # return jsonschema property definition for list then - default // example // description // { type = "array"; } - + 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 = parseOptions (option.type.nestedTypes.elemType.getSubOptions option.loc); + additionalProperties = parseSubOptions { + inherit option; + prefix = [ "" ]; + }; } - # parse attrs else if option.type.name == "attrs" # return jsonschema property definition for attrs then default + // exposedModuleInfo // example // description // { type = "object"; additionalProperties = true; } - # parse attrsOf - # TODO: if nested option is excluded, the parent sould be excluded too + # 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 @@ -302,6 +392,7 @@ rec { }; in default + // exposedModuleInfo // example // description // { @@ -316,15 +407,13 @@ rec { 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 - parseOptions (option.type.getSubOptions option.loc) - + exposedModuleInfo // example // description // parseSubOptions { inherit option; } # throw error if option type is not supported else notSupported option;