From JSON Chaos to AI-Powered Memes: A Quarkus + Jackson Adventure
Learn how to clean messy JSON, validate inputs, and enrich responses with LangChain4j and a local Ollama model.
Memes are the internet’s universal language. Underneath the fun, they’re just structured metadata: a title, an image, some tags, and maybe a rating. That makes them the perfect teaching tool for JSON handling in Quarkus.
In this tutorial, you’ll build a JSON-first service that accepts “messy” client JSON, normalizes it with Jackson, validates it, and stores it. To make it more fun, we’ll also connect to a local LLM with LangChain4j + Ollama, so each meme automatically gets witty captions and suggested tags.
By the end, you’ll have a working Quarkus service you can curl against, complete with clean JSON APIs, validation errors, and AI-enriched results.
Why this matters
Most enterprise APIs spend as much time cleaning up and shaping JSON as they do implementing business logic. Clients send inconsistent property names, bad dates, missing fields, or tags in the wrong format. Jackson in Quarkus makes it easy to accept these messy inputs and return clean JSON.
On top of that, AI-powered enrichment is increasingly common. For example, an AI model can generate product descriptions, suggest tags, or flag potentially unsafe content. Our meme service demonstrates both sides: strong JSON handling and playful AI enrichment.
Prerequisites and setup
Before writing a single line of code, make sure your development environment is ready:
Java 17 or newer (Quarkus requires modern Java)
Maven (we’ll use it for builds and dependency management)
Podman (or Docker, if you prefer) to run Ollama
Quarkus CLI (optional, but speeds up project bootstrapping)
With that ready, bootstrap the project:
quarkus create app com.example:meme-service \
-x rest-jackson,hibernate-validator,smallrye-openapi,langchain4j-ollama,quarkus-smallrye-health
cd meme-service
If you prefer Maven without the CLI, create a standard Quarkus app and add the listed extensions to your pom.xml
.
Here we pull in:
rest-jackson for JSON (de)serialization
hibernate-validator for JSON validation with annotations
smallrye-openapi for auto-generated API docs
smallrye-health for an ootb health endpoint (learn more about it in my other post!)
langchain4j and langchain4j-ollama to talk to a local model
This gives us the bare bones of a meme API ready to grow.
Configuration
Quarkus centralizes config in application.properties
. We’ll configure three things:
Jackson defaults (ignore unknown fields, pretty-print responses).
LangChain4j Ollama (model, timeout).
A customer property to enable or disable the LLM call.
src/main/resources/application.properties
# Jackson defaults: consistent JSON style
quarkus.jackson.fail-on-unknown-properties=false
# LangChain4j + Ollama
quarkus.langchain4j.ollama.chat-model.model-id=llama3
quarkus.langchain4j.timeout=10s
# For tests or CI where you don't want to hit the LLM
meme.ai.enabled=true
This setup makes the service resilient. Unknown JSON won’t break the API, and you can quickly turn off AI in test environments.
Domain model and Jackson shaping
Let’s define what a meme looks like in code. The tricky part is making the model flexible enough to accept odd JSON while still enforcing rules.
Titles must not be blank.
Image URLs can come under multiple aliases (
img
,image
,image_url
).Tags can be sent as a list or as a comma-separated string.
Dates may appear as epoch millis, ISO timestamps, or plain
yyyy-MM-dd
.
By using Jackson annotations and a custom deserializer, we’ll make this work smoothly.
src/main/java/com/example/model/Meme.java
package com.example.model;
import com.example.json.LenientInstantDeserializer;
import com.fasterxml.jackson.annotation.*;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.*;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Meme {
@JsonProperty("id")
private UUID id;
@NotBlank
@Size(max = 120)
private String title;
@JsonAlias({"img", "image", "image_url"})
@NotBlank
@Size(max = 1024)
private String imageUrl;
@Size(max = 64)
private String author;
@Min(0) @Max(5)
private Integer rating; // 0..5
// Comma-separated input accepted, normalized to lower-cased list
private List<String> tags;
private Boolean nsfw;
@JsonDeserialize(using = LenientInstantDeserializer.class)
private Instant createdAt;
// Enriched by AI
private String aiCaption;
private List<String> aiTags;
public Meme() {}
public Meme(UUID id, String title, String imageUrl, String author, Integer rating,
List<String> tags, Boolean nsfw, Instant createdAt, String aiCaption, List<String> aiTags) {
this.id = id;
this.title = title;
this.imageUrl = imageUrl;
this.author = author;
this.rating = rating;
this.tags = tags;
this.nsfw = nsfw;
this.createdAt = createdAt;
this.aiCaption = aiCaption;
this.aiTags = aiTags;
}
// Getters + setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title != null ? title.trim() : null; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl != null ? imageUrl.trim() : null; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author != null ? author.trim() : null; }
public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; }
public List<String> getTags() { return tags; }
public void setTags(Object value) {
// Accept ["cat","funny"] or "cat, funny"
if (value == null) { this.tags = null; return; }
if (value instanceof List<?> l) {
this.tags = normalizeStrings(l);
return;
}
String s = value.toString();
if (s.isBlank()) { this.tags = List.of(); return; }
String[] parts = s.split(",");
List<String> out = new ArrayList<>();
for (String p : parts) {
String t = p.trim().toLowerCase();
if (!t.isEmpty()) out.add(t);
}
this.tags = out;
}
public Boolean getNsfw() { return nsfw; }
public void setNsfw(Boolean nsfw) { this.nsfw = nsfw; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public String getAiCaption() { return aiCaption; }
public void setAiCaption(String aiCaption) { this.aiCaption = aiCaption; }
public List<String> getAiTags() { return aiTags; }
public void setAiTags(List<String> aiTags) { this.aiTags = aiTags; }
private static List<String> normalizeStrings(List<?> raw) {
List<String> out = new ArrayList<>();
for (Object o : raw) {
if (o == null) continue;
String s = o.toString().trim().toLowerCase();
if (!s.isEmpty()) out.add(s);
}
return out;
}
}
This step is important. It shows how Jackson can normalize incoming data before it even reaches your business logic.
Lenient date parsing:
src/main/java/com/example/json/LenientInstantDeserializer.java
package com.example.json;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import java.time.*;
import java.time.format.DateTimeFormatter;
public class LenientInstantDeserializer extends StdDeserializer<Instant> {
public LenientInstantDeserializer() { super(Instant.class); }
@Override
public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String v = p.getValueAsString();
if (v == null || v.isBlank()) return null;
// Try epoch millis
if (v.chars().allMatch(Character::isDigit)) {
try {
long epoch = Long.parseLong(v);
return Instant.ofEpochMilli(epoch);
} catch (NumberFormatException ignored) {}
}
// Try RFC3339/ISO_INSTANT
try { return Instant.parse(v); } catch (Exception ignored) {}
// Try yyyy-MM-dd as local date at midnight UTC
try {
LocalDate d = LocalDate.parse(v, DateTimeFormatter.ISO_LOCAL_DATE);
return d.atStartOfDay(ZoneOffset.UTC).toInstant();
} catch (Exception ignored) {}
// Last resort: throw a meaningful error
throw new IOException("Unsupported date format for createdAt: " + v);
}
}
AI enrichment with LangChain4j
Now for the fun part. Instead of storing memes as-is, we’ll call the model with LangChain4j and let it suggest a witty caption and extra tags.
We define an @AIService
interface that tells the LLM how to respond. Then we add a tiny parser to split the caption and tags into structured fields.
src/main/java/com/example/ai/MemeAI.java
package com.example.ai;
import java.util.List;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface MemeAI {
@SystemMessage("""
You are a concise, playful meme captioner and tagger.
Produce a single witty caption under 20 words.
Then output 3-6 short, lowercase tags separated by commas.
Avoid offensive content. If NSFW, keep neutral.
IMPORTANT: You must respond in EXACTLY this format:
caption:::tag1,tag2,tag3
Do not use any other format, no newlines, no "tags:" prefix, just the exact format above.
""")
String captionAndTags(
@UserMessage("""
Title: {title}
Existing tags: {tags}
Return "caption:::tag1,tag2,tag3".
""") MemePrompt prompt);
record MemePrompt(String title, List<String> tags) {
}
}
A small parser to split the model’s single-line response:
src/main/java/com/example/ai/AIParsers.java
package com.example.ai;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class AIParsers {
private AIParsers() {
}
public static Parsed parseCaptionAndTags(String raw) {
if (raw == null)
return new Parsed(null, List.of());
String s = raw.trim();
int sep = s.indexOf(":::");
String caption = (sep > 0) ? s.substring(0, sep).trim() : s;
String rest = (sep > 0) ? s.substring(sep + 3).trim() : "";
List<String> tags = new ArrayList<>();
if (!rest.isBlank()) {
Arrays.stream(rest.split(","))
.map(String::trim)
.map(String::toLowerCase)
.filter(t -> !t.isBlank())
.forEach(tags::add);
}
return new Parsed(caption, tags);
}
public record Parsed(String caption, List<String> tags) {
}
}
This demonstrates how to keep AI integration deterministic and predictable. We’re not just dumping LLM output into the response, but parsing and validating it.
Service layer and in-memory store
To keep the tutorial simple, we’ll store memes in an in-memory map. This makes it easy to focus on JSON handling and AI enrichment without worrying about databases.
src/main/java/com/example/service/MemeStore.java
package com.example.service;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.example.model.Meme;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class MemeStore {
private final Map<UUID, Meme> data = new ConcurrentHashMap<>();
public Meme save(Meme m) {
if (m.getId() == null)
m.setId(UUID.randomUUID());
if (m.getCreatedAt() == null)
m.setCreatedAt(Instant.now());
data.put(m.getId(), m);
return m;
}
public Optional<Meme> find(UUID id) {
return Optional.ofNullable(data.get(id));
}
public List<Meme> list(Optional<String> tag) {
return data.values().stream()
.filter(m -> tag.map(t -> hasTag(m, t)).orElse(true))
.sorted(Comparator.comparing(Meme::getCreatedAt).reversed())
.toList();
}
public Optional<Meme> random(Optional<String> tag) {
List<Meme> list = list(tag);
if (list.isEmpty())
return Optional.empty();
return Optional.of(list.get(new Random().nextInt(list.size())));
}
private boolean hasTag(Meme m, String t) {
if (m.getTags() == null)
return false;
String needle = t.toLowerCase();
return m.getTags().stream().anyMatch(x -> x.equalsIgnoreCase(needle));
}
}
Notice how the MemeService
wraps around AI enrichment. If AI is enabled, we call the model. If not, we just persist the meme as-is.
src/main/java/com/example/service/MemeService.java
package com.example.service;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.example.ai.AIParsers;
import com.example.ai.MemeAI;
import com.example.model.Meme;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class MemeService {
@Inject
MemeStore store;
@ConfigProperty(name = "meme.ai.enabled", defaultValue = "true")
boolean aiEnabled;
@Inject
MemeAI memeAI;
public Meme create(Meme input) {
Meme normalized = normalize(input);
if (aiEnabled) {
var raw = memeAI.captionAndTags(new MemeAI.MemePrompt(
normalized.getTitle(),
normalized.getTags()));
var parsed = AIParsers.parseCaptionAndTags(raw);
normalized.setAiCaption(parsed.caption());
normalized.setAiTags(parsed.tags());
}
return store.save(normalized);
}
private Meme normalize(Meme in) {
// Already handled by setters/Jackson; hook for future logic
return in;
}
}
REST API with REST-Jackson
Next, we wire everything into a JAX-RS resource.
Endpoints:
POST /memes
→ create a memeGET /memes
→ list memes, optionally filter by tagGET /memes/random
→ pick a random memeGET /memes/{id}
→ fetch a meme by ID
We also add a custom exception mapper to turn validation errors into JSON.
src/main/java/com/example/rest/MemeResource.java
package com.example.rest;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import com.example.model.Meme;
import com.example.service.MemeService;
import com.example.service.MemeStore;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
@Path("/memes")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class MemeResource {
@Inject
MemeService service;
@Inject
MemeStore store;
@POST
public Response create(@Valid Meme m, @Context UriInfo uri) {
Meme saved = service.create(m);
URI location = uri.getAbsolutePathBuilder().path(saved.getId().toString()).build();
return Response.created(location).entity(saved).build();
}
@GET
public List<Meme> list(@QueryParam("tag") String tag) {
return store.list(Optional.ofNullable(tag));
}
@GET
@Path("/random")
public Response random(@QueryParam("tag") String tag) {
return store.random(Optional.ofNullable(tag))
.map(Response::ok)
.orElse(Response.status(Response.Status.NOT_FOUND))
.build();
}
@GET
@Path("/{id}")
public Response get(@PathParam("id") UUID id) {
return store.find(id)
.map(Response::ok)
.orElse(Response.status(Response.Status.NOT_FOUND))
.build();
}
}
Validation error handling that returns JSON:
src/main/java/com/example/rest/ConstraintViolationExceptionMapper.java
package com.example.rest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException e) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "validation");
List<Map<String, String>> violations = new ArrayList<>();
for (ConstraintViolation<?> v : e.getConstraintViolations()) {
violations.add(Map.of(
"path", v.getPropertyPath().toString(),
"message", v.getMessage()));
}
body.put("violations", violations);
return Response.status(Response.Status.BAD_REQUEST).entity(body).build();
}
}
This is where Quarkus REST-Jackson shines. The JSON transformation happens seamlessly.
Build and run
Start the app in dev mode:
quarkus dev
Check health:
curl -s 'http://localhost:8080/q/health' | jq
You’re ready to try out the endpoints.
Try messy JSON and see normalization
Create a meme with aliases and a funky date:
curl -s -X POST http://localhost:8080/memes \
-H 'Content-Type: application/json' \
-d '{
"title": "Cat typing faster than me",
"image": "https://example.com/memes/cat-typing.gif",
"author": " Alice ",
"rating": 5,
"tags": "Cat, coding ,funny",
"nsfw": false,
"createdAt": "2025-09-01"
}' | jq
The return you get:
{
"title": "Cat typing faster than me",
"imageUrl": "https://example.com/memes/cat-typing.gif",
"author": "Alice",
"rating": 5,
"tags": [
"cat",
"coding",
"funny"
],
"nsfw": false,
"createdAt": "2025-09-01T00:00:00Z",
"aiCaption": "Purr-fectly optimized code",
"aiTags": [
"cat",
"coding",
"funny"
],
"id": "966cf071-6cc7-4805-9db6-351981ba59c1"
}
Observe the response:
image
becomesimageUrl
.Tags are normalized to lowercase list.
The date is parsed into a full timestamp.
The LLM fills in
aiCaption
andaiTags
.
You can also test listing, filtering, fetching by ID, or validation errors as shown earlier.
List memes:
curl -s http://localhost:8080/memes | jq
Filter by tag:
curl -s 'http://localhost:8080/memes?tag=coding' | jq
Random:
curl -s http://localhost:8080/memes/random | jq
Get by id:
ID=$(curl -s http://localhost:8080/memes | jq -r '.[0].id')
curl -s http://localhost:8080/memes/$ID | jq
Validation error:
curl -s -X POST http://localhost:8080/memes \
-H 'Content-Type: application/json' \
-d '{"title":"","img":"","rating":9}' | jq
You’ll get a neat JSON error with violations.
{
"error": "validation",
"violations": [
{
"path": "create.m.rating",
"message": "must be less than or equal to 5"
},
{
"path": "create.m.title",
"message": "must not be blank"
},
{
"path": "create.m.imageUrl",
"message": "must not be blank"
}
]
}
Tests
To avoid hitting the model in tests, we disable AI with a test profile. Then we use RestAssured to verify creation, retrieval, and validation errors.
src/test/java/com/example/rest/MemeResourceTest.java
package com.example.rest;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class MemeResourceTest {
@Test
void create_and_get() {
String payload = """
{
"title": "Keyboard Warrior",
"image_url": "https://example.com/kw.gif",
"tags": "keyboard,warrior"
}
""";
String id = given()
.contentType(ContentType.JSON)
.body(payload)
.when().post("/memes")
.then()
.statusCode(201)
.body("title", equalTo("Keyboard Warrior"))
.body("imageUrl", equalTo("https://example.com/kw.gif"))
.extract().jsonPath().getString("id");
given()
.when().get("/memes/" + id)
.then()
.statusCode(200)
.body("id", equalTo(id))
.body("tags", contains("keyboard", "warrior"));
}
@Test
void validation_error() {
String bad = """
{"title":"", "img":"", "rating": 10}
""";
given()
.contentType(ContentType.JSON)
.body(bad)
.when().post("/memes")
.then()
.statusCode(400)
.body("error", equalTo("validation"))
.body("violations.size()", greaterThan(0));
}
}
This ensures our JSON rules and error handling behave correctly.
Run tests:
quarkus test
Production notes
At this point, you’ve got a working prototype. But in real-world systems:
Model pinning. Pin a specific Ollama model and revision in environments you control. Document it next to your deployment manifests.
Timeouts and retries. You set a 10s timeout. For real services, add circuit breakers and fallbacks.
Logging. Don’t log user inputs that might contain sensitive content. Redact image URLs if needed.
Validation. Extend constraints for
imageUrl
with URL checks if your policy requires it.Persistence. Swap the in-memory store for a database (Panache + PostgreSQL). Add unique constraints and indexes on tags and createdAt.
Security. Add authentication and rate limits. Consider an allowlist for outbound calls if images are later fetched.
Schema governance. Generate OpenAPI (
/q/openapi
) and lock a versioned contract with consumers.
These are the steps that take a fun demo into production readiness.
What’s next?
Want to keep going?
Vision models. If you want captioning from the actual image, use a vision-capable model and accept an image URL or base64 content. Add server-side HEAD checks and virus scanning for downloaded assets.
JSON Patch. Add
PATCH /memes/{id}
supporting JSON Merge Patch to update rating or tags efficiently.Public vs internal views. Use
@JsonView
to hide internal moderation fields from public clients.Streaming. If your list grows, use Jackson streaming for large result sets.
You’ve built a playful meme service, but the skills you learned: JSON normalization, Jackson tricks, and AI enrichment with Quarkus, apply to every serious enterprise project.