Skip to content

Commit 8083a11

Browse files
committed
Normalize null collections in RecipeDescriptor deserialization
`Recipe.createRecipeDescriptor()` always passes non-null lists for `tags`, `options`, `preconditions`, `recipeList`, `dataTables`, `maintainers`, `contributors`, and `examples` — so callers in the ecosystem treat these getters as never-null and iterate them directly. When a `RecipeDescriptor` arrives from a polyglot RPC peer (rewrite-javascript-remote, rewrite-csharp-remote, rewrite-python-remote), those peers omit empty collection-valued fields from the JSON they return. Jackson's default `@AllArgsConstructor`-based deserialization then leaves the corresponding fields `null`, breaking every downstream caller that does `descriptor.getPreconditions().iterator()` without a null check. Replace the Lombok `@Value` + `@AllArgsConstructor(@JsonCreator)` setup with an explicit `@JsonCreator` constructor that normalizes null to empty collections at the deserialization boundary. Field semantics (immutability, getters, equals/hashCode, `@With`) are preserved via explicit `private final` fields plus `@Getter` / `@ToString` / `@EqualsAndHashCode`.
1 parent 72febb9 commit 8083a11

2 files changed

Lines changed: 113 additions & 18 deletions

File tree

rewrite-core/src/main/java/org/openrewrite/config/RecipeDescriptor.java

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
package org.openrewrite.config;
1717

1818
import com.fasterxml.jackson.annotation.JsonCreator;
19-
import lombok.AllArgsConstructor;
19+
import com.fasterxml.jackson.annotation.JsonProperty;
2020
import lombok.EqualsAndHashCode;
21-
import lombok.Value;
21+
import lombok.Getter;
22+
import lombok.ToString;
2223
import lombok.With;
2324
import org.intellij.lang.annotations.Language;
2425
import org.jspecify.annotations.Nullable;
@@ -36,48 +37,86 @@
3637
import static java.util.Collections.emptyList;
3738
import static java.util.Collections.emptySet;
3839

