Limited JSON-LD serialization (the ActivityPub @context)

33 views
Skip to first unread message

Steinar Bang

unread,
Aug 17, 2024, 4:44:40 AM8/17/24
to jackso...@googlegroups.com
I'm trying to implement the ActivityPub protocol[1] in Java (the project
is here[2], but there is nothing interesting pushed yet, just an
instantiated sample project and and edited README)

And the ActivityPub protocol is kinda, sorta (i.e. "they *say* its") JSON-LD[3].

I've trawled Java JSON-LD implementations found by google searches, and
they mostly seem to be dead, obsolete and have limited interest.

But "JSON-LD" doesn't seem to be actively used in the protocol, so I am
trying to treat it as plain Java.

So that's where jackson comes in.

The "JSON-LD" part of the ActivityPub JSON consist of the single
property "@context".

This property mostly seems to be static and not actually used by the
protocol, but it *has* to be there.

The property seems to have two types of values
1. A string value holding the URL of the type[4]
2. An array value where the first item is the type URL string value,
and the second value is an object[5]

If it was just type 1 I could just serialize a constant, ignore it on
deserialization, and be done with it.

(but since I'm lazy it would be nice to get a ready-made recipe for how
to serialize to a strangely named property like "@context", in jackson?)

But I can see that I would like to deserialize type 2 because, for
nothing else, I would like to get hold of the language setting put
there.

But that gives me two issue:
1. How do I represent type 2 in java so that it can be serialized the
way shown?
2. How to I deserialize type 2 so that I can get hold of the values in
the object?

All ideas and pointers appreciated!

Thanks!


- Steinar

References:
[1] <https://w3c.github.io/activitypub/>
[2] <https://github.com/steinarb/ratatoskr>
[3] <https://en.wikipedia.org/wiki/JSON-LD>
[4] <https://w3c.github.io/activitypub/#example-1>
[5] <https://w3c.github.io/activitypub/#example-8>

Steinar Bang

unread,
Oct 25, 2024, 9:35:15 AM10/25/24
to jackso...@googlegroups.com
Here's the head of a mastodon thread for the same thing, but I haven't
had any responses there, so I'm trying here.
https://mastodon.social/@steinarb/113364650965101732

I'm mostly hoping for some pointers to magic for how to parse either a String or an object type for a property (right now an object in the field turns into a hashmap).

I haven't pushed the code yet but I can do so (note that the code is very much in earlt progress. I'm only at example 9 of a document that has 155 examples)

And here is the contents of the thread:

I am currently trying to create a Java 21 sealed interface hierarchy for activitystreams objects, terminating in records for the leaf nodes I need to instantiate.

The idea is to have something jackson can automatically parse where it's easy to write logic around the parse result using Java pattern matching.

I'm building the hierarchy gradually by parsing the example files of https://www.w3.org/TR/activitystreams-vocabulary/

I'm currently stuck at Example 9 https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept

The problem is the "actor" property of the "Invite" object.

What I have is Link or Collection (which both are sealed interfaces in the same hierarchy), but here I have a String holding a URL.

So I'm pondering what to do?

Rewrite the actor in the JSON file to be a Link?

Or figure out some way to have a String here?

