public record DatabaseConfiguration()

in modules/ingest-geoip/src/main/java/org/elasticsearch/ingest/geoip/direct/DatabaseConfiguration.java [41:396]


public record DatabaseConfiguration(String id, String name, Provider provider) implements Writeable, ToXContentObject {

    // id is a user selected signifier like 'my_domain_db'
    // name is the name of a file that can be downloaded (like 'GeoIP2-Domain')

    // a configuration will have a 'provider' like "maxmind", and that might have some more details,
    // for now, though the important thing is that the json has to have it even though we don't model it meaningfully in this class

    public DatabaseConfiguration {
        // these are invariants, not actual validation
        Objects.requireNonNull(id);
        Objects.requireNonNull(name);
        Objects.requireNonNull(provider);
    }

    /**
     * An alphanumeric, followed by 0-126 alphanumerics, dashes, or underscores. That is, 1-127 alphanumerics, dashes, or underscores,
     * but a leading dash or underscore isn't allowed (we're reserving leading dashes and underscores [and other odd characters] for
     * Elastic and the future).
     */
    private static final Pattern ID_PATTERN = Pattern.compile("\\p{Alnum}[_\\-\\p{Alnum}]{0,126}");

    public static final Set<String> MAXMIND_NAMES = Set.of(
        "GeoIP2-Anonymous-IP",
        "GeoIP2-City",
        "GeoIP2-Connection-Type",
        "GeoIP2-Country",
        "GeoIP2-Domain",
        "GeoIP2-Enterprise",
        "GeoIP2-ISP"

        // in order to prevent a conflict between the (ordinary) geoip downloader and the enterprise geoip downloader,
        // the enterprise geoip downloader is limited only to downloading the commercial files that the (ordinary) geoip downloader
        // doesn't support out of the box -- in the future if we would like to relax this constraint, then we'll need to resolve that
        // conflict at the same time.

        // "GeoLite2-ASN",
        // "GeoLite2-City",
        // "GeoLite2-Country"
    );

    public static final Set<String> IPINFO_NAMES = Set.of(
        // these file names are from https://ipinfo.io/developers/database-filename-reference
        "asn", // "Free IP to ASN"
        "country", // "Free IP to Country"
        // "country_asn" // "Free IP to Country + IP to ASN", not supported at present
        "standard_asn", // commercial "ASN"
        "standard_location", // commercial "IP Geolocation"
        "standard_privacy" // commercial "Privacy Detection" (sometimes "Anonymous IP")
    );

    private static final ParseField NAME = new ParseField("name");
    private static final ParseField MAXMIND = new ParseField(Maxmind.NAME);
    private static final ParseField IPINFO = new ParseField(Ipinfo.NAME);
    private static final ParseField WEB = new ParseField(Web.NAME);
    private static final ParseField LOCAL = new ParseField(Local.NAME);

    private static final ConstructingObjectParser<DatabaseConfiguration, String> PARSER = new ConstructingObjectParser<>(
        "database",
        false,
        (a, id) -> {
            String name = (String) a[0];
            Provider provider;

            // one and only one provider object must be present
            final long numNonNulls = Arrays.stream(a, 1, a.length).filter(Objects::nonNull).count();
            if (numNonNulls != 1) {
                throw new IllegalArgumentException("Exactly one provider object must be specified, but [" + numNonNulls + "] were found");
            }

            if (a[1] != null) {
                provider = (Maxmind) a[1];
            } else if (a[2] != null) {
                provider = (Ipinfo) a[2];
            } else if (a[3] != null) {
                provider = (Web) a[3];
            } else {
                provider = (Local) a[4];
            }
            return new DatabaseConfiguration(id, name, provider);
        }
    );

