Skip to content

Commit d8932c1

Browse files
author
Mark Pollack
committed
Javadoc: JSON mappers and test kit (pass 1, batch B10)
1 parent 00144d6 commit d8932c1

9 files changed

Lines changed: 563 additions & 351 deletions

File tree

‎acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapper.java‎

Lines changed: 88 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -10,118 +10,138 @@
1010
import org.jspecify.annotations.Nullable;
1111

1212
/**
13-
* Abstraction for JSON serialization/deserialization to decouple the SDK from any
14-
* specific JSON library.
13+
* Reads and writes the JSON of ACP messages for the SDK, so that acp-core itself depends on no JSON
14+
* library. The transports use it to write each message they send, to read each message that
15+
* arrives, and to convert a message's params or result into the
16+
* {@link com.agentclientprotocol.sdk.spec.AcpSchema} record the method expects. Most applications
17+
* never call it: a transport created without a mapper gets one from {@link #createDefault()}.
1518
*
16-
* <p>
17-
* {@code acp-core} contains no implementation. One comes from a JSON module on the
18-
* classpath: {@code acp-json-jackson2} (Jackson 2, which the transport and agent-support
19-
* modules bring in) or {@code acp-json-jackson3} (Jackson 3). Each registers an
20-
* {@link AcpJsonMapperSupplier} discovered via {@link ServiceLoader}.
21-
* </p>
19+
* <p>The implementation comes from a JSON module on the classpath: {@code acp-json-jackson2}
20+
* ({@code JacksonAcpJsonMapper}, Jackson 2) or {@code acp-json-jackson3}
21+
* ({@code Jackson3AcpJsonMapper}, Jackson 3). {@code acp-agent-support},
22+
* {@code acp-streamable-http-jetty} and {@code acp-test} bring in {@code acp-json-jackson2}; with
23+
* acp-core alone, add one of the two. Each module registers an {@link AcpJsonMapperSupplier}, and
24+
* {@link #createDefault()} picks one. To configure Jackson yourself, create the module's mapper
25+
* around your own Jackson mapper and pass it to the transport's constructor.
2226
*
23-
* <p>
24-
* Alternative implementations (e.g., for Micronaut serialization) can be provided by
25-
* implementing {@link AcpJsonMapperSupplier} and registering it in
26-
* {@code META-INF/services/com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier}.
27-
* </p>
27+
* <p>Implementations: the schema records carry Jackson annotations ({@code @JsonProperty},
28+
* {@code @JsonInclude}, {@code @JsonTypeInfo}, {@code @JsonSubTypes}, {@code @JsonCreator},
29+
* {@code @JsonValue}, {@code @JsonAnyGetter} and {@code @JsonAnySetter}), so a mapper must honour
30+
* them to read and write ACP. A transport calls its mapper from several threads at once, so it must
31+
* be safe for concurrent use. Reads and writes report failures as {@link IOException}; the SDK
32+
* answers text that is not JSON with a parse error ({@code -32700}). A conversion whose value does
33+
* not fit the target type must fail with {@link IllegalArgumentException}, which the SDK answers as
34+
* invalid params ({@code -32602}). Register an implementation through an
35+
* {@link AcpJsonMapperSupplier}.
2836
*
2937
* @author Mark Pollack
3038
*/
3139
public interface AcpJsonMapper {
3240

3341
/**
34-
* Creates a default AcpJsonMapper from the {@link AcpJsonMapperSupplier}s found via
35-
* {@link ServiceLoader}. The choice is deterministic:
42+
* Returns a new mapper from the {@link AcpJsonMapperSupplier}s that {@link ServiceLoader} finds
43+
* with the current thread's context class loader. The choice does not depend on classpath
44+
* order:
3645
* <ol>
37-
* <li>if the system property {@code acp.json.mapper.supplier} is set, the supplier
38-
* with exactly that fully qualified class name is used (and it is an error if there is
39-
* none);</li>
40-
* <li>otherwise the supplier with the highest {@link AcpJsonMapperSupplier#priority()}
41-
* is used, ties broken by class name. With both SDK modules present,
42-
* {@code acp-json-jackson3} (priority -100) wins over {@code acp-json-jackson2}
43-
* (-200); an application supplier that keeps the default priority (0) wins over
44-
* both.</li>
46+
* <li>if the system property {@code acp.json.mapper.supplier} is set (and not blank), the
47+
* supplier whose class has exactly that fully qualified name is used;</li>
48+
* <li>otherwise the supplier with the highest {@link AcpJsonMapperSupplier#priority()} is used,
49+
* ties broken by class name. With both SDK modules present, {@code acp-json-jackson3} (priority
50+
* {@code -100}) wins over {@code acp-json-jackson2} ({@code -200}); an application supplier
51+
* that keeps the default priority ({@code 0}) wins over both.</li>
4552
* </ol>
46-
* @return a new AcpJsonMapper instance
47-
* @throws java.util.ServiceConfigurationError if no supplier is found, or none
48-
* matches the system property
53+
*
54+
* <p>Each call looks the suppliers up again and asks the chosen one for a mapper; the SDK's
55+
* suppliers build a new one every time, so keep the result rather than calling this per
56+
* message.
57+
* @return the chosen supplier's mapper
58+
* @throws java.util.ServiceConfigurationError if no supplier is found, or none has the class
59+
* named by the system property
60+
* @throws IllegalStateException if the chosen SDK module finds a Jackson older than it supports
61+
* (2.18.1 for Jackson 2, 3.0.0 for Jackson 3)
4962
*/
5063
static AcpJsonMapper createDefault() {
5164
return AcpJsonMapperSelector.select().get();
5265
}
5366

5467
/**
55-
* Deserialize JSON string into a target type.
56-
* @param content JSON as String
57-
* @param type target class
58-
* @return deserialized instance, or {@code null} when the JSON is the {@code null} literal
59-
* @param <T> generic type
60-
* @throws IOException on parse errors
68+
* Reads a JSON text as the given type.
69+
* @param content the JSON text
70+
* @param type the type to read
71+
* @param <T> the type to read
72+
* @return the value, or {@code null} when the text is the JSON literal {@code null}
73+
* @throws IOException if the text is not JSON or does not fit the type
6174
*/
6275
<T> @Nullable T readValue(String content, Class<T> type) throws IOException;
6376

6477
/**
65-
* Deserialize JSON bytes into a target type.
66-
* @param content JSON as bytes
67-
* @param type target class
68-
* @return deserialized instance, or {@code null} when the JSON is the {@code null} literal
69-
* @param <T> generic type
70-
* @throws IOException on parse errors
78+
* Reads JSON bytes as the given type, like {@link #readValue(String, Class)}.
79+
* @param content the JSON bytes
80+
* @param type the type to read
81+
* @param <T> the type to read
82+
* @return the value, or {@code null} when the bytes are the JSON literal {@code null}
83+
* @throws IOException if the bytes are not JSON or do not fit the type
7184
*/
7285
<T> @Nullable T readValue(byte[] content, Class<T> type) throws IOException;
7386

7487
/**
75-
* Deserialize JSON string into a parameterized target type.
76-
* @param content JSON as String
77-
* @param type parameterized type reference
78-
* @return deserialized instance, or {@code null} when the JSON is the {@code null} literal
79-
* @param <T> generic type
80-
* @throws IOException on parse errors
88+
* Reads a JSON text as a generic type, such as {@code List<AcpSchema.ContentBlock>}, that a
89+
* {@link TypeRef} captures.
90+
* @param content the JSON text
91+
* @param type the type to read
92+
* @param <T> the type to read
93+
* @return the value, or {@code null} when the text is the JSON literal {@code null}
94+
* @throws IOException if the text is not JSON or does not fit the type
8195
*/
8296
<T> @Nullable T readValue(String content, TypeRef<T> type) throws IOException;
8397

8498
/**
85-
* Deserialize JSON bytes into a parameterized target type.
86-
* @param content JSON as bytes
87-
* @param type parameterized type reference
88-
* @return deserialized instance, or {@code null} when the JSON is the {@code null} literal
89-
* @param <T> generic type
90-
* @throws IOException on parse errors
99+
* Reads JSON bytes as a generic type that a {@link TypeRef} captures, like
100+
* {@link #readValue(String, TypeRef)}.
101+
* @param content the JSON bytes
102+
* @param type the type to read
103+
* @param <T> the type to read
104+
* @return the value, or {@code null} when the bytes are the JSON literal {@code null}
105+
* @throws IOException if the bytes are not JSON or do not fit the type
91106
*/
92107
<T> @Nullable T readValue(byte[] content, TypeRef<T> type) throws IOException;
93108

94109
/**
95-
* Convert a value to a given type, useful for mapping nested JSON structures.
96-
* @param fromValue source value
97-
* @param type target class
98-
* @return converted value
99-
* @param <T> generic type
110+
* Converts a value to the given type as if it were written as JSON and read back. The
111+
* transports use it to turn the params or result of a message, read as maps and lists, into the
112+
* record the method expects.
113+
* @param fromValue the value, usually maps and lists read from JSON
114+
* @param type the type to convert to
115+
* @param <T> the type to convert to
116+
* @return the converted value
117+
* @throws IllegalArgumentException if the value does not fit the type
100118
*/
101119
<T> T convertValue(Object fromValue, Class<T> type);
102120

103121
/**
104-
* Convert a value to a given parameterized type.
105-
* @param fromValue source value
106-
* @param type target type reference
107-
* @return converted value
108-
* @param <T> generic type
122+
* Converts a value to a generic type that a {@link TypeRef} captures, like
123+
* {@link #convertValue(Object, Class)}.
124+
* @param fromValue the value, usually maps and lists read from JSON
125+
* @param type the type to convert to
126+
* @param <T> the type to convert to
127+
* @return the converted value
128+
* @throws IllegalArgumentException if the value does not fit the type
109129
*/
110130
<T> T convertValue(Object fromValue, TypeRef<T> type);
111131

112132
/**
113-
* Serialize an object to JSON string.
114-
* @param value object to serialize
115-
* @return JSON as String
116-
* @throws IOException on serialization errors
133+
* Writes a value, such as a JSON-RPC message, as JSON text.
134+
* @param value the value to write
135+
* @return the JSON text
136+
* @throws IOException if the value cannot be written as JSON
117137
*/
118138
String writeValueAsString(Object value) throws IOException;
119139

120140
/**
121-
* Serialize an object to JSON bytes.
122-
* @param value object to serialize
123-
* @return JSON as bytes
124-
* @throws IOException on serialization errors
141+
* Writes a value as JSON bytes in UTF-8.
142+
* @param value the value to write
143+
* @return the JSON bytes
144+
* @throws IOException if the value cannot be written as JSON
125145
*/
126146
byte[] writeValueAsBytes(Object value) throws IOException;
127147

‎acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSupplier.java‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,40 @@
77
import java.util.function.Supplier;
88

99
/**
10-
* Strategy interface for providing an {@link AcpJsonMapper} implementation.
10+
* Supplies the {@link AcpJsonMapper} that {@link AcpJsonMapper#createDefault()} returns. Each JSON
11+
* module registers one with {@link java.util.ServiceLoader}, and {@code createDefault()} picks one
12+
* by {@link #priority()}. Implement it to plug in a JSON library the SDK does not ship, or a
13+
* Jackson mapper configured your way for every transport that creates its own mapper.
1114
*
12-
* <p>
13-
* Implementations are discovered at runtime via {@link java.util.ServiceLoader}.
14-
* To register an implementation, create a file at
15-
* {@code META-INF/services/com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier}
16-
* containing the fully qualified class name of the supplier.
17-
* </p>
15+
* <p>To register an implementation, list its fully qualified class name in
16+
* {@code META-INF/services/com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier}; ServiceLoader
17+
* needs a public class with a public no-argument constructor. With the default priority it wins
18+
* over the SDK's own suppliers. The system property {@code acp.json.mapper.supplier} chooses a
19+
* supplier by class name instead (see {@link AcpJsonMapper#createDefault()}).
1820
*
19-
* <p>
20-
* When several suppliers are on the classpath, {@link AcpJsonMapper#createDefault()}
21-
* picks the one with the highest {@link #priority()}; see there for the full rule.
22-
* </p>
21+
* <p>Implementations: {@link #get()} is called on every {@code createDefault()} call, and the
22+
* mapper it returns must meet the {@link AcpJsonMapper} requirements, including use from several
23+
* threads at once. {@link #priority()} returns {@link #DEFAULT_PRIORITY} unless overridden.
2324
*
2425
* @author Mark Pollack
2526
* @see AcpJsonMapper#createDefault()
2627
*/
2728
public interface AcpJsonMapperSupplier extends Supplier<AcpJsonMapper> {
2829

2930
/**
30-
* The priority of a supplier that does not override {@link #priority()}. It is higher
31-
* than the priority of both SDK-provided suppliers, so an application's own supplier
32-
* wins over them without further configuration.
31+
* The priority of a supplier that does not override {@link #priority()}: {@value}. It is higher
32+
* than the priority of both SDK suppliers, so an application's own supplier wins over them
33+
* without further configuration.
3334
*/
3435
int DEFAULT_PRIORITY = 0;
3536

3637
/**
37-
* This supplier's priority when more than one supplier is on the classpath: the
38-
* highest value wins. The SDK's own suppliers use negative values ({@code
39-
* acp-json-jackson2} is {@code -200}, {@code acp-json-jackson3} is {@code -100}).
40-
* @return the priority, {@link #DEFAULT_PRIORITY} unless overridden
38+
* Returns this supplier's rank when {@link AcpJsonMapper#createDefault()} finds more than one:
39+
* the highest wins, ties broken by class name. The SDK's suppliers use negative values,
40+
* {@code -200} for {@code acp-json-jackson2} and {@code -100} for {@code acp-json-jackson3}.
41+
* The system property {@code acp.json.mapper.supplier}, when set, decides instead.
42+
* @implSpec Returns {@link #DEFAULT_PRIORITY}.
43+
* @return the priority
4144
*/
4245
default int priority() {
4346
return DEFAULT_PRIORITY;

‎acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapper.java‎

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,20 @@
2323
import org.slf4j.LoggerFactory;
2424

2525
/**
26-
* Jackson 2 implementation of {@link AcpJsonMapper}, shipped in {@code acp-json-jackson2}.
27-
* Wraps a Jackson {@link ObjectMapper} but keeps the SDK decoupled from Jackson at the API
28-
* level. It keeps the package it had when it lived in {@code acp-core}, so code that
29-
* constructs it compiles unchanged. It needs Jackson 2.18.1 or later (jackson-core and
30-
* jackson-databind), which it checks when it is created.
26+
* The Jackson 2 {@link AcpJsonMapper}, from the {@code acp-json-jackson2} module: it reads and
27+
* writes ACP messages with a Jackson {@link ObjectMapper}. {@link AcpJsonMapper#createDefault()}
28+
* returns one built on {@link #defaultObjectMapper()} when this is the only JSON module on the
29+
* classpath; create one yourself to give a transport an {@code ObjectMapper} you configured. Use
30+
* {@code acp-json-jackson3} instead when the application already runs on Jackson 3.
31+
*
32+
* <p>It needs jackson-core and jackson-databind 2.18.1 or later, and checks both when it is
33+
* created: an older Jackson 2 fails then with an {@link IllegalStateException} that names the
34+
* versions found, not later while reading a message. It lives in the package of
35+
* {@link AcpJsonMapper}, which acp-core and this module share.
36+
*
37+
* <p>Reads and writes fail with Jackson's {@link IOException} subclasses, and conversions with
38+
* {@link IllegalArgumentException}, as {@link AcpJsonMapper} requires. It is safe for concurrent
39+
* use as long as the {@code ObjectMapper} is not reconfigured once in use.
3140
*
3241
* @author Mark Pollack
3342
*/
@@ -38,27 +47,26 @@ public final class JacksonAcpJsonMapper implements AcpJsonMapper {
3847
private final ObjectMapper objectMapper;
3948

4049
/**
41-
* The {@link ObjectMapper} the SDK uses by default: <em>lenient</em> about unknown
42-
* properties, because the ACP specification adds fields between releases and an agent
43-
* newer than this SDK must keep working. Each unknown property is logged once per
44-
* occurrence at DEBUG under this class's logger, so spec drift is observable without
45-
* making the wire strict.
50+
* Returns a new {@link ObjectMapper} set up the way the SDK reads and writes ACP; the module's
51+
* supplier builds its mapper on it. It differs from a bare {@code new ObjectMapper()} in two
52+
* ways:
53+
* <ul>
54+
* <li>unknown properties are skipped, not rejected, because the ACP specification adds fields
55+
* between releases and an agent newer than this SDK must keep working. Each skipped property is
56+
* logged at DEBUG under this class's logger, so drift can be seen without making the wire
57+
* strict;</li>
58+
* <li>a scalar of the wrong JSON type is refused rather than coerced (a number or boolean where
59+
* a string is expected, a string where a number or boolean is expected): the SDK answers such
60+
* params {@code -32602} (Invalid params), and a coerced value would hide a peer's bug.</li>
61+
* </ul>
4662
*
47-
* <p>
48-
* Strictness is the mapper's decision, not the schema's: the schema records carry no
63+
* <p>Strictness is the mapper's choice, not the schema's: the schema records carry no
4964
* {@code @JsonIgnoreProperties}, so a mapper with
50-
* {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} enabled (Jackson's own
51-
* default for a bare {@code new ObjectMapper()}) fails on the first unknown field.
52-
* Consumers who want their own configuration on top of the SDK's defaults should start
53-
* from this method's result.
54-
* </p>
55-
* <p>
56-
* A scalar of the wrong JSON type is refused rather than coerced (a number or boolean for
57-
* a string, a string for a number or boolean): JSON-RPC answers such params -32602 Invalid
58-
* params, and a coerced value would hide a peer's bug.
59-
* </p>
60-
*
61-
* @return a new, independently configurable lenient mapper
65+
* {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} enabled (a bare
66+
* {@code ObjectMapper}'s default) fails on the first unknown field. To customise the SDK's
67+
* settings, start from this method's result and pass it to
68+
* {@link #JacksonAcpJsonMapper(ObjectMapper)}.
69+
* @return a new mapper, shared with no other caller
6270
*/
6371
public static ObjectMapper defaultObjectMapper() {
6472
return JsonMapper.builder()
@@ -91,11 +99,13 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p,
9199
}
92100

93101
/**
94-
* Constructs a new JacksonAcpJsonMapper with the given ObjectMapper.
95-
* @param objectMapper the ObjectMapper to use. Must not be null.
96-
* @throws IllegalArgumentException if the provided ObjectMapper is null.
97-
* @throws IllegalStateException if the Jackson 2 on the classpath is older than the supported
98-
* floor (see the type comment)
102+
* Creates a mapper that reads and writes with the given {@code ObjectMapper}, used as is: none
103+
* of the settings of {@link #defaultObjectMapper()} are added.
104+
* @param objectMapper the Jackson mapper; start from {@link #defaultObjectMapper()} to keep the
105+
* SDK's settings
106+
* @throws IllegalArgumentException if {@code objectMapper} is null
107+
* @throws IllegalStateException if the jackson-core or jackson-databind on the classpath is
108+
* older than 2.18.1
99109
*/
100110
public JacksonAcpJsonMapper(ObjectMapper objectMapper) {
101111
JacksonVersions.requireSupported();
@@ -106,8 +116,9 @@ public JacksonAcpJsonMapper(ObjectMapper objectMapper) {
106116
}
107117

108118
/**
109-
* Returns the underlying Jackson {@link ObjectMapper}.
110-
* @return the ObjectMapper instance
119+
* Returns the {@code ObjectMapper} this mapper reads and writes with: the one given to the
120+
* constructor, not a copy.
121+
* @return the Jackson mapper
111122
*/
112123
public ObjectMapper getObjectMapper() {
113124
return objectMapper;

0 commit comments

Comments
 (0)