Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): A feature flag system to handle version compatibility #1002

Merged
merged 12 commits into from
Dec 18, 2023
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ truth = "1.1.5"
# shared
examination-api = { module = "net.kyori:examination-api", version.ref = "examination" }
examination-string = { module = "net.kyori:examination-string", version.ref = "examination" }
option = { module = "net.kyori:option", version = "1.0.0" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
guava-testlib = { module = "com.google.guava:guava-testlib", version.ref = "guava" }
jetbrainsAnnotations = "org.jetbrains:annotations:24.1.0"
Expand Down
1 change: 1 addition & 0 deletions nbt/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
}

dependencies {
api(libs.option)
api(libs.examination.api)
api(libs.examination.string)
compileOnlyApi(libs.jetbrainsAnnotations)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.TranslationArgument;
import net.kyori.adventure.text.serializer.json.JSONOptions;
import net.kyori.option.OptionState;
import org.jetbrains.annotations.Nullable;

import static net.kyori.adventure.text.serializer.json.JSONComponentConstants.EXTRA;
Expand All @@ -76,13 +78,15 @@ final class ComponentSerializerImpl extends TypeAdapter<Component> {
static final Type COMPONENT_LIST_TYPE = new TypeToken<List<Component>>() {}.getType();
static final Type TRANSLATABLE_ARGUMENT_LIST_TYPE = new TypeToken<List<TranslationArgument>>() {}.getType();

static TypeAdapter<Component> create(final Gson gson) {
return new ComponentSerializerImpl(gson).nullSafe();
static TypeAdapter<Component> create(final OptionState features, final Gson gson) {
return new ComponentSerializerImpl(features.value(JSONOptions.EMIT_COMPACT_TEXT_COMPONENT), gson).nullSafe();
}

private final boolean emitCompactTextComponent;
private final Gson gson;

private ComponentSerializerImpl(final Gson gson) {
private ComponentSerializerImpl(final boolean emitCompactTextComponent, final Gson gson) {
this.emitCompactTextComponent = emitCompactTextComponent;
this.gson = gson;
}

Expand Down Expand Up @@ -232,6 +236,16 @@ private static <C extends NBTComponent<C, B>, B extends NBTComponentBuilder<C, B

@Override
public void write(final JsonWriter out, final Component value) throws IOException {
if (
value instanceof TextComponent
&& value.children().isEmpty()
&& !value.hasStyling()
&& this.emitCompactTextComponent
) {
out.value(((TextComponent) value).content());
return;
}

out.beginObject();

if (value.hasStyling()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
import net.kyori.adventure.builder.AbstractBuilder;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.json.JSONComponentSerializer;
import net.kyori.adventure.text.serializer.json.JSONOptions;
import net.kyori.adventure.util.Buildable;
import net.kyori.adventure.util.PlatformAPI;
import net.kyori.option.OptionState;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand Down Expand Up @@ -122,14 +124,22 @@ static Builder builder() {
* @since 4.0.0
*/
interface Builder extends AbstractBuilder<GsonComponentSerializer>, Buildable.Builder<GsonComponentSerializer>, JSONComponentSerializer.Builder {
@Override
@NotNull Builder options(final @NotNull OptionState flags);

@Override
@NotNull Builder editOptions(final @NotNull Consumer<OptionState.Builder> optionEditor);

/**
* Sets that the serializer should downsample hex colors to named colors.
*
* @return this builder
* @since 4.0.0
*/
@Override
@NotNull Builder downsampleColors();
default @NotNull Builder downsampleColors() {
return this.editOptions(features -> features.value(JSONOptions.EMIT_RGB, false));
}

/**
* Sets a serializer that will be used to interpret legacy hover event {@code value} payloads.
Expand All @@ -154,8 +164,11 @@ interface Builder extends AbstractBuilder<GsonComponentSerializer>, Buildable.Bu
*
* @since 4.0.0
*/
@Deprecated
@Override
@NotNull Builder emitLegacyHoverEvent();
default @NotNull Builder emitLegacyHoverEvent() {
return this.editOptions(b -> b.value(JSONOptions.EMIT_HOVER_EVENT_TYPE, JSONOptions.HoverEventValueMode.BOTH));
}

/**
* Builds the serializer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.json.JSONOptions;
import net.kyori.adventure.util.Services;
import net.kyori.option.OptionState;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import static java.util.Objects.requireNonNull;

final class GsonComponentSerializerImpl implements GsonComponentSerializer {
private static final Optional<Provider> SERVICE = Services.service(Provider.class);
static final Consumer<Builder> BUILDER = SERVICE
Expand All @@ -46,24 +50,22 @@ final class GsonComponentSerializerImpl implements GsonComponentSerializer {
static final class Instances {
static final GsonComponentSerializer INSTANCE = SERVICE
.map(Provider::gson)
.orElseGet(() -> new GsonComponentSerializerImpl(false, null, false));
.orElseGet(() -> new GsonComponentSerializerImpl(JSONOptions.byDataVersion(), null));
static final GsonComponentSerializer LEGACY_INSTANCE = SERVICE
.map(Provider::gsonLegacy)
.orElseGet(() -> new GsonComponentSerializerImpl(true, null, true));
.orElseGet(() -> new GsonComponentSerializerImpl(JSONOptions.byDataVersion().at(2525 /* just before 1.16 */), null));
}

private final Gson serializer;
private final UnaryOperator<GsonBuilder> populator;
private final boolean downsampleColor;
private final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHoverSerializer;
private final boolean emitLegacyHover;
private final OptionState flags;

GsonComponentSerializerImpl(final boolean downsampleColor, final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHoverSerializer, final boolean emitLegacyHover) {
this.downsampleColor = downsampleColor;
GsonComponentSerializerImpl(final OptionState flags, final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHoverSerializer) {
this.flags = flags;
this.legacyHoverSerializer = legacyHoverSerializer;
this.emitLegacyHover = emitLegacyHover;
this.populator = builder -> {
builder.registerTypeAdapterFactory(new SerializerFactory(downsampleColor, legacyHoverSerializer, emitLegacyHover));
builder.registerTypeAdapterFactory(new SerializerFactory(flags, legacyHoverSerializer));
return builder;
};
this.serializer = this.populator.apply(
Expand Down Expand Up @@ -120,46 +122,43 @@ static final class Instances {
}

static final class BuilderImpl implements Builder {
private boolean downsampleColor = false;
private OptionState flags = JSONOptions.byDataVersion(); // latest
private net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHoverSerializer;
private boolean emitLegacyHover = false;

BuilderImpl() {
BUILDER.accept(this); // let service provider touch the builder before anybody else touches it
}

BuilderImpl(final GsonComponentSerializerImpl serializer) {
this();
this.downsampleColor = serializer.downsampleColor;
this.emitLegacyHover = serializer.emitLegacyHover;
this.flags = serializer.flags;
this.legacyHoverSerializer = serializer.legacyHoverSerializer;
}

@Override
public @NotNull Builder downsampleColors() {
this.downsampleColor = true;
public @NotNull Builder options(final @NotNull OptionState flags) {
this.flags = requireNonNull(flags, "flags");
return this;
}

@Override
public @NotNull Builder legacyHoverEventSerializer(final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer serializer) {
this.legacyHoverSerializer = serializer;
public @NotNull Builder editOptions(final @NotNull Consumer<OptionState.Builder> optionEditor) {
final OptionState.Builder builder = OptionState.optionState()
.values(this.flags);
requireNonNull(optionEditor, "flagEditor").accept(builder);
this.flags = builder.build();
return this;
}

@Override
public @NotNull Builder emitLegacyHoverEvent() {
this.emitLegacyHover = true;
public @NotNull Builder legacyHoverEventSerializer(final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer serializer) {
this.legacyHoverSerializer = serializer;
return this;
}

@Override
public @NotNull GsonComponentSerializer build() {
if (this.legacyHoverSerializer == null) {
return this.downsampleColor ? Instances.LEGACY_INSTANCE : Instances.INSTANCE;
} else {
return new GsonComponentSerializerImpl(this.downsampleColor, this.legacyHoverSerializer, this.emitLegacyHover);
}
return new GsonComponentSerializerImpl(this.flags, this.legacyHoverSerializer);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.json.JSONOptions;
import net.kyori.option.OptionState;
import org.jetbrains.annotations.Nullable;

final class SerializerFactory implements TypeAdapterFactory {
Expand All @@ -55,26 +57,24 @@ final class SerializerFactory implements TypeAdapterFactory {
static final Class<UUID> UUID_TYPE = UUID.class;
static final Class<TranslationArgument> TRANSLATION_ARGUMENT_TYPE = TranslationArgument.class;

private final boolean downsampleColors;
private final OptionState features;
private final net.kyori.adventure.text.serializer.json.LegacyHoverEventSerializer legacyHoverSerializer;
private final boolean emitLegacyHover;

SerializerFactory(final boolean downsampleColors, final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHoverSerializer, final boolean emitLegacyHover) {
this.downsampleColors = downsampleColors;
SerializerFactory(final OptionState features, final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHoverSerializer) {
this.features = features;
this.legacyHoverSerializer = legacyHoverSerializer;
this.emitLegacyHover = emitLegacyHover;
}

@Override
@SuppressWarnings("unchecked")
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> type) {
final Class<? super T> rawType = type.getRawType();
if (COMPONENT_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) ComponentSerializerImpl.create(gson);
return (TypeAdapter<T>) ComponentSerializerImpl.create(this.features, gson);
} else if (KEY_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) KeySerializer.INSTANCE;
} else if (STYLE_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) StyleSerializer.create(this.legacyHoverSerializer, this.emitLegacyHover, gson);
return (TypeAdapter<T>) StyleSerializer.create(this.legacyHoverSerializer, this.features, gson);
} else if (CLICK_ACTION_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) ClickEventActionSerializer.INSTANCE;
} else if (HOVER_ACTION_TYPE.isAssignableFrom(rawType)) {
Expand All @@ -86,13 +86,13 @@ public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> type) {
} else if (COLOR_WRAPPER_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) TextColorWrapper.Serializer.INSTANCE;
} else if (COLOR_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) (this.downsampleColors ? TextColorSerializer.DOWNSAMPLE_COLOR : TextColorSerializer.INSTANCE);
return (TypeAdapter<T>) (this.features.value(JSONOptions.EMIT_RGB) ? TextColorSerializer.INSTANCE : TextColorSerializer.DOWNSAMPLE_COLOR);
} else if (TEXT_DECORATION_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) TextDecorationSerializer.INSTANCE;
} else if (BLOCK_NBT_POS_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) BlockNBTComponentPosSerializer.INSTANCE;
} else if (UUID_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) UUIDSerializer.INSTANCE;
return (TypeAdapter<T>) UUIDSerializer.uuidSerializer(this.features);
} else if (TRANSLATION_ARGUMENT_TYPE.isAssignableFrom(rawType)) {
return (TypeAdapter<T>) TranslationArgumentSerializer.create(gson);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public void write(final JsonWriter out, final HoverEvent.ShowEntity value) throw
this.gson.toJson(value.type(), SerializerFactory.KEY_TYPE, out);

out.name(SHOW_ENTITY_ID);
out.value(value.id().toString());
this.gson.toJson(value.id(), SerializerFactory.UUID_TYPE, out);

final @Nullable Component name = value.name();
if (name != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.json.JSONOptions;
import net.kyori.adventure.util.Codec;
import net.kyori.option.OptionState;
import org.jetbrains.annotations.Nullable;

import static net.kyori.adventure.text.serializer.json.JSONComponentConstants.CLICK_EVENT;
Expand Down Expand Up @@ -80,17 +82,34 @@ final class StyleSerializer extends TypeAdapter<Style> {
}
}

static TypeAdapter<Style> create(final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHover, final boolean emitLegacyHover, final Gson gson) {
return new StyleSerializer(legacyHover, emitLegacyHover, gson).nullSafe();
static TypeAdapter<Style> create(final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHover, final OptionState features, final Gson gson) {
final JSONOptions.HoverEventValueMode hoverMode = features.value(JSONOptions.EMIT_HOVER_EVENT_TYPE);
return new StyleSerializer(
legacyHover,
hoverMode == JSONOptions.HoverEventValueMode.LEGACY_ONLY || hoverMode == JSONOptions.HoverEventValueMode.BOTH,
hoverMode == JSONOptions.HoverEventValueMode.MODERN_ONLY || hoverMode == JSONOptions.HoverEventValueMode.BOTH,
features.value(JSONOptions.VALIDATE_STRICT_EVENTS),
gson
).nullSafe();
}

private final net.kyori.adventure.text.serializer.json.LegacyHoverEventSerializer legacyHover;
private final boolean emitLegacyHover;
private final boolean emitModernHover;
private final boolean strictEventValues;
private final Gson gson;

private StyleSerializer(final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHover, final boolean emitLegacyHover, final Gson gson) {
private StyleSerializer(
final net.kyori.adventure.text.serializer.json.@Nullable LegacyHoverEventSerializer legacyHover,
final boolean emitLegacyHover,
final boolean emitModernHover,
final boolean strictEventValues,
final Gson gson
) {
this.legacyHover = legacyHover;
this.emitLegacyHover = emitLegacyHover;
this.emitModernHover = emitModernHover;
this.strictEventValues = strictEventValues;
this.gson = gson;
}

Expand Down Expand Up @@ -123,6 +142,9 @@ public Style read(final JsonReader in) throws IOException {
if (clickEventField.equals(CLICK_EVENT_ACTION)) {
action = this.gson.fromJson(in, SerializerFactory.CLICK_ACTION_TYPE);
} else if (clickEventField.equals(CLICK_EVENT_VALUE)) {
if (in.peek() == JsonToken.NULL && this.strictEventValues) {
throw ComponentSerializerImpl.notSureHowToDeserialize(CLICK_EVENT_VALUE);
}
value = in.peek() == JsonToken.NULL ? null : in.nextString();
} else {
in.skipValue();
Expand All @@ -148,6 +170,9 @@ public Style read(final JsonReader in) throws IOException {
if (hoverEventObject.has(HOVER_EVENT_CONTENTS)) {
final @Nullable JsonElement rawValue = hoverEventObject.get(HOVER_EVENT_CONTENTS);
if (GsonHacks.isNullOrEmpty(rawValue)) {
if (this.strictEventValues) {
throw ComponentSerializerImpl.notSureHowToDeserialize(rawValue);
}
value = null;
} else if (SerializerFactory.COMPONENT_TYPE.isAssignableFrom(actionType)) {
value = this.gson.fromJson(rawValue, SerializerFactory.COMPONENT_TYPE);
Expand All @@ -161,6 +186,9 @@ public Style read(final JsonReader in) throws IOException {
} else if (hoverEventObject.has(HOVER_EVENT_VALUE)) {
final JsonElement element = hoverEventObject.get(HOVER_EVENT_VALUE);
if (GsonHacks.isNullOrEmpty(element)) {
if (this.strictEventValues) {
throw ComponentSerializerImpl.notSureHowToDeserialize(element);
}
value = null;
} else if (SerializerFactory.COMPONENT_TYPE.isAssignableFrom(actionType)) {
final Component rawValue = this.gson.fromJson(element, SerializerFactory.COMPONENT_TYPE);
Expand All @@ -171,6 +199,9 @@ public Style read(final JsonReader in) throws IOException {
value = null;
}
} else {
if (this.strictEventValues) {
throw ComponentSerializerImpl.notSureHowToDeserialize(hoverEventObject);
}
value = null;
}

Expand Down Expand Up @@ -253,13 +284,13 @@ public void write(final JsonWriter out, final Style value) throws IOException {
}

final @Nullable HoverEvent<?> hoverEvent = value.hoverEvent();
if (hoverEvent != null && (hoverEvent.action() != HoverEvent.Action.SHOW_ACHIEVEMENT || this.emitLegacyHover)) {
if (hoverEvent != null && ((this.emitModernHover && hoverEvent.action() != HoverEvent.Action.SHOW_ACHIEVEMENT) || this.emitLegacyHover)) {
out.name(HOVER_EVENT);
out.beginObject();
out.name(HOVER_EVENT_ACTION);
final HoverEvent.Action<?> action = hoverEvent.action();
this.gson.toJson(action, SerializerFactory.HOVER_ACTION_TYPE, out);
if (action != HoverEvent.Action.SHOW_ACHIEVEMENT) { // legacy action has no modern contents value
if (this.emitModernHover && action != HoverEvent.Action.SHOW_ACHIEVEMENT) { // legacy action has no modern contents value
out.name(HOVER_EVENT_CONTENTS);
if (action == HoverEvent.Action.SHOW_ITEM) {
this.gson.toJson(hoverEvent.value(), SerializerFactory.SHOW_ITEM_TYPE, out);
Expand Down
Loading