    static {
        PARSER.declareString(ConstructingObjectParser.constructorArg(), NAME);
        PARSER.declareObject(
            ConstructingObjectParser.optionalConstructorArg(),
            (parser, id) -> Maxmind.PARSER.apply(parser, null),
            MAXMIND
        );
        PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), (parser, id) -> Ipinfo.PARSER.apply(parser, null), IPINFO);
        PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), (parser, id) -> Web.PARSER.apply(parser, null), WEB);
        PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), (parser, id) -> Local.PARSER.apply(parser, null), LOCAL);
    }

    public DatabaseConfiguration(StreamInput in) throws IOException {
        this(in.readString(), in.readString(), readProvider(in));
    }

    private static Provider readProvider(StreamInput in) throws IOException {
        if (in.getTransportVersion().onOrAfter(TransportVersions.V_8_16_0)) {
            return in.readNamedWriteable(Provider.class);
        } else {
            // prior to the above version, everything was always a maxmind, so this half of the if is logical
            return new Maxmind(in.readString());
        }
    }

    public static DatabaseConfiguration parse(XContentParser parser, String id) {
        return PARSER.apply(parser, id);
    }

    @Override
    public void writeTo(StreamOutput out) throws IOException {
        out.writeString(id);
        out.writeString(name);
        if (out.getTransportVersion().onOrAfter(TransportVersions.V_8_16_0)) {
            out.writeNamedWriteable(provider);
        } else {
            if (provider instanceof Maxmind maxmind) {
                out.writeString(maxmind.accountId);
            } else {
                assert false : "non-maxmind DatabaseConfiguration.Provider [" + provider.getWriteableName() + "]";
            }
        }
    }

    @Override
    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
        builder.startObject();
        builder.field("name", name);
        builder.field(provider.getWriteableName(), provider);
        builder.endObject();
        return builder;
    }

    /**
     * An id is intended to be alphanumerics, dashes, and underscores (only), but we're reserving leading dashes and underscores for
     * ourselves in the future, that is, they're not for the ones that users can PUT.
     */
    static void validateId(String id) throws IllegalArgumentException {
        if (Strings.isNullOrEmpty(id)) {
            throw new IllegalArgumentException("invalid database configuration id [" + id + "]: must not be null or empty");
        }
        MetadataCreateIndexService.validateIndexOrAliasName(
            id,
            (id1, description) -> new IllegalArgumentException("invalid database configuration id [" + id1 + "]: " + description)
        );
        int byteCount = id.getBytes(StandardCharsets.UTF_8).length;
        if (byteCount > 127) {
            throw new IllegalArgumentException(
                "invalid database configuration id [" + id + "]: id is too long, (" + byteCount + " > " + 127 + ")"
            );
        }
        if (ID_PATTERN.matcher(id).matches() == false) {
            throw new IllegalArgumentException(
                "invalid database configuration id ["
                    + id
                    + "]: id doesn't match required rules (alphanumerics, dashes, and underscores, only)"
            );
        }
    }

    public ActionRequestValidationException validate() {
        ActionRequestValidationException err = new ActionRequestValidationException();

        // how do we cross the id validation divide here? or do we? it seems unfortunate to not invoke it at all.

        // name validation
        if (Strings.hasText(name) == false) {
            err.addValidationError("invalid name [" + name + "]: cannot be empty");
        }

        // provider-specific name validation
        if (provider instanceof Maxmind) {
            if (MAXMIND_NAMES.contains(name) == false) {
                err.addValidationError("invalid name [" + name + "]: must be a supported name ([" + MAXMIND_NAMES + "])");
            }
        }
        if (provider instanceof Ipinfo) {
            if (IPINFO_NAMES.contains(name) == false) {
                err.addValidationError("invalid name [" + name + "]: must be a supported name ([" + IPINFO_NAMES + "])");
            }
        }

        // important: the name must be unique across all configurations of this same type,
        // but we validate that in the cluster state update, not here.
        try {
            validateId(id);
        } catch (IllegalArgumentException e) {
            err.addValidationError(e.getMessage());
        }
        return err.validationErrors().isEmpty() ? null : err;
    }

    public boolean isReadOnly() {
        return provider.isReadOnly();
    }

    /**
      * A marker interface that all providers need to implement.
      */
    public interface Provider extends NamedWriteable, ToXContentObject {
        boolean isReadOnly();
    }

    public record Maxmind(String accountId) implements Provider {
        public static final String NAME = "maxmind";

        @Override
        public String getWriteableName() {
            return NAME;
        }

        public Maxmind {
            // this is an invariant, not actual validation
            Objects.requireNonNull(accountId);
        }

        private static final ParseField ACCOUNT_ID = new ParseField("account_id");

        private static final ConstructingObjectParser<Maxmind, Void> PARSER = new ConstructingObjectParser<>("maxmind", false, (a, id) -> {
            String accountId = (String) a[0];
            return new Maxmind(accountId);
        });

        static {
            PARSER.declareString(ConstructingObjectParser.constructorArg(), ACCOUNT_ID);
        }

        public Maxmind(StreamInput in) throws IOException {
            this(in.readString());
        }

        @Override
        public void writeTo(StreamOutput out) throws IOException {
            out.writeString(accountId);
        }

        @Override
        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
            builder.startObject();
            builder.field("account_id", accountId);
            builder.endObject();
            return builder;
        }

        @Override
        public boolean isReadOnly() {
            return false;
        }
    }

    public record Ipinfo() implements Provider {
        public static final String NAME = "ipinfo";

        // this'll become a ConstructingObjectParser once we accept the token (securely) in the json definition
        private static final ObjectParser<Ipinfo, Void> PARSER = new ObjectParser<>("ipinfo", Ipinfo::new);

        public Ipinfo(StreamInput in) throws IOException {
            this();
        }

        @Override
        public void writeTo(StreamOutput out) throws IOException {}

        @Override
        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
            builder.startObject();
            builder.endObject();
            return builder;
        }

        @Override
        public String getWriteableName() {
            return NAME;
        }

        @Override
        public boolean isReadOnly() {
            return false;
        }
    }

    public record Local(String type) implements Provider {
        public static final String NAME = "local";

        private static final ParseField TYPE = new ParseField("type");

        private static final ConstructingObjectParser<Local, Void> PARSER = new ConstructingObjectParser<>("database", false, (a, id) -> {
            String type = (String) a[0];
            return new Local(type);
        });

        static {
            PARSER.declareString(ConstructingObjectParser.constructorArg(), TYPE);
        }

        public Local(StreamInput in) throws IOException {
            this(in.readString());
        }

        @Override
        public void writeTo(StreamOutput out) throws IOException {
            out.writeString(type);
        }

        @Override
        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
            builder.startObject();
            builder.field("type", type);
            builder.endObject();
            return builder;
        }

        @Override
        public String getWriteableName() {
            return NAME;
        }

        @Override
        public boolean isReadOnly() {
            return true;
        }
    }

    public record Web() implements Provider {
        public static final String NAME = "web";

        private static final ObjectParser<Web, Void> PARSER = new ObjectParser<>("database", Web::new);

        public Web(StreamInput in) throws IOException {
            this();
        }

        @Override
        public void writeTo(StreamOutput out) throws IOException {}

        @Override
        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
            builder.startObject();
            builder.endObject();
            return builder;
        }

        @Override
        public String getWriteableName() {
            return NAME;
        }

        @Override
        public boolean isReadOnly() {
            return true;
        }
    }
}