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

ExampleValues annotation for schema generation along with date examples #5088

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.dataprepper.model.annotations;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
* Use this annotation to provide example values for plugin configuration.
*
* @since 2.11
*/
@Documented
@Retention(RUNTIME)
@Target({FIELD})
public @interface ExampleValues {
/**
* One or more examples.
* @return the examples.
* @since 2.11
*/
Example[] value();

/**
* A single example.
*
* @since 2.11
*/
@interface Example {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it have to be an inner interface? As it is inner, at the time of specifying these example values it looks overly verbose

@ExampleValues({
                @ExampleValues.Example("some example value"),
                @ExampleValues.Example(value = "second example value", description = "This is the second value.")
        })

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually quite common for annotations that are coupled with their parent. You can add ExampleValues as a static import.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a revision that imports ExampleValues.Example.

/**
* The example value
* @return The example value
*
* @since 2.11
*/
String value();

/**
* A description of the example value.
*
* @since 2.11
*/
String description() default "";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.dataprepper.schemas;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.InstanceAttributeOverrideV2;
import com.github.victools.jsonschema.generator.SchemaGenerationContext;
import org.opensearch.dataprepper.model.annotations.ExampleValues;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class ExampleValuesInstanceAttributeOverride implements InstanceAttributeOverrideV2<FieldScope> {
@Override
public void overrideInstanceAttributes(final ObjectNode fieldSchema, final FieldScope fieldScope, final SchemaGenerationContext context) {
final ExampleValues exampleValuesAnnotation = fieldScope.getAnnotationConsideringFieldAndGetterIfSupported(ExampleValues.class);
if(exampleValuesAnnotation != null && exampleValuesAnnotation.value().length > 0) {
final ObjectMapper objectMapper = context.getGeneratorConfig().getObjectMapper();

addExampleSchema(fieldSchema, objectMapper, exampleValuesAnnotation);
}
}

private void addExampleSchema(final ObjectNode fieldSchema, final ObjectMapper objectMapper, final ExampleValues exampleValuesAnnotation) {
final List<Map<String, String>> exampleValues = Arrays.stream(exampleValuesAnnotation.value())
.map(ExampleValuesInstanceAttributeOverride::createExampleMap).collect(Collectors.toList());
final ArrayNode exampleNode = objectMapper.convertValue(exampleValues, ArrayNode.class);

fieldSchema.putArray("examples")
.addAll(exampleNode);
}

private static Map<String, String> createExampleMap(final ExampleValues.Example example) {
final HashMap<String, String> exampleMap = new HashMap<>();
exampleMap.put("example", example.value());
if(example.description() != null && !example.description().isEmpty()) {
exampleMap.put("description", example.description());
}
return exampleMap;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public ObjectNode convertIntoJsonSchema(
resolveDependentRequiresFields(scopeSchemaGeneratorConfigPart);
overrideDataPrepperPluginTypeAttribute(configBuilder.forTypesInGeneral(), schemaVersion, optionPreset);
resolveDataPrepperTypes(scopeSchemaGeneratorConfigPart);
scopeSchemaGeneratorConfigPart.withInstanceAttributeOverride(new ExampleValuesInstanceAttributeOverride());

final SchemaGeneratorConfig config = configBuilder.build();
final SchemaGenerator generator = new SchemaGenerator(config);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.dataprepper.schemas;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.FieldScope;
import com.github.victools.jsonschema.generator.SchemaGenerationContext;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.opensearch.dataprepper.model.annotations.ExampleValues;

import java.util.Map;
import java.util.UUID;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ExampleValuesInstanceAttributeOverrideTest {

@Mock
private ObjectNode fieldSchema;

@Mock
private FieldScope fieldScope;

@Mock
private SchemaGenerationContext context;
private ObjectMapper objectMapper;

@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
fieldSchema = spy(objectMapper.convertValue(Map.of(UUID.randomUUID().toString(), UUID.randomUUID().toString()), ObjectNode.class));
}

private ExampleValuesInstanceAttributeOverride createObjectUnderTest() {
return new ExampleValuesInstanceAttributeOverride();
}

@Test
void overrideInstanceAttributes_does_not_modify_fieldSchema_if_no_ExampleValues_annotation() {
createObjectUnderTest().overrideInstanceAttributes(fieldSchema, fieldScope, context);

verifyNoInteractions(fieldSchema);
}

@Nested
class WithExampleValuesAnnotation {
@Mock
private ExampleValues exampleValuesAnnotation;

@Mock
private SchemaGeneratorConfig schemaGeneratorConfig;

@BeforeEach
void setUp() {
when(fieldScope.getAnnotationConsideringFieldAndGetterIfSupported(ExampleValues.class))
.thenReturn(exampleValuesAnnotation);
}

@Test
void overrideInstanceAttributes_does_not_modify_fieldSchema_if_no_ExampleValues_annotation_is_empty() {
when(exampleValuesAnnotation.value()).thenReturn(new ExampleValues.Example[]{});

createObjectUnderTest().overrideInstanceAttributes(fieldSchema, fieldScope, context);

verifyNoInteractions(fieldSchema);
}

@Test
void overrideInstanceAttributes_adds_examples_when_one_ExampleValue() {
when(context.getGeneratorConfig()).thenReturn(schemaGeneratorConfig);
when(schemaGeneratorConfig.getObjectMapper()).thenReturn(objectMapper);

final ExampleValues.Example example = mock(ExampleValues.Example.class);
final String value = UUID.randomUUID().toString();
final String description = UUID.randomUUID().toString();
when(example.value()).thenReturn(value);
when(example.description()).thenReturn(description);
final ExampleValues.Example[] examples = {example};
when(exampleValuesAnnotation.value()).thenReturn(examples);

createObjectUnderTest().overrideInstanceAttributes(fieldSchema, fieldScope, context);

final JsonNode examplesNode = fieldSchema.get("examples");
assertThat(examplesNode, notNullValue());

assertThat(examplesNode.getNodeType(), equalTo(JsonNodeType.ARRAY));
assertThat(examplesNode.size(), equalTo(1));
final JsonNode firstExampleNode = examplesNode.get(0);
assertThat(firstExampleNode, notNullValue());
assertThat(firstExampleNode.getNodeType(), equalTo(JsonNodeType.OBJECT));
assertThat(firstExampleNode.get("example"), notNullValue());
assertThat(firstExampleNode.get("example").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(firstExampleNode.get("example").textValue(), equalTo(value));
assertThat(firstExampleNode.get("description"), notNullValue());
assertThat(firstExampleNode.get("description").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(firstExampleNode.get("description").textValue(), equalTo(description));
}

@Test
void overrideInstanceAttributes_adds_examples_when_one_ExampleValue_with_no_description() {
when(context.getGeneratorConfig()).thenReturn(schemaGeneratorConfig);
when(schemaGeneratorConfig.getObjectMapper()).thenReturn(objectMapper);

final ExampleValues.Example example = mock(ExampleValues.Example.class);
final String value = UUID.randomUUID().toString();
final String description = UUID.randomUUID().toString();
when(example.value()).thenReturn(value);
final ExampleValues.Example[] examples = {example};
when(exampleValuesAnnotation.value()).thenReturn(examples);

createObjectUnderTest().overrideInstanceAttributes(fieldSchema, fieldScope, context);

final JsonNode examplesNode = fieldSchema.get("examples");
assertThat(examplesNode, notNullValue());

assertThat(examplesNode.getNodeType(), equalTo(JsonNodeType.ARRAY));
assertThat(examplesNode.size(), equalTo(1));
final JsonNode firstExampleNode = examplesNode.get(0);
assertThat(firstExampleNode, notNullValue());
assertThat(firstExampleNode.getNodeType(), equalTo(JsonNodeType.OBJECT));
assertThat(firstExampleNode.get("example"), notNullValue());
assertThat(firstExampleNode.get("example").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(firstExampleNode.get("example").textValue(), equalTo(value));
assertThat(firstExampleNode.has("description"), equalTo(false));
}

@ParameterizedTest
@ValueSource(ints = {2, 3, 5})
void overrideInstanceAttributes_adds_examples_when_multiple_ExampleValue(final int numberOfExamples) {
when(context.getGeneratorConfig()).thenReturn(schemaGeneratorConfig);
when(schemaGeneratorConfig.getObjectMapper()).thenReturn(objectMapper);

final ExampleValues.Example[] examples = new ExampleValues.Example[numberOfExamples];
for (int i = 0; i < numberOfExamples; i++) {
final ExampleValues.Example example = mock(ExampleValues.Example.class);
final String value = UUID.randomUUID().toString();
final String description = UUID.randomUUID().toString();
when(example.value()).thenReturn(value);
when(example.description()).thenReturn(description);

examples[i] = example;
}
when(exampleValuesAnnotation.value()).thenReturn(examples);

createObjectUnderTest().overrideInstanceAttributes(fieldSchema, fieldScope, context);

final JsonNode examplesNode = fieldSchema.get("examples");
assertThat(examplesNode, notNullValue());

assertThat(examplesNode.getNodeType(), equalTo(JsonNodeType.ARRAY));
assertThat(examplesNode.size(), equalTo(numberOfExamples));

for (int i = 0; i < numberOfExamples; i++) {
final JsonNode exampleNode = examplesNode.get(0);
assertThat(exampleNode, notNullValue());
assertThat(exampleNode.getNodeType(), equalTo(JsonNodeType.OBJECT));
assertThat(exampleNode.get("example"), notNullValue());
assertThat(exampleNode.get("example").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(exampleNode.get("example").textValue(), notNullValue());
assertThat(exampleNode.get("description"), notNullValue());
assertThat(exampleNode.get("description").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(exampleNode.get("description").textValue(), notNullValue());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package org.opensearch.dataprepper.schemas;

import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.OptionPreset;
Expand All @@ -14,6 +16,7 @@
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.opensearch.dataprepper.model.annotations.ExampleValues;
import org.opensearch.dataprepper.model.annotations.UsesDataPrepperPlugin;
import org.opensearch.dataprepper.model.configuration.PluginModel;
import org.opensearch.dataprepper.plugin.ClasspathPluginProvider;
Expand All @@ -23,8 +26,10 @@
import java.util.List;

import static com.github.victools.jsonschema.module.jackson.JacksonOption.RESPECT_JSONPROPERTY_REQUIRED;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;

public class JsonSchemaConverterIT {
Expand Down Expand Up @@ -59,6 +64,38 @@ void testSubTypes() throws JsonProcessingException {
anyOfNode.forEach(aggregateActionNode -> assertThat(aggregateActionNode.has(PROPERTIES_KEY), is(true)));
}

@Test
void test_examples() throws JsonProcessingException {
final ObjectNode jsonSchemaNode = objectUnderTest.convertIntoJsonSchema(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON, TestConfig.class);
assertThat(jsonSchemaNode, instanceOf(ObjectNode.class));
final JsonNode propertiesNode = jsonSchemaNode.at("/" + PROPERTIES_KEY);
assertThat(propertiesNode, instanceOf(ObjectNode.class));
assertThat(propertiesNode.has("string_value_with_two_examples"), is(true));
final JsonNode propertyNode = propertiesNode.at("/string_value_with_two_examples");
assertThat(propertyNode.has("type"), equalTo(true));
assertThat(propertyNode.get("type").getNodeType(), equalTo(JsonNodeType.STRING));

assertThat(propertyNode.has("examples"), equalTo(true));
assertThat(propertyNode.get("examples").getNodeType(), equalTo(JsonNodeType.ARRAY));
assertThat(propertyNode.get("examples").size(), equalTo(2));
assertThat(propertyNode.get("examples").get(0), notNullValue());
assertThat(propertyNode.get("examples").get(0).getNodeType(), equalTo(JsonNodeType.OBJECT));
assertThat(propertyNode.get("examples").get(0).has("example"), equalTo(true));
assertThat(propertyNode.get("examples").get(0).get("example").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(propertyNode.get("examples").get(0).get("example").textValue(), equalTo("some example value"));
assertThat(propertyNode.get("examples").get(0).has("description"), equalTo(false));

assertThat(propertyNode.get("examples").get(1), notNullValue());
assertThat(propertyNode.get("examples").get(1).getNodeType(), equalTo(JsonNodeType.OBJECT));
assertThat(propertyNode.get("examples").get(1).has("example"), equalTo(true));
assertThat(propertyNode.get("examples").get(1).get("example").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(propertyNode.get("examples").get(1).get("example").textValue(), equalTo("second example value"));
assertThat(propertyNode.get("examples").get(1).has("description"), equalTo(true));
assertThat(propertyNode.get("examples").get(1).get("description").getNodeType(), equalTo(JsonNodeType.STRING));
assertThat(propertyNode.get("examples").get(1).get("description").textValue(), equalTo("This is the second value."));
}

@JsonClassDescription("test config")
static class TestConfig {
@JsonPropertyDescription("The aggregate action description")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe make this description more generic

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just for testing.

Expand All @@ -68,5 +105,16 @@ static class TestConfig {
public PluginModel getAction() {
return action;
}

@JsonProperty("string_value_with_two_examples")
@ExampleValues({
@ExampleValues.Example("some example value"),
@ExampleValues.Example(value = "second example value", description = "This is the second value.")
})
private String stringValueWithTwoExamples;

public String getStringValueWithTwoExamples() {
return stringValueWithTwoExamples;
}
}
}
Loading
Loading