Skip to content

Commit 98ff822

Browse files
ctruedenclaude
andcommitted
Make object encoding and decoding extensible
See also: * apposed/appose-python@35625c3 * apposed/appose-python@ed30232 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6f76d20 commit 98ff822

File tree

1 file changed

+109
-67
lines changed

1 file changed

+109
-67
lines changed

src/main/java/org/apposed/appose/util/Messages.java

Lines changed: 109 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
import java.util.LinkedHashMap;
4141
import java.util.List;
4242
import java.util.Map;
43-
import java.util.function.BiConsumer;
43+
import java.util.concurrent.ConcurrentHashMap;
44+
import java.util.function.Function;
4445

4546
import static org.apposed.appose.NDArray.Shape.Order.C_ORDER;
4647

@@ -65,6 +66,80 @@ private Messages() {
6566
// Counter for auto-generated proxy variable names.
6667
private static int proxyCounter = 0;
6768

69+
// Registry of class -> (appose_type, encoder) for encoding custom types.
70+
private static final Map<Class<?>, String> ENCODER_TYPES = new ConcurrentHashMap<>();
71+
private static final Map<Class<?>, Function<Object, Object>> ENCODER_FNS = new ConcurrentHashMap<>();
72+
73+
// Registry of appose_type -> factory for decoding custom types.
74+
private static final Map<String, Function<Map<String, Object>, Object>> DECODERS =
75+
new ConcurrentHashMap<>();
76+
77+
/**
78+
* Registers encoder and decoder functions for a custom Appose type.
79+
* <p>
80+
* When encoding, if an object is an instance of {@code objType}, {@code encoder}
81+
* is called and its return value is wrapped as
82+
* {@code {"appose_type": apposeType, "data": <encoded>}}.
83+
* </p>
84+
* <p>
85+
* When decoding, if a JSON object has the given {@code apposeType}, {@code decoder}
86+
* is called with the {@code "data"} field value and should return the
87+
* reconstructed object.
88+
* </p>
89+
*
90+
* @param <T> The type being registered.
91+
* @param objType The class of objects to encode.
92+
* @param apposeType The {@code appose_type} string used on the wire.
93+
* @param encoder Function from object to JSON-compatible value (without appose_type).
94+
* @param decoder Function from decoded data map to reconstructed object.
95+
*/
96+
@SuppressWarnings("unchecked")
97+
public static <T> void register(
98+
Class<T> objType,
99+
String apposeType,
100+
Function<T, Object> encoder,
101+
Function<Map<String, Object>, Object> decoder
102+
) {
103+
ENCODER_TYPES.put(objType, apposeType);
104+
ENCODER_FNS.put(objType, (Function<Object, Object>) (Function<?, ?>) encoder);
105+
DECODERS.put(apposeType, decoder);
106+
}
107+
108+
static {
109+
// NB: Built-in type registrations live here rather than in their respective
110+
// classes (SharedMemory, NDArray) because Java static initializers only run
111+
// when a class is first loaded. If Messages.decode() were called before
112+
// SharedMemory or NDArray had been referenced, their decoders would not yet
113+
// be registered. Keeping registrations here ensures they are always in place
114+
// as soon as the Messages class itself is loaded.
115+
register(SharedMemory.class, "shm",
116+
shm -> {
117+
Map<String, Object> payload = new LinkedHashMap<>();
118+
payload.put("name", shm.name());
119+
payload.put("rsize", shm.rsize());
120+
return payload;
121+
},
122+
map -> SharedMemory.attach(
123+
(String) map.get("name"),
124+
((Number) map.get("rsize")).longValue()
125+
)
126+
);
127+
register(NDArray.class, "ndarray",
128+
nda -> {
129+
Map<String, Object> payload = new LinkedHashMap<>();
130+
payload.put("dtype", nda.dType().label());
131+
payload.put("shape", nda.shape().toIntArray(C_ORDER));
132+
payload.put("shm", nda.shm());
133+
return payload;
134+
},
135+
map -> new NDArray(
136+
toDType((String) map.get("dtype")),
137+
toShape((List<Integer>) map.get("shape")),
138+
(SharedMemory) map.get("shm")
139+
)
140+
);
141+
}
142+
68143
/**
69144
* Converts a Map into a JSON string.
70145
* @param data
@@ -137,13 +212,12 @@ public static String stackTrace(Throwable t) {
137212
* Map, List, String (and other CharSequences), Number, Boolean, and primitives.
138213
* </p>
139214
* <p>
140-
* Other types either have custom Appose converters (SharedMemory, NDArray) or
141-
* will be auto-proxied as worker_object references when in worker mode.
215+
* Other types either implement {@link ForJson} (and are handled by the ForJson
216+
* converter) or will be auto-proxied as worker_object references when in worker mode.
142217
* </p>
143218
* <p>
144-
* Note: This list should remain stable. When adding new Appose-specific converters
145-
* (e.g., for custom types), add them to the GENERATOR converter chain before the
146-
* catch-all converter - you do NOT need to modify this method.
219+
* Note: This list should remain stable. Types implementing {@link ForJson} are
220+
* handled before the catch-all and do not require changes here.
147221
* </p>
148222
*
149223
* @param type The class to check
@@ -158,50 +232,28 @@ private static boolean isNativelyJsonSerializable(Class<?> type) {
158232
|| type.isPrimitive();
159233
}
160234

161-
/**
162-
* Helper to create a {@link JsonGenerator.Converter}.
163-
* <p>
164-
* The converter encodes objects of a specified class {@code clz} into a
165-
* {@code Map<String, Object>}. The given {@code appose_type} string is put
166-
* into the map with key {@code "appose_type"}. The map and value to be
167-
* converted are passed to the given {@code BiConsumer} which should
168-
* serialize the value into the map somehow.
169-
* </p>
170-
*
171-
* @param clz the converter will handle objects of this class (or sub-classes)
172-
* @param appose_type the value for key "appose_type" in the returned Map
173-
* @param converter accepts a map and a value, and serializes the value into the map somehow.
174-
* @return a new converter
175-
* @param <T> object type handled by this converter
176-
*/
177-
private static <T> JsonGenerator.Converter convert(Class<T> clz, String appose_type, BiConsumer<Map<String, Object>, T> converter) {
178-
return new JsonGenerator.Converter() {
179-
180-
@Override
181-
public boolean handles(Class<?> type) {
182-
return clz.isAssignableFrom(type);
183-
}
184-
185-
@SuppressWarnings("unchecked")
186-
@Override
187-
public Object convert(Object value, String key) {
188-
Map<String, Object> map = new LinkedHashMap<>();
189-
map.put("appose_type", appose_type);
190-
converter.accept(map, (T) value);
191-
return map;
192-
}
193-
};
194-
}
195-
196235
static final JsonGenerator GENERATOR = new JsonGenerator.Options() //
197-
.addConverter(convert(SharedMemory.class, "shm", (map, shm) -> {
198-
map.put("name", shm.name());
199-
map.put("rsize", shm.rsize());
200-
})).addConverter(convert(NDArray.class, "ndarray", (map, ndArray) -> {
201-
map.put("dtype", ndArray.dType().label());
202-
map.put("shape", ndArray.shape().toIntArray(C_ORDER));
203-
map.put("shm", ndArray.shm());
204-
})).addConverter(new JsonGenerator.Converter() {
236+
.addConverter(new JsonGenerator.Converter() {
237+
@Override
238+
public boolean handles(Class<?> type) {
239+
return ENCODER_TYPES.keySet().stream().anyMatch(c -> c.isAssignableFrom(type));
240+
}
241+
242+
@Override
243+
public Object convert(Object value, String key) {
244+
for (Map.Entry<Class<?>, Function<Object, Object>> entry : ENCODER_FNS.entrySet()) {
245+
if (entry.getKey().isAssignableFrom(value.getClass())) {
246+
Map<String, Object> map = new LinkedHashMap<>();
247+
map.put("appose_type", ENCODER_TYPES.get(entry.getKey()));
248+
@SuppressWarnings("unchecked")
249+
Map<String, Object> payload = (Map<String, Object>) entry.getValue().apply(value);
250+
map.putAll(payload);
251+
return map;
252+
}
253+
}
254+
throw new IllegalStateException("No encoder for " + value.getClass());
255+
}
256+
}).addConverter(new JsonGenerator.Converter() {
205257
// Catch-all converter for non-serializable objects in worker mode.
206258
// This should be the LAST converter in the chain, so it only handles
207259
// objects that no other converter claimed.
@@ -211,8 +263,7 @@ public boolean handles(Class<?> type) {
211263
if (!workerMode) return false;
212264

213265
// Don't auto-proxy types that Groovy's JSON encoder handles natively.
214-
// Custom converters (SharedMemory, NDArray, etc.) are earlier in the
215-
// chain and will have already claimed their types.
266+
// Registered types are earlier in the chain and will have already claimed theirs.
216267
return !isNativelyJsonSerializable(type);
217268
}
218269

@@ -250,23 +301,14 @@ private static Object processValue(Object value) {
250301
Object v = map.get("appose_type");
251302
if (v instanceof String) {
252303
String appose_type = (String) v;
253-
switch (appose_type) {
254-
case "shm":
255-
String name = (String) map.get("name");
256-
long rsize = ((Number) map.get("rsize")).longValue();
257-
return SharedMemory.attach(name, rsize);
258-
case "ndarray":
259-
NDArray.DType dType = toDType((String) map.get("dtype"));
260-
NDArray.Shape shape = toShape((List<Integer>) map.get("shape"));
261-
SharedMemory shm = (SharedMemory) map.get("shm");
262-
return new NDArray(dType, shape, shm);
263-
case "worker_object":
264-
// Return map as-is; will be converted to WorkerObject
265-
// by Proxies.proxifyWorkerObjects() in Service.Task.handle().
266-
return map;
267-
default:
268-
System.err.println("unknown appose_type \"" + appose_type + "\"");
304+
if ("worker_object".equals(appose_type)) {
305+
// Return map as-is; will be converted to WorkerObject
306+
// by Proxies.proxifyWorkerObjects() in Service.Task.handle().
307+
return map;
269308
}
309+
Function<Map<String, Object>, Object> factory = DECODERS.get(appose_type);
310+
if (factory != null) return factory.apply(map);
311+
System.err.println("unknown appose_type \"" + appose_type + "\"");
270312
}
271313
return map;
272314
} else {

0 commit comments

Comments
 (0)