/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. */ /* * Licensed to Elasticsearch under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /* * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ package org.opensearch.common.xcontent; import org.opensearch.common.CheckedFunction; import org.opensearch.common.Nullable; import org.opensearch.core.ParseField; import org.opensearch.common.xcontent.ObjectParserTests.NamedObject; import org.opensearch.common.xcontent.json.JsonXContent; import org.opensearch.core.xcontent.ConstructingObjectParser; import org.opensearch.core.xcontent.ContextParser; import org.opensearch.core.xcontent.ObjectParser; import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.ToXContentObject; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParseException; import org.opensearch.core.xcontent.XContentParser; import org.opensearch.test.OpenSearchTestCase; import org.hamcrest.Matcher; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Arrays; import java.util.List; import static java.util.Collections.unmodifiableList; import static org.opensearch.core.xcontent.ConstructingObjectParser.constructorArg; import static org.opensearch.core.xcontent.ConstructingObjectParser.optionalConstructorArg; import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; public class ConstructingObjectParserTests extends OpenSearchTestCase { public void testNullDeclares() { ConstructingObjectParser objectParser = new ConstructingObjectParser<>("foo", a -> null); Exception e = expectThrows( IllegalArgumentException.class, () -> objectParser.declareField(null, (r, c) -> null, new ParseField("test"), ObjectParser.ValueType.STRING) ); assertEquals("[consumer] is required", e.getMessage()); e = expectThrows( IllegalArgumentException.class, () -> objectParser.declareField( (o, v) -> {}, (ContextParser) null, new ParseField("test"), ObjectParser.ValueType.STRING ) ); assertEquals("[parser] is required", e.getMessage()); e = expectThrows( IllegalArgumentException.class, () -> objectParser.declareField( (o, v) -> {}, (CheckedFunction) null, new ParseField("test"), ObjectParser.ValueType.STRING ) ); assertEquals("[parser] is required", e.getMessage()); e = expectThrows( IllegalArgumentException.class, () -> objectParser.declareField((o, v) -> {}, (r, c) -> null, null, ObjectParser.ValueType.STRING) ); assertEquals("[parseField] is required", e.getMessage()); e = expectThrows( IllegalArgumentException.class, () -> objectParser.declareField((o, v) -> {}, (r, c) -> null, new ParseField("test"), null) ); assertEquals("[type] is required", e.getMessage()); } /** * Builds the object in random order and parses it. */ public void testRandomOrder() throws Exception { HasCtorArguments expected = new HasCtorArguments(randomAlphaOfLength(5), randomInt()); expected.setMineral(randomInt()); expected.setFruit(randomInt()); expected.setA(randomBoolean() ? null : randomAlphaOfLength(5)); expected.setB(randomBoolean() ? null : randomAlphaOfLength(5)); expected.setC(randomBoolean() ? null : randomAlphaOfLength(5)); expected.setD(randomBoolean()); XContentBuilder builder = XContentFactory.jsonBuilder().prettyPrint(); expected.toXContent(builder, ToXContent.EMPTY_PARAMS); builder = shuffleXContent(builder); builder.flush(); byte[] bytes = ((ByteArrayOutputStream) builder.getOutputStream()).toByteArray(); try (XContentParser parser = createParser(JsonXContent.jsonXContent, bytes)) { HasCtorArguments parsed = randomFrom(HasCtorArguments.ALL_PARSERS).apply(parser, null); assertEquals(expected.animal, parsed.animal); assertEquals(expected.vegetable, parsed.vegetable); assertEquals(expected.mineral, parsed.mineral); assertEquals(expected.fruit, parsed.fruit); assertEquals(expected.a, parsed.a); assertEquals(expected.b, parsed.b); assertEquals(expected.c, parsed.c); assertEquals(expected.d, parsed.d); } } public void testMissingAllConstructorArgs() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"mineral\": 1 }"); ConstructingObjectParser objectParser = randomBoolean() ? HasCtorArguments.PARSER : HasCtorArguments.PARSER_VEGETABLE_OPTIONAL; IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> objectParser.apply(parser, null)); if (objectParser == HasCtorArguments.PARSER) { assertEquals("Required [animal, vegetable]", e.getMessage()); } else { assertEquals("Required [animal]", e.getMessage()); } } public void testMissingAllConstructorArgsButNotRequired() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"mineral\": 1 }"); HasCtorArguments parsed = HasCtorArguments.PARSER_ALL_OPTIONAL.apply(parser, null); assertEquals(1, parsed.mineral); } public void testMissingSecondConstructorArg() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"mineral\": 1, \"animal\": \"cat\" }"); IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> HasCtorArguments.PARSER.apply(parser, null)); assertEquals("Required [vegetable]", e.getMessage()); } public void testMissingSecondConstructorArgButNotRequired() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"mineral\": 1, \"animal\": \"cat\" }"); @SuppressWarnings("unchecked") HasCtorArguments parsed = randomFrom(HasCtorArguments.PARSER_VEGETABLE_OPTIONAL, HasCtorArguments.PARSER_ALL_OPTIONAL).apply( parser, null ); assertEquals(1, parsed.mineral); assertEquals("cat", parsed.animal); } public void testMissingFirstConstructorArg() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"mineral\": 1, \"vegetable\": 2 }"); @SuppressWarnings("unchecked") IllegalArgumentException e = expectThrows( IllegalArgumentException.class, () -> randomFrom(HasCtorArguments.PARSER, HasCtorArguments.PARSER_VEGETABLE_OPTIONAL).apply(parser, null) ); assertEquals("Required [animal]", e.getMessage()); } public void testMissingFirstConstructorArgButNotRequired() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"mineral\": 1, \"vegetable\": 2 }"); HasCtorArguments parsed = HasCtorArguments.PARSER_ALL_OPTIONAL.apply(parser, null); assertEquals(1, parsed.mineral); assertEquals((Integer) 2, parsed.vegetable); } public void testBadParam() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, // The following JSON needs to include newlines, in order to affect the line numbers // included in the exception "{\n" + " \"animal\": \"cat\",\n" + " \"vegetable\": 2,\n" + " \"a\": \"supercalifragilisticexpialidocious\"\n" + "}" ); XContentParseException e = expectThrows( XContentParseException.class, () -> randomFrom(HasCtorArguments.ALL_PARSERS).apply(parser, null) ); assertThat(e.getMessage(), containsString("[has_required_arguments] failed to parse field [a]")); assertEquals(4, e.getLineNumber()); assertEquals( "[a] must be less than 10 characters in length but was [supercalifragilisticexpialidocious]", e.getCause().getMessage() ); } public void testBadParamBeforeObjectBuilt() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, // The following JSON needs to include newlines, in order to affect the line numbers // included in the exception "{\n" + " \"a\": \"supercalifragilisticexpialidocious\",\n" + " \"animal\": \"cat\"\n," + " \"vegetable\": 2\n" + "}" ); XContentParseException e = expectThrows( XContentParseException.class, () -> randomFrom(HasCtorArguments.ALL_PARSERS).apply(parser, null) ); assertThat(e.getMessage(), containsString("[has_required_arguments] failed to parse field [vegetable]")); assertEquals(4, e.getLineNumber()); e = (XContentParseException) e.getCause(); assertThat(e.getMessage(), containsString("failed to build [has_required_arguments] after last required field arrived")); assertEquals(2, e.getLineNumber()); e = (XContentParseException) e.getCause(); assertThat(e.getMessage(), containsString("[has_required_arguments] failed to parse field [a]")); assertEquals(2, e.getLineNumber()); assertEquals( "[a] must be less than 10 characters in length but was [supercalifragilisticexpialidocious]", e.getCause().getMessage() ); } public void testConstructorArgsMustBeConfigured() throws IOException { class NoConstructorArgs {} ConstructingObjectParser parser = new ConstructingObjectParser<>( "constructor_args_required", (a) -> new NoConstructorArgs() ); try { parser.apply(createParser(JsonXContent.jsonXContent, "{}"), null); fail("Expected AssertionError"); } catch (AssertionError e) { assertEquals( "[constructor_args_required] must configure at least one constructor argument. If it doesn't have any it should " + "use ObjectParser instead of ConstructingObjectParser. This is a bug in the parser declaration.", e.getMessage() ); } } /** * Tests the non-constructor fields are only set on time. */ public void testCalledOneTime() throws IOException { boolean ctorArgOptional = randomBoolean(); class CalledOneTime { CalledOneTime(String yeah) { Matcher yeahMatcher = equalTo("!"); if (ctorArgOptional) { // either(yeahMatcher).or(nullValue) is broken by https://github.com/hamcrest/JavaHamcrest/issues/49 yeahMatcher = anyOf(yeahMatcher, nullValue()); } assertThat(yeah, yeahMatcher); } boolean fooSet = false; void setFoo(String foo) { assertFalse(fooSet); fooSet = true; } } ConstructingObjectParser parser = new ConstructingObjectParser<>( "one_time_test", (a) -> new CalledOneTime((String) a[0]) ); parser.declareString(CalledOneTime::setFoo, new ParseField("foo")); parser.declareString(ctorArgOptional ? optionalConstructorArg() : constructorArg(), new ParseField("yeah")); // ctor arg first so we can test for the bug we found one time XContentParser xcontent = createParser(JsonXContent.jsonXContent, "{ \"yeah\": \"!\", \"foo\": \"foo\" }"); CalledOneTime result = parser.apply(xcontent, null); assertTrue(result.fooSet); // and ctor arg second just in case xcontent = createParser(JsonXContent.jsonXContent, "{ \"foo\": \"foo\", \"yeah\": \"!\" }"); result = parser.apply(xcontent, null); assertTrue(result.fooSet); if (ctorArgOptional) { // and without the constructor arg if we've made it optional xcontent = createParser(JsonXContent.jsonXContent, "{ \"foo\": \"foo\" }"); result = parser.apply(xcontent, null); } assertTrue(result.fooSet); } public void testIgnoreUnknownFields() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"test\" : \"foo\", \"junk\" : 2 }"); class TestStruct { public final String test; TestStruct(String test) { this.test = test; } } ConstructingObjectParser objectParser = new ConstructingObjectParser<>( "foo", true, a -> new TestStruct((String) a[0]) ); objectParser.declareString(constructorArg(), new ParseField("test")); TestStruct s = objectParser.apply(parser, null); assertEquals(s.test, "foo"); } public void testConstructObjectUsingContext() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{ \"animal\": \"dropbear\", \"mineral\": -8 }"); HasCtorArguments parsed = HasCtorArguments.PARSER_INT_CONTEXT.apply(parser, 42); assertEquals(Integer.valueOf(42), parsed.vegetable); assertEquals("dropbear", parsed.animal); assertEquals(-8, parsed.mineral); } private static class HasCtorArguments implements ToXContentObject { @Nullable final String animal; @Nullable final Integer vegetable; int mineral; int fruit; String a; String b; String c; boolean d; HasCtorArguments(@Nullable String animal, @Nullable Integer vegetable) { this.animal = animal; this.vegetable = vegetable; } public void setMineral(int mineral) { this.mineral = mineral; } public void setFruit(int fruit) { this.fruit = fruit; } public void setA(String a) { if (a != null && a.length() > 9) { throw new IllegalArgumentException("[a] must be less than 10 characters in length but was [" + a + "]"); } this.a = a; } public void setB(String b) { this.b = b; } public void setC(String c) { this.c = c; } public void setD(boolean d) { this.d = d; } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(); builder.field("animal", animal); builder.field("vegetable", vegetable); if (mineral != 0) { // We're just using 0 as the default because it is easy for testing builder.field("mineral", mineral); } if (fruit != 0) { builder.field("fruit", fruit); } if (a != null) { builder.field("a", a); } if (b != null) { builder.field("b", b); } if (c != null) { builder.field("c", c); } if (d) { builder.field("d", d); } builder.endObject(); return builder; } /* * It is normal just to declare a single PARSER but we use a couple of different parsers for testing so we have all of these. Don't * this this style is normal just because it is in the test. */ public static final ConstructingObjectParser PARSER = buildParser(true, true); public static final ConstructingObjectParser PARSER_VEGETABLE_OPTIONAL = buildParser(true, false); public static final ConstructingObjectParser PARSER_ALL_OPTIONAL = buildParser(false, false); public static final List> ALL_PARSERS = unmodifiableList( Arrays.asList(PARSER, PARSER_VEGETABLE_OPTIONAL, PARSER_ALL_OPTIONAL) ); public static final ConstructingObjectParser PARSER_INT_CONTEXT = buildContextParser(); private static ConstructingObjectParser buildParser(boolean animalRequired, boolean vegetableRequired) { ConstructingObjectParser parser = new ConstructingObjectParser<>( "has_required_arguments", a -> new HasCtorArguments((String) a[0], (Integer) a[1]) ); parser.declareString(animalRequired ? constructorArg() : optionalConstructorArg(), new ParseField("animal")); parser.declareInt(vegetableRequired ? constructorArg() : optionalConstructorArg(), new ParseField("vegetable")); declareSetters(parser); return parser; } private static ConstructingObjectParser buildContextParser() { ConstructingObjectParser parser = new ConstructingObjectParser<>( "has_required_arguments", false, (args, ctx) -> new HasCtorArguments((String) args[0], ctx) ); parser.declareString(constructorArg(), new ParseField("animal")); declareSetters(parser); return parser; } private static void declareSetters(ConstructingObjectParser parser) { parser.declareInt(HasCtorArguments::setMineral, new ParseField("mineral")); parser.declareInt(HasCtorArguments::setFruit, new ParseField("fruit")); parser.declareString(HasCtorArguments::setA, new ParseField("a")); parser.declareString(HasCtorArguments::setB, new ParseField("b")); parser.declareString(HasCtorArguments::setC, new ParseField("c")); parser.declareBoolean(HasCtorArguments::setD, new ParseField("d")); } } public void testParseNamedObject() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": { \"a\": {} }, \"named_in_constructor\": { \"b\": {} } }" ); NamedObjectHolder h = NamedObjectHolder.PARSER.apply(parser, null); assertThat(h.named, hasSize(1)); assertEquals("a", h.named.get(0).name); assertThat(h.namedInConstructor, hasSize(1)); assertEquals("b", h.namedInConstructor.get(0).name); assertFalse(h.namedSuppliedInOrder); } public void testParseNamedObjectInOrder() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": [ {\"a\": {}} ], \"named_in_constructor\": [ {\"b\": {}} ]}" ); NamedObjectHolder h = NamedObjectHolder.PARSER.apply(parser, null); assertThat(h.named, hasSize(1)); assertEquals("a", h.named.get(0).name); assertThat(h.namedInConstructor, hasSize(1)); assertEquals("b", h.namedInConstructor.get(0).name); assertTrue(h.namedSuppliedInOrder); } public void testParseNamedObjectTwoFieldsInArray() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": [ {\"a\": {}, \"b\": {}}], \"named_in_constructor\": [ {\"c\": {}} ]}" ); XContentParseException e = expectThrows(XContentParseException.class, () -> NamedObjectHolder.PARSER.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named]")); assertThat( e.getCause().getMessage(), containsString( "[named] can be a single object with any number of fields " + "or an array where each entry is an object with a single field" ) ); } public void testParseNamedObjectTwoFieldsInArrayConstructorArg() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": [ {\"a\": {}}], \"named_in_constructor\": [ {\"c\": {}, \"d\": {}} ]}" ); XContentParseException e = expectThrows(XContentParseException.class, () -> NamedObjectHolder.PARSER.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named_in_constructor]")); assertThat( e.getCause().getMessage(), containsString( "[named_in_constructor] can be a single object with any number of fields " + "or an array where each entry is an object with a single field" ) ); } public void testParseNamedObjectNoFieldsInArray() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"named\": [ {} ], \"named_in_constructor\": [ {\"a\": {}} ]}"); XContentParseException e = expectThrows(XContentParseException.class, () -> NamedObjectHolder.PARSER.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named]")); assertThat( e.getCause().getMessage(), containsString( "[named] can be a single object with any number of fields " + "or an array where each entry is an object with a single field" ) ); } public void testParseNamedObjectNoFieldsInArrayConstructorArg() throws IOException { XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"named\": [ {\"a\": {}} ], \"named_in_constructor\": [ {} ]}"); XContentParseException e = expectThrows(XContentParseException.class, () -> NamedObjectHolder.PARSER.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named_in_constructor]")); assertThat( e.getCause().getMessage(), containsString( "[named_in_constructor] can be a single object with any number of fields " + "or an array where each entry is an object with a single field" ) ); } public void testParseNamedObjectJunkInArray() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": [ \"junk\" ], \"named_in_constructor\": [ {\"a\": {}} ]}" ); XContentParseException e = expectThrows(XContentParseException.class, () -> NamedObjectHolder.PARSER.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named]")); assertThat( e.getCause().getMessage(), containsString( "[named] can be a single object with any number of fields " + "or an array where each entry is an object with a single field" ) ); } public void testParseNamedObjectJunkInArrayConstructorArg() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": [ {\"a\": {}} ], \"named_in_constructor\": [ \"junk\" ]}" ); XContentParseException e = expectThrows(XContentParseException.class, () -> NamedObjectHolder.PARSER.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named_in_constructor]")); assertThat( e.getCause().getMessage(), containsString( "[named_in_constructor] can be a single object with any number of fields " + "or an array where each entry is an object with a single field" ) ); } public void testParseNamedObjectInOrderNotSupported() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": [\n" + " {\"a\": {}}" + "],\"named_in_constructor\": {\"b\": {}}" + "}" ); // Create our own parser for this test so we can disable support for the "ordered" mode specified by the array above @SuppressWarnings("unchecked") ConstructingObjectParser objectParser = new ConstructingObjectParser<>( "named_object_holder", a -> new NamedObjectHolder(((List) a[0])) ); objectParser.declareNamedObjects( ConstructingObjectParser.constructorArg(), NamedObject.PARSER, new ParseField("named_in_constructor") ); objectParser.declareNamedObjects(NamedObjectHolder::setNamed, NamedObject.PARSER, new ParseField("named")); // Now firing the xml through it fails XContentParseException e = expectThrows(XContentParseException.class, () -> objectParser.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named]")); assertEquals("[named] doesn't support arrays. Use a single object with multiple fields.", e.getCause().getMessage()); } public void testParseNamedObjectInOrderNotSupportedConstructorArg() throws IOException { XContentParser parser = createParser( JsonXContent.jsonXContent, "{\"named\": {\"a\": {}}, \"named_in_constructor\": [ {\"b\": {}} ]}" ); // Create our own parser for this test so we can disable support for the "ordered" mode specified by the array above @SuppressWarnings("unchecked") ConstructingObjectParser objectParser = new ConstructingObjectParser<>( "named_object_holder", a -> new NamedObjectHolder(((List) a[0])) ); objectParser.declareNamedObjects( ConstructingObjectParser.constructorArg(), NamedObject.PARSER, new ParseField("named_in_constructor") ); objectParser.declareNamedObjects(NamedObjectHolder::setNamed, NamedObject.PARSER, new ParseField("named")); // Now firing the xml through it fails XContentParseException e = expectThrows(XContentParseException.class, () -> objectParser.apply(parser, null)); assertThat(e.getMessage(), containsString("[named_object_holder] failed to parse field [named_in_constructor]")); assertThat( e.getCause().getMessage(), containsString("[named_in_constructor] doesn't support arrays. Use a single object with multiple fields.") ); } static class NamedObjectHolder { @SuppressWarnings("unchecked") public static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( "named_object_holder", a -> new NamedObjectHolder(((List) a[0])) ); static { PARSER.declareNamedObjects( ConstructingObjectParser.constructorArg(), NamedObject.PARSER, NamedObjectHolder::keepNamedInOrder, new ParseField("named_in_constructor") ); PARSER.declareNamedObjects( NamedObjectHolder::setNamed, NamedObject.PARSER, NamedObjectHolder::keepNamedInOrder, new ParseField("named") ); } private List named; private List namedInConstructor; private boolean namedSuppliedInOrder = false; NamedObjectHolder(List namedInConstructor) { this.namedInConstructor = namedInConstructor; } public void setNamed(List named) { this.named = named; } public void keepNamedInOrder() { namedSuppliedInOrder = true; } } public void testRequiredAndExclusiveFields() throws IOException { class TestStruct { final String a; final long b; TestStruct(String a) { this.a = a; this.b = 0; } TestStruct(long b) { this.a = null; this.b = b; } } XContentParser ok = createParser(JsonXContent.jsonXContent, "{ \"a\" : \"a\" }"); XContentParser toomany = createParser(JsonXContent.jsonXContent, "{ \"a\" : \"a\", \"b\" : 1 }"); XContentParser notenough = createParser(JsonXContent.jsonXContent, "{ }"); ConstructingObjectParser parser = new ConstructingObjectParser<>("teststruct", args -> { if (args[0] != null) { return new TestStruct((String) args[0]); } return new TestStruct((Long) args[1]); }); parser.declareString(optionalConstructorArg(), new ParseField("a")); parser.declareLong(optionalConstructorArg(), new ParseField("b")); parser.declareExclusiveFieldSet("a", "b"); parser.declareRequiredFieldSet("a", "b"); TestStruct actual = parser.parse(ok, null); assertThat(actual.a, equalTo("a")); assertThat(actual.b, equalTo(0L)); IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> parser.parse(toomany, null)); assertThat(e.getMessage(), containsString("allowed together: [a, b]")); e = expectThrows(IllegalArgumentException.class, () -> parser.parse(notenough, null)); assertThat(e.getMessage(), containsString("Required one of fields [a, b], but none were specified.")); } }