swimos / swim

Full stack application platform for building stateful microservices, streaming APIs, and real-time UIs
https://www.swimos.org
Apache License 2.0
488 stars 41 forks source link

Configuration annotation processor #109

Closed jcustenborder closed 1 year ago

jcustenborder commented 1 year ago

This is a WIP for adding configuration validation and generation to swim. The idea being that we create simple configuration interfaces and an annotation processor fills in the implementation while extracting documentation.

For example this is a configuration class.

@Config
@Tag("exampleConfig")
public interface ExampleConfig extends Configurable {
  /**
   * This is the hostname that the adapter should connect to
   *
   * @return
   */
  String hostName();

  /**
   * Port to connect to
   *
   * @return
   */
  @ValidPort()
  int port();

  /**
   * This is a port that is limited to 3000 or 5000
   *
   * @return
   */
  @ValidPort(min = 3000, max = 5000)
  int limitedPort();

  @ValidPort
  default int portWithDefault() {
    return 8080;
  }

  /**
   * This is an example that contains multiple paragraphs.
   *
   * <p>This should be the start of the second paragraph.</p>
   * <p>This should be the start of the third paragraph.</p>
   * @return
   */
  Map<String, String> additionalProperties();

  Value relaySchema();

  @Ignore
  default int weirdCalculation() {
    return this.port() + this.limitedPort();
  }

  List<String> elements();

}

This is an adapter and it's configuration.

/**
 * This is an example adapter that is used to retrieve data from one system and write it to the swim runtime.
 *
 * <p>This is some additional information about this adapter that should show up in the documentation</p>
 * <p>This is some additional information about this adapter that should show up in the documentation</p>
 * <p>This is some additional information about this adapter that should show up in the documentation</p>
 */
@Adapter(
    configuration = ExampleConfig.class,
    displayName = "Example Adapter",
    iconGalleryName = "swim-marlin-logo-black.svg",
    iconGalleryType = "image/svg+xml",
    iconSmallName = "swim-marlin-logo-black.svg",
    iconSmallType = "image/svg+xml",
    iconLargeName = "swim-marlin-logo-black.svg",
    iconLargeType = "image/svg+xml"
)
public class ExampleAdapter {

}

The annotation processor reads this to extract documentation and generate configuration classes for validating the configuration. The idea is minimizing the code required for configs while getting valuable documentation.

A generated implementation of the config class looks like this.

@Tag(value = "exampleConfig")
public class ExampleConfigImpl extends AbstractConfigurable implements ExampleConfig {

    @Override()
    public <T> Output<T> debug(Output<T> output) {
        DebugFormatter formatter = DebugFormatter.of(ExampleConfig.class);
        formatter.add("hostName", this.hostName);
        formatter.add("port", this.port);
        formatter.add("limitedPort", this.limitedPort);
        formatter.add("portWithDefault", this.portWithDefault);
        formatter.add("additionalProperties", this.additionalProperties);
        formatter.add("relaySchema", this.relaySchema);
        formatter.add("elements", this.elements);
        return formatter.to(output);
    }

    /**
     * Method is used to validate the supplied configuration.
     */
    @Override()
    public void validate() throws ConfigException {
        List errors = new ArrayList();
        ValidPort.Validator validPortValidator = new ValidPort.Validator();
        ValidPort portValidPort = attribute(ExampleConfig.class, "port", ValidPort.class);
        validPortValidator.validate(errors, portValidPort, "port", this.port);
        ValidPort limitedPortValidPort = attribute(ExampleConfig.class, "limitedPort", ValidPort.class);
        validPortValidator.validate(errors, limitedPortValidPort, "limitedPort", this.limitedPort);
        ValidPort portWithDefaultValidPort = attribute(ExampleConfig.class, "portWithDefault", ValidPort.class);
        validPortValidator.validate(errors, portWithDefaultValidPort, "portWithDefault", this.portWithDefault);
        if (!errors.isEmpty()) {
            throw new ConfigException(errors);
        }
    }

    @Override()
    public void configure(Value value) {
        if (!value.containsKey("portWithDefault")) {
            this.portWithDefault = ExampleConfig.super.portWithDefault();
        }
    }

    public static final Form<ExampleConfigImpl> FORM = Form.forClass(ExampleConfigImpl.class);

    public static ExampleConfig load(Value value) {
        ExampleConfig result = ExampleConfigImpl.FORM.cast(value);
        result.configure(value);
        return result;
    }

    /**
     * This is the hostname that the adapter should connect to
     *
     * @return
     */
    protected java.lang.String hostName;

    /**
     * This is the hostname that the adapter should connect to
     *
     * @return
     */
    public java.lang.String hostName() {
        return this.hostName;
    }

    /**
     * Port to connect to
     *
     * @return
     */
    protected int port;

    /**
     * Port to connect to
     *
     * @return
     */
    public int port() {
        return this.port;
    }