39-
@Value
40+
@Getter
41+
@ToString
4042
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
41-
@AllArgsConstructor(onConstructor = @__(@JsonCreator))
4243
public class RecipeDescriptor {
4344
@EqualsAndHashCode.Include
44-
String name;
45+
private final String name;
4546

4647
@NlsRewrite.DisplayName
47-
String displayName;
48+
private final String displayName;
4849

4950
@NlsRewrite.DisplayName
50-
String instanceName;
51+
private final String instanceName;
5152

5253
@NlsRewrite.Description
53-
String description;
54+
private final String description;
5455

55-
Set<String> tags;
56+
private final Set<String> tags;
5657

5758
@Nullable
58-
Duration estimatedEffortPerOccurrence;
59+
private final Duration estimatedEffortPerOccurrence;
5960

6061
@EqualsAndHashCode.Include
61-
List<OptionDescriptor> options;
62+
private final List<OptionDescriptor> options;
6263

6364
@With
64-
List<RecipeDescriptor> preconditions;
65+
private final List<RecipeDescriptor> preconditions;
6566

6667
@With
67-
List<RecipeDescriptor> recipeList;
68+
private final List<RecipeDescriptor> recipeList;
6869

6970
@With
70-
List<DataTableDescriptor> dataTables;
71+
private final List<DataTableDescriptor> dataTables;
7172

72-
List<Maintainer> maintainers;
73+
private final List<Maintainer> maintainers;
7374

7475
@Deprecated(/* No longer populated */)
75-
List<Contributor> contributors;
76+
private final List<Contributor> contributors;
7677

77-
List<RecipeExample> examples;
78+
private final List<RecipeExample> examples;
7879

7980
@Deprecated
80-
URI source;
81+
private final URI source;
82+
83+
/**
84+
* Recipes constructed locally always pass non-null collections (see
85+
* {@link org.openrewrite.Recipe#createRecipeDescriptor()}). Polyglot RPC peers
86+
* may omit empty collections from JSON, so normalize null to empty here to
87+
* preserve the "never null" contract for collection-valued getters.
88+
*/
89+
@JsonCreator
90+
public RecipeDescriptor(
91+
@JsonProperty("name") String name,
92+
@JsonProperty("displayName") String displayName,
93+
@JsonProperty("instanceName") String instanceName,
94+
@JsonProperty("description") String description,
95+
@JsonProperty("tags") @Nullable Set<String> tags,
96+
@JsonProperty("estimatedEffortPerOccurrence") @Nullable Duration estimatedEffortPerOccurrence,
97+
@JsonProperty("options") @Nullable List<OptionDescriptor> options,
98+
@JsonProperty("preconditions") @Nullable List<RecipeDescriptor> preconditions,
99+
@JsonProperty("recipeList") @Nullable List<RecipeDescriptor> recipeList,
100+
@JsonProperty("dataTables") @Nullable List<DataTableDescriptor> dataTables,
101+
@JsonProperty("maintainers") @Nullable List<Maintainer> maintainers,
102+
@JsonProperty("contributors") @Nullable List<Contributor> contributors,
103+
@JsonProperty("examples") @Nullable List<RecipeExample> examples,
104+
@JsonProperty("source") URI source) {
105+
this.name = name;
106+
this.displayName = displayName;
107+
this.instanceName = instanceName;
108+
this.description = description;
109+
this.tags = tags == null ? emptySet() : tags;
110+
this.estimatedEffortPerOccurrence = estimatedEffortPerOccurrence;
111+
this.options = options == null ? emptyList() : options;
112+
this.preconditions = preconditions == null ? emptyList() : preconditions;
113+
this.recipeList = recipeList == null ? emptyList() : recipeList;
114+
this.dataTables = dataTables == null ? emptyList() : dataTables;
115+
this.maintainers = maintainers == null ? emptyList() : maintainers;
116+
this.contributors = contributors == null ? emptyList() : contributors;
117+
this.examples = examples == null ? emptyList() : examples;
118+
this.source = source;
119+
}
81120

82121
@Deprecated
83122
public RecipeDescriptor(String name, String displayName, String instanceName, String description,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.config;
17+
18+
import com.fasterxml.jackson.databind.ObjectMapper;
19+
import org.junit.jupiter.api.Test;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
23+
class RecipeDescriptorTest {
24+
25+
/**
26+
* Polyglot RPC peers (rewrite-javascript-remote, rewrite-csharp-remote,
27+
* rewrite-python-remote) may omit empty collection-valued fields from the
28+
* descriptor JSON they return. Java callers iterate {@code getPreconditions()},
29+
* {@code getRecipeList()}, etc. without null checks, so deserialization must
30+
* normalize missing fields to empty collections to preserve the "never null"
31+
* contract that {@link org.openrewrite.Recipe#createRecipeDescriptor()} maintains
32+
* for locally constructed descriptors.
33+
*/
34+
@Test
35+
void deserializeOmittedCollectionsAsEmpty() throws Exception {
36+
String json = """
37+
{
38+
"name": "org.example.Foo",
39+
"displayName": "Foo",
40+
"instanceName": "Foo",
41+
"description": "A recipe."
42+
}
43+
""";
44+
45+
RecipeDescriptor descriptor = new ObjectMapper().readValue(json, RecipeDescriptor.class);
46+
47+
assertThat(descriptor.getTags()).isEmpty();
48+
assertThat(descriptor.getOptions()).isEmpty();
49+
assertThat(descriptor.getPreconditions()).isEmpty();
50+
assertThat(descriptor.getRecipeList()).isEmpty();
51+
assertThat(descriptor.getDataTables()).isEmpty();
52+
assertThat(descriptor.getMaintainers()).isEmpty();
53+
assertThat(descriptor.getContributors()).isEmpty();
54+
assertThat(descriptor.getExamples()).isEmpty();
55+
}
56+
}

0 commit comments

Comments
 (0)