How to merge attribute sets prior to generating toml/json equivalents?

I am writing a new NixOS module and this particular set of software relies on a toml and json for configuring application. I want to conditionally update the toml file to include the json file if services.new-service.jsonFile is present.

If jsonFile is present, the toml file should look like this:

#/nix/store/....-settings.toml
[other]
a: 1
[schema]
file: file:///nix/store/...-schema.json

Example nix configuration using services.new-service:

# flake.nix
{
  ...
}:
{
  services.new-service = {
    settings = { # transformed to settings.toml
      a = 1;
      b = 2;
    };
    schema = { # transformed to schema.json
      x = "hello";
      y = "world";
    };
  };
}

This is my naive approach so far:

# default.nix
{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.services.new-service;
  tFormat = pkgs.formats.toml { };
  jFormat = pkgs.formats.json { };
in
  imports [ ./new-service.nix ];
  options.services.new-service = {
    settings = lib.mkOption {
      inherit (tFormat) type;
    };
    schema = lib.mkOption {
      inherit (jFormat) type;
    };
};
# new-service.nix
{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.services.new-service;
  cfgFormat = pkgs.formats.toml { };
  schemaFormat = pkgs.formats.json { };
  cfgFile = cfgFormat.generate "settings.toml" (cfg.settings // lib.optionalAttrs (cfg.jsonFile != null) { schema = { file = "file://${schemaFormat.generate "schema.json" cfg.jsonFile}"; }; };);
in
  ...

It appears syntactically correct, but flake rebuild fails to evaluate due to:

...
error:
       … while calling the 'seq' builtin
         at /nix/store/wzcf74b2g5c3qfg4rcswcz28ngiwzzp0-source/lib/modules.nix:403:18:
          402|         options = checked options;
          403|         config = checked (removeAttrs config [ "_module" ]);
             |                  ^
          404|         _module = checked (config._module);

       … while evaluating a branch condition
         at /nix/store/wzcf74b2g5c3qfg4rcswcz28ngiwzzp0-source/lib/modules.nix:306:9:
          305|       checkUnmatched =
          306|         if config._module.check && config._module.freeformType == null && merged.unmatchedDefns != [ ] then
             |         ^
          307|           let

       (stack trace truncated; use '--show-trace' to show the full, detailed trace)

       error: function 'mkOption' called with unexpected argument 'configuration'
       at /nix/store/wzcf74b2g5c3qfg4rcswcz28ngiwzzp0-source/lib/options.nix:140:5:
          139|   mkOption =
          140|     {
             |     ^
          141|       default ? null,

Which I suspect is due to:

cfg.settings // lib.optionalAttrs (cfg.jsonFile != null) ...

Which I suppose makes sense, I am trying to merge the attribute set { schema = { file = "file://${schemaFormat.generate "schema.json" cfg.jsonFile}"; }; } with lib.mkOption. But I am not entirely sure where to go from here.

Appreciate any help here!

So I had multiple issues:

  • unbalanced parentheses were causing the evaluation error
  • discovered lib.mkOption has apply option which establishes what I want and use lib.recursiveUpdate
...
  options.services.new-service = {
    settings = lib.mkOption {
      inherit (tFormat) type;
      apply =
        c:
        lib.recursiveUpdate c {
          identity = {
            schemas = lib.mapAttrsToList (key: value: {
                id = "${key}";
                url = "file://${schemaFormat.generate "${key}-schema.json" value}";
              }) cfg.schema;
          };
        };
    };
...

caveat: it does appear to clobber the services.new-service.settings.identity.schema.file option if it is set by user.

What you’re doing is definitely not the expected way to do this, but I can’t quite figure out what the end result you want is. If jsonFile is present, then what goes into the JSON file that ends up referenced in the TOML file — the contents of jsonFile, or the contents of schema? If the former, what happens to the contents of schema — do you want it to be ignored? Merged with the contents of jsonFile? If the latter, why is jsonFile not a boolean? I’m confused.

1 Like

hi @rhendric:

If jsonFile is present, then what goes into the JSON file that ends up referenced in the TOML file — the contents of jsonFile, or the contents of schema?

The jsonFile is separate from the toml file. The attribute set of jsonFile will be passed into pkgs.formats.json ... and the file output referenced in the toml file. In this case (in toml), as schema.file: file:///nix/store....-schema.json <file:///nix/store…-schema.json%60>.

In this example:

what is the output you want?

Your comment in that example says schema is transformed to schema.json, but you also want jsonFile, if present, to be transformed to schema.json? My question is how do you want those two things to interact? Are there two distinct files that you’ve named schema.json or something?

In other words, what output file(s) should this produce?

  services.new-service = {
    schema = {
      x = "hello";
      y = "world";
    };
    jsonFile = {
      x = "conflict";
      z = "new";
    };
  };

Looking back at example, I see why there’s confusion. Sorry about that.

Anyways, as for your question:

  services.new-service = {
    settings = { # transformed to settings.toml
      a = 1;
      b = 2;
    };
    schema = { # transformed to schema.json
      x = "hello";
      y = "world";
    };
  };

This will output 2 files:

# /nix/store/...-settings.toml
b: 2

[schema]
file: file:///nix/store/...-schema.json
# /nix/store/...-schema.json
{
  "x" = "hello";
  "y" = "world";
}

When I mentioned the settings would get clobbered, I was referring to this scenario:

# configuration.nix
  services.new-service = {
    settings = { # transformed to settings.toml
       a = 1;
       b = 2;
       schema = {
         file = "file://value/here/gets/clobbered/by/apply";
       };
    };
    schema = { # transformed to schema.json
      x = "hello";
      y = "world";
    };
  };

Which will again output 2 files:

# /nix/store/...-settings.toml
b: 2

[schema]
file: file:///nix/store/...-schema.json # notice how `file://value/here/gets/clobbered/by/apply` <file://value/here/gets/clobbered/by/apply%60> gets clobbered
# /nix/store/...-schema.json
{
  "x" = "hello";
  "y" = "world";
}

Hope that makes sense.

Okay, but what happens when jsonFile is defined? You said:

So, again, what do you want this to produce?

  services.new-service = {
    schema = {
      x = "hello";
      y = "world";
    };
    jsonFile = {
      x = "conflict";
      z = "new";
    };
  };
  services.new-service = {
    schema = {
      x = "hello";
      y = "world";
    };
    jsonFile = {
      x = "conflict";
      z = "new";
    };
  };

That was a mistake. jsonFile is supposed to be schema. In this case, jsonFile is ignored or would produce eval error since no options exist.

# /nix/store/...-settings.toml
[schema]
file: file:///nix/store..-schema.json
# /nix/store/...-schema.json
{
  "x": "hello";
  "y": "world";
}

You can use freeformType for the toml option in this case, that allows you to define a submodule option that defines (by default) the stringified json file path, but still allows overrides. There’s a million examples in nixpkgs.

Thanks, that clears things up.

You can use freeformType, as @waffle8946 points out, but you don’t have to for your case. Here’s a self-contained example:

{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.services.new-service;
  toml = pkgs.formats.toml { };
  json = pkgs.formats.json { };
in
{
  options = {
    services.new-service = {
      settings = lib.mkOption {
        inherit (toml) type;
      };
      schema = lib.mkOption {
        inherit (json) type;
        default = null;
      };
    };

    # This is a stand-in for whatever NixOS option eventually receives your
    # config file. Maybe it's part of a systemd service definition. Maybe it's
    # an environment variable. Hopefully it's not something like
    # environment.etc.new-service, but maybe it's that. The point is, you
    # aren't including this option definition in your module. It's just here to
    # make this a self-contained test case that anyone can run with
    # lib.evalModules.
    someOtherThingThatUsesTheFinalConfigFile = lib.mkOption {
      type = lib.types.path;
    };
  };

  config = {
    services.new-service.settings = lib.mkIf (cfg.schema != null) {
      # You can remove the lib.mkDefault to make collisions errors.
      # Or you can replace it with lib.mkForce to keep the clobbering
      # behavior you've described.
      # As-is, the user's schema.file will override this.
      schema.file = lib.mkDefault "file://${json.generate "schema.json" cfg.schema}";
    };

    # Replace this with the actual thing that uses the final config file!
    someOtherThingThatUsesTheFinalConfigFile =
      toml.generate "settings.toml" cfg.settings;
  };
}
1 Like

I suggested freeform type since they mentioned other users and they may want to document the option :slight_smile:

2 Likes