    /**
     * This is a port that is limited to 3000 or 5000
     *
     * @return
     */
    protected int limitedPort;

    /**
     * This is a port that is limited to 3000 or 5000
     *
     * @return
     */
    public int limitedPort() {
        return this.limitedPort;
    }

    protected int portWithDefault;

    public int portWithDefault() {
        return this.portWithDefault;
    }

    /**
     * This is an example that contains multiple paragraphs.
     *
     *  <p>This should be the start of the second paragraph.</p>
     *  <p>This should be the start of the third paragraph.</p>
     *
     * @return
     */
    protected java.util.Map<java.lang.String, java.lang.String> additionalProperties;

    /**
     * This is an example that contains multiple paragraphs.
     *
     *  <p>This should be the start of the second paragraph.</p>
     *  <p>This should be the start of the third paragraph.</p>
     *
     * @return
     */
    public java.util.Map<java.lang.String, java.lang.String> additionalProperties() {
        return this.additionalProperties;
    }

    protected swim.structure.Value relaySchema;

    public swim.structure.Value relaySchema() {
        return this.relaySchema;
    }

    protected java.util.List<java.lang.String> elements;

    public java.util.List<java.lang.String> elements() {
        return this.elements;
    }
}

The extracted documentation looks like this. Given this is now generated we can use it to generate some usage documentation.

{
    "@Manifest"
:
    null, "adapters"
:
    [{
        "@AdapterElement": null,
        "documentation": "This is an example adapter that is used to retrieve data from one system and write it to the swim runtime.\n\n <p>This is some additional information about this adapter that should show up in the documentation</p>\n <p>This is some additional information about this adapter that should show up in the documentation</p>\n <p>This is some additional information about this adapter that should show up in the documentation</p>",
        "adapterClass": "processor.ExampleAdapter",
        "configuration": {
            "@ConfigurationElement": null,
            "documentation": null,
            "configurationItems": [{
                "@ConfigurationItemElement": null,
                "documentation": "This is the hostname that the adapter should connect to",
                "name": "hostName",
                "type": "java.lang.String",
                "validations": []
            }, {
                "@ConfigurationItemElement": null,
                "documentation": "Port to connect to",
                "name": "port",
                "type": "int",
                "validations": []
            }, {
                "@ConfigurationItemElement": null,
                "documentation": "This is a port that is limited to 3000 or 5000",
                "name": "limitedPort",
                "type": "int",
                "validations": []
            }, {
                "@ConfigurationItemElement": null,
                "documentation": null,
                "name": "portWithDefault",
                "type": "int",
                "validations": []
            }, {
                "@ConfigurationItemElement": null,
                "documentation": "This is an example that contains multiple paragraphs.\n\n <p>This should be the start of the second paragraph.</p>\n <p>This should be the start of the third paragraph.</p>",
                "name": "additionalProperties",
                "type": "java.util.Map<java.lang.String,java.lang.String>",
                "validations": []
            }, {
                "@ConfigurationItemElement": null,
                "documentation": null,
                "name": "relaySchema",
                "type": "swim.structure.Value",
                "validations": []
            }, {
                "@ConfigurationItemElement": null,
                "documentation": null,
                "name": "elements",
                "type": "java.util.List<java.lang.String>",
                "validations": []
            }]
        },
        "displayName": "Example Adapter",
        "smallIcon": null,
        "largeIcon": null,
        "galleryIcon": null
    }]
}

Using the config class is as easy as this:

    Value input = Record.create().attr("exampleConfig")
        .slot("port", 1025)
        .slot("limitedPort", 3000)
        .slot("hostName", "localhost");
    ExampleConfig exampleConfig = ExampleConfigImpl.load(input);
    exampleConfig.validate();

In the case there are problems with the configuration it will throw an exception that looks similar to this. An exception will be generated highlighting all of the parameters that did not pass validation.

swim.config.ConfigException: 1 configuration error(s) were found:
    port: Value must be between 1024 and 65535.
jcustenborder commented 1 year ago

I refactored the code to no longer use reflection. The annotations are now part of the annotation processor and only require a compile dependency. Now the validators directly emit code which is added into the Impl class.

@Tag(value = "exampleConfig")
public class ExampleConfigImpl implements ExampleConfig {

    @Override()
    public <T> Output<T> debug(Output<T> output) {
        DebugFormatter formatter = DebugFormatter.of(ExampleConfig.class);
        formatter.add("hostName", this.hostName);
        formatter.add("port", this.port);
        formatter.add("limitedPort", this.limitedPort);
        formatter.add("portWithDefault", this.portWithDefault);
        formatter.add("additionalProperties", this.additionalProperties);
        formatter.add("relaySchema", this.relaySchema);
        formatter.add("elements", this.elements);
        formatter.add("testLocation", this.testLocation);
        return formatter.to(output);
    }