How common is it to have just a String with a URL where activitystreams expect a Link?
(I don't know the answer to this, other than the example not matching the spec for the property)

I tried switching actor() from the sealed interface base type LinkOrObject to just java.lang.Object.

Then everything parsed but the pattern matching was much less nice, since instead of a proper type, object values are hashmaps.

Are there some jackson annotations that can tell it to either parse the field as a String (if that is what it is) or use LinkOrObject if it is an object?

Or is it possible to make jackson turn a string value into something like this...?
{
"type": "Link",
"href": "<string value here>"
}

Steinar Bang

unread,
Oct 25, 2024, 9:42:29 AM10/25/24
to jackso...@googlegroups.com
Here is one of the unit tests for reading a file:

@Test
void testParseExample03() throws Exception {
LinkOrObject object = mapper.readValue(refactorExample("example_03.json"), LinkOrObject.class);
switch(object) {
case Activity activity -> {
assertThat(activity.summary()).isEqualTo("Sally did something to a note");
switch (activity.actor()) {
case Person person -> assertThat(person.name()).isEqualTo("Sally");
default -> fail("Did not get the expected type for activity.actor");
}
switch (activity.object()) {
case Note note -> assertThat(note.name()).isEqualTo("A Note");
default -> fail("Did not get the expected type for activity.object");
}
}
default -> fail("Did not get the expected type when parsing");
}
}

Here is the LinkOrObject top level interface (the instantiatable values
are all records, the intermediates are interfaces):

@JsonTypeInfo(use=JsonTypeInfo.Id.NAME, include=JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "type" )
@JsonSubTypes({
@Type(value = ActivityStreamObjectRecord.class, name = ActivityStreamObjectType.Names.OBJECT),
@Type(value = ActivityRecord.class, name = ActivityStreamObjectType.Names.ACTIVITY),
@Type(value = Accept.class, name = ActivityStreamObjectType.Names.ACCEPT),
@Type(value = Invite.class, name = ActivityStreamObjectType.Names.INVITE),
@Type(value = Travel.class, name = ActivityStreamObjectType.Names.TRAVEL),
@Type(value = CollectionRecord.class, name = ActivityStreamObjectType.Names.COLLECTION),
@Type(value = OrderedCollectionRecord.class, name = ActivityStreamObjectType.Names.ORDERED_COLLECTION),
@Type(value = CollectionPage.class, name = ActivityStreamObjectType.Names.COLLECTION_PAGE),
@Type(value = OrderedCollectionPage.class, name = ActivityStreamObjectType.Names.ORDERED_COLLECTION_PAGE),
@Type(value = Person.class, name = ActivityStreamObjectType.Names.PERSON),
@Type(value = NoteRecord.class, name = ActivityStreamObjectType.Names.NOTE),
@Type(value = Place.class, name = ActivityStreamObjectType.Names.PLACE),
@Type(value = Link.class, name = ActivityStreamObjectType.Names.LINK)
})
public sealed interface LinkOrObject permits Link, ActivityStreamObject {

public ActivityStreamObjectType type();

}

ActivityStreamObjectType is an enum defined this way:

public enum ActivityStreamObjectType {
Object(Names.OBJECT),
Activity(Names.ACTIVITY),
Accept(Names.ACCEPT),
Invite(Names.INVITE),
Travel(Names.TRAVEL),
Collection(Names.COLLECTION),
OrderedCollection(Names.ORDERED_COLLECTION),
CollectionPage(Names.COLLECTION_PAGE),
OrderedCollectionPage(Names.ORDERED_COLLECTION_PAGE),
Application(Names.APPLICATION),
Group(Names.GROUP),
Organization(Names.ORGANIZATION),
Person(Names.PERSON),
Service(Names.SERVICE),
Note(Names.NOTE),
Place(Names.PLACE),
Link(Names.LINK);

public class Names {
public static final String OBJECT = "Object";
public static final String ACTIVITY = "Activity";
public static final String ACCEPT = "Accept";
public static final String INVITE = "Invite";
public static final String TRAVEL = "Travel";
public static final String COLLECTION = "Collection";
public static final String ORDERED_COLLECTION = "OrderedCollection";
public static final String COLLECTION_PAGE = "CollectionPage";
public static final String ORDERED_COLLECTION_PAGE = "OrderedCollectionPage";
public static final String APPLICATION = "Application";
public static final String GROUP = "Group";
public static final String ORGANIZATION = "Organization";
public static final String PERSON = "Person";
public static final String SERVICE = "Service";
public static final String NOTE = "Note";
public static final String PLACE = "Place";
public static final String LINK = "Link";
}

private final String label;

private ActivityStreamObjectType(String label) {
this.label = label;
}

public String toString() {
return label;
}
}

Here is an instantiable record type:

public record CollectionPage(
@JsonGetter("@context") Object context,
ActivityStreamObjectType type,
String id,
String name,
String summary,
int totalItems,
List<LinkOrObject> items,
String partOf
) implements Collection
{
}

Joo Hyuk Kim (Vince)

unread,
Oct 25, 2024, 10:11:52 AM10/25/24
to jackson-user
Some notes to help you with more chance of finding your answers.

1. Try over at Github discussion of Jackson I guess https://github.com/FasterXML/jackson-databind/discussions
2. Simplify your usage question. Try sharing the simplest form of reproduction or how you want it to work. With...
2.a. ObjectMapper settings
2.b. Input JSON String (Simplified)
2.c. Expected result obejct and value of its fields (Even better with JUnit 5 assertions)

Hope this helps.
Cheers

Joo Hyuk, Kim

Steinar Bang

unread,
Oct 25, 2024, 10:33:10 AM10/25/24
to jackso...@googlegroups.com
>>>>> Steinar Bang <s...@dod.no>:

> Here's the head of a mastodon thread for the same thing, but I haven't
> had any responses there, so I'm trying here.
> https://mastodon.social/@steinarb/113364650965101732

> I'm mostly hoping for some pointers to magic for how to parse either a String or an object type for a property (right now an object in the field turns into a hashmap).
[snip!]
> I tried switching actor() from the sealed interface base type LinkOrObject to just java.lang.Object.

> Then everything parsed but the pattern matching was much less nice, since instead of a proper type, object values are hashmaps.

> Are there some jackson annotations that can tell it to either parse the field as a String (if that is what it is) or use LinkOrObject if it is an object?

> Or is it possible to make jackson turn a string value into something like this...?
> {
> "type": "Link",
> "href": "<string value here>"
> }

@JsonDerserialize to set a custom deserializer on the custom property
looks promising
https://www.baeldung.com/jackson-annotations#5-jsondeserialize

Ie change this in the Java interface
LinkOrObject target()

to something like this?
@JsonDeserialize(using=StringIntoLinkDeserializer.class)
java.lang.Object target()

Maybe...?

Or maybe keep the original type (since now I'll force a string value
into that object?)
@JsonDeserialize(using=StringIntoLinkDeserializer.class)
LinkOrObject target()

(no clear idea what StringIntoLinkDeserializer should look like, but it
would be nice to leverage the default deserializing of LinkOrObject and
just revert to custom behaviour if the json value of target() is a
string value)

Steinar Bang

unread,
Oct 25, 2024, 4:03:24 PM10/25/24
to jackso...@googlegroups.com
Figured out a way: @JsonDeserialize with a converter on the value that
can be both a string value and an object:

Like so:
public record Invite(
@JsonGetter("@context") Object context,
ActivityStreamObjectType type,
String id,
String name,
String summary,
@JsonDeserialize(converter = StringToLinkConverter.class)
LinkOrObject actor,
LinkOrObject target,
ActivityStreamObject object
) implements Offer
{
}

The StringToLinkConverter is pretty simple:

public class StringToLinkConverter extends StdConverter<String, Link> {
@Override
public Link convert(String value) {
return Link.with().href(value).build();
}
}

Here is the Link record and its builder class:
https://gist.github.com/steinarb/47e5f588790675c60f109ba70e3eabe6

Steinar Bang

unread,
Oct 26, 2024, 8:03:53 AM10/26/24
to jackso...@googlegroups.com
>>>>> Steinar Bang <s...@dod.no>:

> Figured out a way: @JsonDeserialize with a converter on the value that
> can be both a string value and an object:

> Like so:
> public record Invite(
> @JsonGetter("@context") Object context,
> ActivityStreamObjectType type,
> String id,
> String name,
> String summary,
> @JsonDeserialize(converter = StringToLinkConverter.class)
> LinkOrObject actor,
> LinkOrObject target,
> ActivityStreamObject object
> ) implements Offer
> {
> }

Looks like I was celebrating too early: this works when "actor" is a
string, but does not work when "actor" is an object.

Then I get
com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.String` from Object value (token `JsonToken.START_OBJECT`)

I've tried using a custom deserializer but
@JsonDeserialize(using = MyCustomDeserializer.class)
on a record field (like above), doesn't seem to have any effect: the
custom deserializer's deserialize() method is never called when I put a
breakpoint there.

Hm...

Tatu Saloranta

unread,
Oct 28, 2024, 10:13:04 PM10/28/24
to jackso...@googlegroups.com
Wrt Converter: nominal type of converter must match, so it cannot be
declared as `String` to work for both. What can be used, however, are:

1. java.lang.Object -- if so, Converter gets either `String` or `Map`
("natural" mapping)
2. JsonNode -- probably more convenient

This does leave the question of why a plain deserializer would not
work (not get called).

-+ Tatu +-

ps. Jackson 2.18.1 was just released so can test against that, no need
for SNAPSHOT version



> --
> You received this message because you are subscribed to the Google Groups "jackson-user" group.
> To unsubscribe from this group and stop receiving emails from it, send an email to jackson-user...@googlegroups.com.
> To view this discussion visit https://groups.google.com/d/msgid/jackson-user/87msirvzok.fsf%40dod.no.

Steinar Bang

unread,
Oct 29, 2024, 11:57:24 AM10/29/24
to jackso...@googlegroups.com
>>>>> Tatu Saloranta <tsalo...@gmail.com>:

> This does leave the question of why a plain deserializer would not
> work (not get called).
[snip!]
> ps. Jackson 2.18.1 was just released so can test against that, no need
> for SNAPSHOT version

I bumped jackson to 2.18.1 on both branches of the test case:
https://github.com/steinarb/record-field-deserializer-repro

No luck unfortunately! Custom deserializer on record field still not called. :-/

Steinar Bang

unread,
Oct 29, 2024, 2:09:26 PM10/29/24
to jackso...@googlegroups.com
>>>>> Tatu Saloranta <tsalo...@gmail.com>:

> Wrt Converter: nominal type of converter must match, so it cannot be
> declared as `String` to work for both. What can be used, however, are:

> 1. java.lang.Object -- if so, Converter gets either `String` or `Map`
> ("natural" mapping)
> 2. JsonNode -- probably more convenient

Ok, this worked:
https://github.com/steinarb/record-field-deserializer-repro/blob/use-converter-instead-of-deserializer/src/main/java/no/priv/bang/ratatoskr/asvocabulary/StringToLinkConverter.java#L21
https://github.com/steinarb/record-field-deserializer-repro/blob/use-converter-instead-of-deserializer/src/main/java/no/priv/bang/ratatoskr/asvocabulary/Add.java#L31

All 13 tests pass now.

The reserialization of the Map followed by a new deserialization is a
bit wasteful, but it was the only one I could think about that leveraged
the existing parsing logic.

Tatu Saloranta

unread,
Oct 29, 2024, 8:40:41 PM10/29/24
to jackso...@googlegroups.com
Minor optimization here would be to use `ObjectMapper.convertValue()`:
it does logically same as write-then-deserialize, but skips actual
low-level encoding/decoding, building a `TokenBuffer`.
So it is more efficient for use cases like this.

Hope this helps,

-+ Tatu +-

>
> --
> You received this message because you are subscribed to the Google Groups "jackson-user" group.
> To unsubscribe from this group and stop receiving emails from it, send an email to jackson-user...@googlegroups.com.
> To view this discussion visit https://groups.google.com/d/msgid/jackson-user/87y126u6gp.fsf%40dod.no.

Steinar Bang

unread,
Oct 30, 2024, 12:03:27 PM10/30/24
to jackso...@googlegroups.com
>>>>> Tatu Saloranta <ta...@fasterxml.com>:

> On Tue, Oct 29, 2024 at 11:09 AM Steinar Bang <s...@dod.no> wrote:

>> The reserialization of the Map followed by a new deserialization is a
>> bit wasteful, but it was the only one I could think about that leveraged
>> the existing parsing logic.

> Minor optimization here would be to use `ObjectMapper.convertValue()`:
> it does logically same as write-then-deserialize, but skips actual
> low-level encoding/decoding, building a `TokenBuffer`.
> So it is more efficient for use cases like this.

Nice! Worked like a charm!
https://github.com/steinarb/record-field-deserializer-repro/blob/use-converter-instead-of-deserializer/src/main/java/no/priv/bang/ratatoskr/asvocabulary/StringToLinkConverter.java#L21

(example has been updated)

Reply all
Reply to author
Forward
0 new messages