Skip to content

Check input object classes against the schema with Jackson - #857

Open
oryan-block wants to merge 4 commits into
masterfrom
bugfix/422
Open

oryan-block wants to merge 4 commits into
masterfrom
bugfix/422

Conversation

@oryan-block

Copy link
Copy Markdown
Collaborator

Fixes #422

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

Stacked on #842, merge that first.

A property name on an input class that doesn't match the schema isn't caught when the schema is built. In the #77 comment that #422 links to, ProjectInput has technologies: [TechnologyInput!] in the schema but private List<TechnologyInput> technolgies in the class. Before #842 that failed the build with the misleading "Expected type 'TechnologyInput' to be a GraphQLInputType" error. With #842 it builds, and then fails when a client sends the field, with Jackson's Unrecognized field "technologies". A typo on a scalar field never failed the build at all. Either way you find out at runtime, or not at all if the ObjectMapper ignores unknown properties and the value is silently dropped.

The only check today is the scanner's findInputValueTypeInType, which looks for a name/getName method or a public field. That's fine for finding types, but as a strictness check it's wrong in both directions. It flags setter-only classes, isX getters, @JsonCreator and @ConstructorProperties classes, naming strategies, @JsonAlias and @JsonAnySetter, all of which Jackson maps fine. That's why #842 left #422 out. The thing that decides whether a field is mapped is the field's own ObjectMapper, and that's per field (PerFieldObjectMapperProvider) while an input type can be shared by several fields.

So I ask Jackson. The new internal UnmappedInputFieldChecker runs per resolver field, after SchemaParser has built the input objects and wired their directives:

  • For each input-object argument it gets the deserializer convertValue would use, from that field's mapper. MethodFieldResolver now creates its ObjectMapper once, lazily, as internal val objectMapper, so the argument conversion and the check use the same one, along with getMethodParameterType and the same argument index offsets.
  • It walks the fields of the built input types, so extend input fields are included, and follows lists, arrays, Optionals and nested inputs.
  • A field with no property is read into a placeholder object with FAIL_ON_UNKNOWN_PROPERTIES forced on. It only counts as unmapped if that throws an UnrecognizedPropertyException for that exact name. So anything Jackson maps or deliberately ignores (setters, creators, naming strategies, aliases, @JsonIgnoreProperties, any setters, ignoreUnknown) isn't reported.
  • It stops at things it can't check property by property: Map-like types, delegating creators, builders, custom deserializers, and polymorphic values (type info on the property, the class, or from default typing, including elements of lists, arrays and Optionals).
  • Visited checks are keyed by deserializer and input type, which handles recursion and shared mappers. Reports are de-duplicated by input type, field, class and consequence, and name the first argument that uses the type.
  • Any exception or error during the lookup, apart from VirtualMachineError, is logged at DEBUG and the argument is skipped, so the check can't break a build that worked before.

The message names the field, the class, the usage, what happens at runtime, the properties Jackson knows about, and the fix:

Input field 'ProjectInput.technologies' has no matching property in ProjectInput. Converting argument 'input' of field 'Mutation.createProject' fails whenever the field is sent. Known properties: description, id, name, profiles, technolgies. Rename the property or map it with @JsonProperty("technologies"). If it's left unmapped on purpose, list it in @JsonIgnoreProperties on the class.

