Usage

Getting the library

default.nix at the repository root is the entry point. It takes system, sources, pkgs and lib as optional arguments and returns, among the project's other outputs, lib.module-schema. With npins -- what the repository uses for its own inputs -- pin module-schema and import it:

let
  sources = import ./npins;
  pkgs = import sources.nixpkgs { };

  module-schema = (import sources.module-schema { }).lib.module-schema;
in
module-schema.optionsToSchema (pkgs.nixos { }).options.services.peertube

Called with no arguments it uses its own pinned nixpkgs. Pass sources, pkgs or lib to use yours instead. A sources attrset has to carry a nixpkgs entry: the library reads lib/ascii-table.nix out of the nixpkgs source tree to percent-encode the URI fragments it builds for $ref pointers.

The repository is also a flake, at git+https://git.fediversity.eu/fediversity/module-schema, with the same lib output. The flake declares no inputs of its own, so inputs.nixpkgs.follows has no effect; call default.nix with your own sources to override the pin.

Evaluating without a package set

The library needs nixpkgs' lib, not an instantiated package set. Passing lib avoids import sources.nixpkgs { }, which forces builtins.currentSystem and therefore fails under a pure flake evaluation:

let
  lib = import "${sources.nixpkgs}/lib";
in
(import sources.module-schema { inherit sources lib; }).lib.module-schema

The repository's own flake.nix calls the entry point this way. Note that this covers the library, not the examples below that call pkgs.nixos -- instantiating a NixOS system needs real nixpkgs.

optionsToSchema

The primary entry point. It takes an attrset of options -- all the options declared for a service, say -- and returns a schema for a configuration of that service:

optionsToSchema ((nixos { }).options.services.peertube.database)
=> {
     "$defs" = {
       # The schema of a valid Nix attribute name, elided here.
       attrName = { };
     };
     "$schema" = "https://json-schema.org/draft/2020-12/schema";
     additionalProperties = false;
     properties = {
       createLocally = {
         default = false;
         description = "Configure local PostgreSQL database server for PeerTube.";
         type = "boolean";
       };
       host = {
         default = "if config.services.peertube.database.createLocally\nthen \"/run/postgresql\"\nelse null\n";
         description = "Database host address or unix socket.";
         examples = [ "192.168.15.47" ];
         type = "string";
       };
       name = {
         default = "peertube";
         description = "Database name.";
         type = "string";
       };
       passwordFile = {
         default = null;
         description = "Password for PostgreSQL database.";
         examples = [ "/run/keys/peertube/password-postgresql" ];
         type = [
           "null"
           "string"
         ];
       };
       port = {
         default = 5432;
         description = "Database host port.";
         maximum = 65535;
         minimum = 0;
         type = "integer";
       };
       user = {
         default = "peertube";
         description = "Database user.";
         type = "string";
       };
     };
     propertyNames = {
       "$ref" = "#/$defs/attrName";
     };
     type = "object";
   }

Several things are worth pointing out in that output:

  • optionsToSchema is the only function that emits the $schema root key, so it is the one to use for a schema that stands on its own as a document.
  • The result is an object schema. Each option becomes an entry in properties, every option without a default is listed in required, and additionalProperties = false rejects what the module does not declare. propertyNames refers to a $defs entry that describes a valid Nix attribute name.
  • port is types.port, whose bounds survive as minimum and maximum.
  • passwordFile is nullOr str, and rather than becoming an anyOf it collapses into a single schema with type = [ "null" "string" ].
  • host carries a defaultText, which replaces default in the schema. Whenever a module author provides one it is the better representation to show, so it wins even when the actual default would serialize fine.

optionToSchema

The same conversion for a single option. It emits no $schema, which makes it the building block to use when embedding the result in a larger schema:

optionToSchema (lib.mkEnableOption "SERVICE")
=> {
     default = false;
     description = "Whether to enable SERVICE.";
     examples = [ true ];
     type = "boolean";
   }

optionToSchema ((nixos { }).options.services.peertube.database.port)
=> {
     default = 5432;
     description = "Database host port.";
     maximum = 65535;
     minimum = 0;
     type = "integer";
   }

Note that optionToSchema converts whatever option it is handed. The filtering of hidden and internal options happens while walking an attrset of options, so a bare optionToSchema on an internal option still produces a schema.

Serializing

The result is an attrset built so that builtins.toJSON turns it into a JSON Schema:

builtins.toJSON (optionsToSchema options)

For a schema checked into a repository, writing it out with stable key ordering keeps diffs readable:

pkgs.runCommand "service.schema.json" { nativeBuildInputs = [ pkgs.jq ]; } ''
  jq -S . ${pkgs.writers.writeJSON "schema.json" (optionsToSchema options)} > $out
''

Authoring modules that convert well

A few habits make a module convert cleanly:

  • Hide what the interface should not see. Options marked internal = true or visible = false are skipped entirely, as the generated manuals skip them. Use them for implementation detail.
  • Check the type is supported. An unsupported type throws. Coverage lists what converts. The library is meant to handle any module eventually; until it handles a given type, either filter the options of that type out before conversion or hide them as above.
  • functionTo is not supported yet. Nix's toJSON and fromJSON cannot usefully round-trip a function, and the library defines no JSON representation for one. Conversion throws with a message saying so, and suggesting to mark the option internal or visible = false.
  • An option without a default is treated as required. The conversion leans towards requiring options even where they are not strictly required, so that anything validating against the schema can actually be deployed.
  • visible = "shallow" keeps the option but widens its sub-structure to a permissive schema -- useful for a large nested type an interface should not try to render in full.

Seeing it work

Some bigger examples live in the repository itself: lib/tests/services/ holds schemas for the Mastodon, PeerTube and Pixelfed NixOS modules.

lib/tests/module-schema.nix covers the type matrix option by option, and is the quickest reference for what a particular lib.types constructor turns into. Coverage summarizes the same ground in prose.