    /**
     * Method is used to validate the supplied configuration.
     */
    @Override()
    public void validate() throws ConfigException {
        List<ConfigError> errors = new ArrayList<ConfigError>();
        if (this.hostName == null) {
            errors.add(new ConfigError("hostName", this.hostName, "value cannot be null."));
        }
        if (this.port < 1024 || this.port > 65535) {
            errors.add(new ConfigError("port", this.port, "value must be between 1024 and 65535."));
        }
        if (this.limitedPort < 3000 || this.limitedPort > 5000) {
            errors.add(new ConfigError("limitedPort", this.limitedPort, "value must be between 3000 and 5000."));
        }
        if (this.portWithDefault < 1024 || this.portWithDefault > 65535) {
            errors.add(new ConfigError("portWithDefault", this.portWithDefault, "value must be between 1024 and 65535."));
        }
        if (this.additionalProperties == null) {
            errors.add(new ConfigError("additionalProperties", this.additionalProperties, "value cannot be null."));
        } else {
            if (!this.additionalProperties.containsKey("batch.size")) {
                errors.add(new ConfigError("additionalProperties", this.additionalProperties, "value must contain 'batch.size'"));
            }
        }
        if (!errors.isEmpty()) {
            throw new ConfigException(errors);
        }
    }

    @Override()
    public void configure(Value value) {
        if (!value.containsKey("portWithDefault")) {
            this.portWithDefault = ExampleConfig.super.portWithDefault();
        }
        if (!value.containsKey("testLocation")) {
            this.testLocation = ExampleConfig.super.testLocation();
        }
    }

    public static final Form<ExampleConfigImpl> FORM = Form.forClass(ExampleConfigImpl.class);

    public static ExampleConfig load(Value value) {
        ExampleConfig result = ExampleConfigImpl.FORM.cast(value);
        result.configure(value);
        return result;
    }

    /**
     * <p>This is the hostname that the adapter should connect to</p>
     *
     * @return
     */
    protected java.lang.String hostName;

    /**
     * <p>This is the hostname that the adapter should connect to</p>
     *
     * @return
     */
    public java.lang.String hostName() {
        return this.hostName;
    }

    /**
     * <p>Port to connect to</p>
     *
     * @return
     */
    protected int port;

    /**
     * <p>Port to connect to</p>
     *
     * @return
     */
    public int port() {
        return this.port;
    }

    /**
     * <p>This is a port that is limited to 3000 or 5000</p>
     *
     * @return
     */
    protected int limitedPort;

    /**
     * <p>This is a port that is limited to 3000 or 5000</p>
     *
     * @return
     */
    public int limitedPort() {
        return this.limitedPort;
    }

    /**
     * <p>This parameter will use a default value if none is specified.</p>
     *
     * @return
     */
    protected int portWithDefault;

    /**
     * <p>This parameter will use a default value if none is specified.</p>
     *
     * @return
     */
    public int portWithDefault() {
        return this.portWithDefault;
    }

    /**
     * <p>This is an example that contains multiple paragraphs.</p>
     *  <p>This should be the start of the second paragraph.</p>
     *  <p>This should be the start of the third paragraph.</p>
     *
     * @return
     */
    protected java.util.Map<java.lang.String, java.lang.String> additionalProperties;

    /**
     * <p>This is an example that contains multiple paragraphs.</p>
     *  <p>This should be the start of the second paragraph.</p>
     *  <p>This should be the start of the third paragraph.</p>
     *
     * @return
     */
    public java.util.Map<java.lang.String, java.lang.String> additionalProperties() {
        return this.additionalProperties;
    }

    /**
     * <p class='code-recon'>
     *  {@literal @}command($value) {
     *  nodeUri: {
     *  "/dynamic/",
     *  $val + 1
     *  },
     *  laneUri: "unused"
     *  value:
     *  }
     *  }
     *  </p>
     *
     * @return
     */
    protected swim.structure.Value relaySchema;

    /**
     * <p class='code-recon'>
     *  {@literal @}command($value) {
     *  nodeUri: {
     *  "/dynamic/",
     *  $val + 1
     *  },
     *  laneUri: "unused"
     *  value:
     *  }
     *  }
     *  </p>
     *
     * @return
     */
    public swim.structure.Value relaySchema() {
        return this.relaySchema;
    }

    protected java.util.List<java.lang.String> elements;

    public java.util.List<java.lang.String> elements() {
        return this.elements;
    }

    /**
     * <p>This example uses an enum and recommendations of values will be provided.</p>
     *
     * @return
     */
    protected processor.TestLocation testLocation;

    /**
     * <p>This example uses an enum and recommendations of values will be provided.</p>
     *
     * @return
     */
    public processor.TestLocation testLocation() {
        return this.testLocation;
    }
}
ajay-gov commented 1 year ago

@jcustenborder please look at comments in pr #119