The consequence depends on the mapper and the field. It "fails whenever the field is sent", "fails for every 'X' value sent" when the field has a default (graphql-java fills it in, so it's always sent), or "drops the field's value" when the mapper ignores unknown properties. If one type is used by fields whose mappers disagree, each consequence is reported once.

The new SchemaParserOptions.unmappedInputFields takes a new public enum UnmappedInputFields: IGNORE, WARN or FAIL. I made WARN the default. The issue asks for an error, but an error by default would break every schema that has a deliberately unmapped field, and there's no way to tell those apart from typos. WARN logs one line per mismatch. FAIL throws a single SchemaError listing all of them. IGNORE doesn't create the checker at all. Since the check now covers this properly, #842's "Cannot find a public getter or field" WARN in SchemaClassScanner is now DEBUG, because it's the one that gave false positives for setter-only classes and the like.

What isn't checked: Map, JsonNode, Object and type-variable parameters, custom deserializers, delegating creators, builders (only their nested inputs), polymorphic values (skipped, not checked per subtype), classes with @JacksonInject, unknown fields of classes with @JsonUnwrapped properties, and directive arguments. Type-variable parameters already fail at runtime with a ClassCastException in shouldValueBeConverted. That's an existing bug I didn't touch, and since the check uses the same getMethodParameterType, fixing it later fixes both.

Expect small textual conflicts with #843 in SchemaParser.kt and with #849 in SchemaParserOptions.kt.

The new UnmappedInputFieldsTest (with Java fixtures in UnmappedInputFieldsFixtures.java) captures log output and runs real queries next to the schema build, so each report is backed by what actually happens at runtime. Reported cases: the #77 technolgies typo in WARN and FAIL mode (exact text, and the mutation fails), a scalar typo, a Kotlin isActive for a schema active, typos two levels deep through List, array and Optional under an Optional argument, a type shared by two fields plus an extend input field, a mapper that ignores unknown properties, a SNAKE_CASE mapper on one field only, a mismatched field with a default value, mappers with different consequences for one type, and argument index offsets (GraphQLResolver source, DataFetchingEnvironment, suspend continuation). Not reported, in FAIL mode with every value round-tripping in a query: the Java patterns listed above plus @JsonIgnore and @JsonUnwrapped, Kotlin data classes, @JsonNaming, case-insensitive properties, property-level @JsonIgnoreProperties, recursive inputs, Map, Map<String, bean> and ObjectNode parameters, delegating, builder, polymorphic and custom-deserializer classes, polymorphic list and Optional elements, and a module throwing NoClassDefFoundError during the lookup. The known gaps above have tests that pin the current behaviour. I wrote the first four tests before the fix, against existing API only, and all four fail on bugfix/479: the #77 and scalar and isActive cases log nothing useful, and the Java patterns test gets the scanner's false-positive WARN. The rest use the new option, so they don't compile on the base. mvn clean verify passes.

Behaviour change: with the default WARN, schemas build as before but each input field the field's mapper can't map logs one WARN from graphql.kickstart.tools.UnmappedInputFieldChecker, including scalar fields that used to get no message. Deliberately unmapped fields can go in @JsonIgnoreProperties on the class, or the check can be turned off with unmappedInputFields(UnmappedInputFields.IGNORE). Three existing fixtures in this repo now log it, and all three really do fail at runtime. #842's scanner WARN is now DEBUG with shorter text. Jackson deserializers for resolver argument types are now built when the schema is built instead of on the first query, so startup is a bit slower and the first query a bit faster. In a synthetic benchmark that was about 0.1 ms and 33 KB per field with a nested Kotlin input and the default per-field provider, and nothing measurable with one shared mapper. I haven't measured it on a large real schema yet. The per-field ObjectMapper is now created once per MethodFieldResolver, so if one SchemaParser builds the schema twice, both share it, where provide() used to be called again. SchemaParserOptions gains a property, so the data class's copy() gets another parameter and there's a new component17(). That's binary-incompatible for compiled callers of copy(), like when allowUndeclaredDirectives was added, so this should go out in a minor version. Worth a note in the release notes, along with the IGNORE opt-out.

🤖 Generated with Claude Code

oryan-block and others added 4 commits October 9, 2026 21:34
When the scanner couldn't find a Java property for an input field, e.g.
because the input is bound to a Map or ObjectNode, the getter name
doesn't match the schema field (getFoo_Back for foo_back) or the field
is private, it only logged "Cannot find definition for field ..." and
never registered the field's type. Nested input objects and custom
scalars only reachable that way were left out of the scan, and the
parser then failed with "Expected type 'X' to be a GraphQLInputType,
but it wasn't!", which pointed users at the wrong problem.

The scanner now still registers what such a field needs. A nested input
object is registered without a class and its fields are scanned the
same way; a class found for it later is set and scanned as usual, so it
doesn't clash. A custom scalar is registered like any other. An enum
can't be built without its Java class, so if none is found for it
anywhere, the scan fails with a message naming the enum, the input type,
the field and the class, and how to fix it. The garbled warning is
replaced by one that's only logged when an input class (not a Map or
JsonNode) lacks a matching property.

Input fields of a Map or JsonNode are no longer matched against the
methods and fields it inherits from the JDK or Jackson, so fields named
like values, elements or fields work with ObjectNode. Getters declared
by a Map subclass are still used. A class taken from the dictionary for
an input field is matched against the field's unwrapped type, so a list
field whose element type is in the dictionary no longer fails with "Java
class is not a List".

A misspelled property on an input class whose nested input type is only
reachable through it used to fail the build with the misleading error
above. It now builds with the warning and, like any other misspelled
property, fails when a client sends the field, or the value is dropped
if the ObjectMapper ignores unknown properties. There's still no
build-time check that input class properties match the schema (#422).

The parser's "Expected type ... to be a GraphQLInputType" error is
replaced by one that tells apart a type that isn't defined, a type of
the wrong kind (an output type used as an input or vice versa) and a
type that was defined but never reached while scanning, with a hint for
reaching it that depends on the kind of type. It names the field or
argument using the type and its source location. Schemas passed through
schemaString() are still parsed as one document without a source name,
so their line numbers count from the start of all strings (#392).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A misspelled property on an input class, like `technolgies` for
`technologies` in #77, only showed up once a client sent the field:
Jackson then failed to convert the argument, or dropped the value if the
ObjectMapper ignores unknown properties. The scanner's getter lookup
can't tell, since Jackson also maps setters, creators, naming
strategies, aliases and any setters.

Now the SchemaParser asks Jackson. For each resolver argument whose type
is an input object, it looks up the deserializer that the field's own
ObjectMapper converts the argument with, and checks that it has a
property for every field of the built input object type, through lists,
arrays, Optionals and nested input objects. A field it doesn't have is
read into a placeholder object with unknown properties made to fail, so
ignored names, any setters and ignoreUnknown count as mapped without
running the class's constructors or setters.

- New SchemaParserOptions.unmappedInputFields: WARN by default, FAIL
  throws one SchemaError listing every field, IGNORE skips the check
- MethodFieldResolver keeps its ObjectMapper, so the check and the
  conversion use the same one
- The scanner's "Cannot find a public getter or field" warning is now
  debug, since it fired for setter-only classes and similar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A List, array or Optional of a class with @JsonTypeInfo got the base
class's deserializer from the container, so the type id and every field
that only a subclass has were reported as unmapped, which failed the
build in FAIL mode for inputs that convert fine. The check now looks up
the elements' type info the way Jackson does when it creates the
container's deserializer, from the property's annotation or else from
the class or default typing, and skips them like a polymorphic property.

- An input type used by fields whose ObjectMappers handle unknown
  properties differently is reported once for each consequence, so a
  field that fails isn't hidden behind one that drops the value
- Errors such as NoClassDefFoundError while looking up a deserializer
  are logged at debug like exceptions, since the lookup used to run on
  the first query and mustn't fail a build that used to succeed
- Tests for Map<String, bean> arguments and polymorphic properties, and
  contains assertions that show the actual message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Check that field names in input object classes match

1 participant