Sanity.io Java Client: Complete Implementation Guide

Introduction to Sanity.io Java Client

Sanity.io is a headless CMS platform that provides real-time content management. This guide covers building a comprehensive Java client for interacting with Sanity.io's APIs, including content querying, mutations, real-time subscriptions, and asset management.

Key Features

  • Content Querying with GROQ (Graph-Relational Object Queries)
  • Real-time Subscriptions for live content updates
  • Asset Management for file uploads and transformations
  • Mutation Operations for creating, updating, and deleting content
  • Type-safe Models for Java integration
  • Error Handling and retry mechanisms

Dependencies and Setup

Maven Configuration

<properties>
<okhttp.version>4.12.0</okhttp.version>
<jackson.version>2.15.2</jackson.version>
<websocket.version>1.5.3</websocket.version>
</properties>
<dependencies>
<!-- HTTP Client -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-sse</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- WebSocket Client -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>${websocket.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.7</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>
<!-- Mock Web Server for Testing -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>${okhttp.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Core Sanity Client Implementation

Main Sanity Client

package com.sanity.client;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class SanityClient {
private static final Logger logger = LoggerFactory.getLogger(SanityClient.class);
private final String projectId;
private final String dataset;
private final String token;
private final boolean useCdn;
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
public SanityClient(String projectId, String dataset, String token) {
this(projectId, dataset, token, true);
}
public SanityClient(String projectId, String dataset, String token, boolean useCdn) {
this.projectId = projectId;
this.dataset = dataset;
this.token = token;
this.useCdn = useCdn;
this.objectMapper = new ObjectMapper();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
this.baseUrl = useCdn ? 
"https://" + projectId + ".apicdn.sanity.io/v1" :
"https://" + projectId + ".api.sanity.io/v1";
}
/**
* Execute GROQ query and return typed results
*/
public <T> QueryResponse<T> query(String groqQuery, Class<T> resultType) throws SanityException {
return query(groqQuery, Collections.emptyMap(), resultType);
}
/**
* Execute GROQ query with parameters
*/
public <T> QueryResponse<T> query(String groqQuery, Map<String, Object> params, Class<T> resultType) throws SanityException {
try {
// Build request URL
HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl + "/data/query/" + dataset).newBuilder();
urlBuilder.addQueryParameter("query", groqQuery);
// Add parameters as JSON
if (params != null && !params.isEmpty()) {
String paramsJson = objectMapper.writeValueAsString(params);
urlBuilder.addQueryParameter("$params", paramsJson);
}
Request request = createAuthenticatedRequest()
.url(urlBuilder.build())
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
return parseQueryResponse(response, resultType);
}
} catch (IOException e) {
throw new SanityException("Query execution failed", e);
}
}
/**
* Execute GROQ query returning list of results
*/
public <T> QueryResponse<List<T>> queryList(String groqQuery, Class<T> elementType) throws SanityException {
return queryList(groqQuery, Collections.emptyMap(), elementType);
}
public <T> QueryResponse<List<T>> queryList(String groqQuery, Map<String, Object> params, Class<T> elementType) throws SanityException {
TypeReference<QueryResponse<List<T>>> typeRef = new TypeReference<QueryResponse<List<T>>>() {};
return queryWithTypeReference(groqQuery, params, typeRef);
}
/**
* Execute query with TypeReference for complex types
*/
public <T> T queryWithTypeReference(String groqQuery, Map<String, Object> params, 
TypeReference<T> typeRef) throws SanityException {
try {
HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl + "/data/query/" + dataset).newBuilder();
urlBuilder.addQueryParameter("query", groqQuery);
if (params != null && !params.isEmpty()) {
String paramsJson = objectMapper.writeValueAsString(params);
urlBuilder.addQueryParameter("$params", paramsJson);
}
Request request = createAuthenticatedRequest()
.url(urlBuilder.build())
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new SanityException("Query failed: " + response.code() + " - " + response.message());
}
String responseBody = response.body().string();
return objectMapper.readValue(responseBody, typeRef);
}
} catch (IOException e) {
throw new SanityException("Query execution failed", e);
}
}
/**
* Create a new document
*/
public MutationResult create(Map<String, Object> document) throws SanityException {
List<Map<String, Object>> mutations = Collections.singletonList(
Collections.singletonMap("create", document)
);
return mutate(mutations);
}
/**
* Create a new document with specified type
*/
public <T> MutationResult create(T document) throws SanityException {
Map<String, Object> documentMap = objectMapper.convertValue(document, Map.class);
return create(documentMap);
}
/**
* Update an existing document
*/
public MutationResult update(String documentId, Map<String, Object> updates) throws SanityException {
List<Map<String, Object>> mutations = Collections.singletonList(
Collections.singletonMap("patch", 
Collections.singletonMap("id", documentId)
.with("set", updates)
)
);
return mutate(mutations);
}
/**
* Update an existing document with typed object
*/
public <T> MutationResult update(String documentId, T updates) throws SanityException {
Map<String, Object> updatesMap = objectMapper.convertValue(updates, Map.class);
return update(documentId, updatesMap);
}
/**
* Delete a document
*/
public MutationResult delete(String documentId) throws SanityException {
List<Map<String, Object>> mutations = Collections.singletonList(
Collections.singletonMap("delete", 
Collections.singletonMap("id", documentId)
)
);
return mutate(mutations);
}
/**
* Execute multiple mutations in a transaction
*/
public MutationResult mutate(List<Map<String, Object>> mutations) throws SanityException {
try {
String mutationsJson = objectMapper.writeValueAsString(mutations);
RequestBody body = RequestBody.create(
mutationsJson, 
MediaType.parse("application/json")
);
Request request = createAuthenticatedRequest()
.url(baseUrl + "/data/mutate/" + dataset)
.post(body)
.build();
try (Response response = httpClient.newCall(request).execute()) {
return parseMutationResponse(response);
}
} catch (IOException e) {
throw new SanityException("Mutation failed", e);
}
}
/**
* Upload file to Sanity
*/
public AssetUploadResult uploadAsset(byte[] fileData, String contentType, String originalFilename) throws SanityException {
try {
// Step 1: Begin upload
Map<String, String> beginUploadRequest = Collections.singletonMap(
"contentType", contentType
);
String beginUploadJson = objectMapper.writeValueAsString(beginUploadRequest);
Request beginRequest = createAuthenticatedRequest()
.url(baseUrl + "/assets/images/" + dataset)
.post(RequestBody.create(beginUploadJson, MediaType.parse("application/json")))
.build();
String uploadUrl;
String assetId;
try (Response beginResponse = httpClient.newCall(beginRequest).execute()) {
if (!beginResponse.isSuccessful()) {
throw new SanityException("Failed to begin upload: " + beginResponse.code());
}
String beginResponseBody = beginResponse.body().string();
Map<String, Object> beginResult = objectMapper.readValue(beginResponseBody, Map.class);
uploadUrl = (String) beginResult.get("url");
assetId = (String) beginResult.get("id");
}
// Step 2: Upload file data
Request uploadRequest = new Request.Builder()
.url(uploadUrl)
.put(RequestBody.create(fileData, MediaType.parse(contentType)))
.build();
try (Response uploadResponse = httpClient.newCall(uploadRequest).execute()) {
if (!uploadResponse.isSuccessful()) {
throw new SanityException("File upload failed: " + uploadResponse.code());
}
}
// Step 3: Commit upload
Map<String, Object> commitRequest = new HashMap<>();
commitRequest.put("id", assetId);
commitRequest.put("originalFilename", originalFilename);
String commitJson = objectMapper.writeValueAsString(commitRequest);
Request commitRequestObj = createAuthenticatedRequest()
.url(baseUrl + "/assets/images/" + dataset)
.post(RequestBody.create(commitJson, MediaType.parse("application/json")))
.build();
try (Response commitResponse = httpClient.newCall(commitRequestObj).execute()) {
if (!commitResponse.isSuccessful()) {
throw new SanityException("Upload commit failed: " + commitResponse.code());
}
String commitResponseBody = commitResponse.body().string();
return objectMapper.readValue(commitResponseBody, AssetUploadResult.class);
}
} catch (IOException e) {
throw new SanityException("Asset upload failed", e);
}
}
/**
* Get project information
*/
public ProjectInfo getProjectInfo() throws SanityException {
try {
Request request = createAuthenticatedRequest()
.url("https://" + projectId + ".api.sanity.io/v1/projects/" + projectId)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new SanityException("Failed to get project info: " + response.code());
}
String responseBody = response.body().string();
return objectMapper.readValue(responseBody, ProjectInfo.class);
}
} catch (IOException e) {
throw new SanityException("Failed to get project info", e);
}
}
// Helper methods
private Request.Builder createAuthenticatedRequest() {
Request.Builder builder = new Request.Builder()
.addHeader("Content-Type", "application/json");
if (token != null && !token.isEmpty()) {
builder.addHeader("Authorization", "Bearer " + token);
}
return builder;
}
private <T> QueryResponse<T> parseQueryResponse(Response response, Class<T> resultType) throws IOException {
if (!response.isSuccessful()) {
throw new SanityException("Query failed: " + response.code() + " - " + response.message());
}
String responseBody = response.body().string();
QueryResponse<Map<String, Object>> rawResponse = objectMapper.readValue(
responseBody, 
new TypeReference<QueryResponse<Map<String, Object>>>() {}
);
QueryResponse<T> typedResponse = new QueryResponse<>();
typedResponse.setMs(rawResponse.getMs());
typedResponse.setQuery(rawResponse.getQuery());
if (rawResponse.getResult() != null) {
T convertedResult = objectMapper.convertValue(rawResponse.getResult(), resultType);
typedResponse.setResult(convertedResult);
}
return typedResponse;
}
private MutationResult parseMutationResponse(Response response) throws IOException {
if (!response.isSuccessful()) {
throw new SanityException("Mutation failed: " + response.code() + " - " + response.message());
}
String responseBody = response.body().string();
return objectMapper.readValue(responseBody, MutationResult.class);
}
// Custom exception
public static class SanityException extends Exception {
public SanityException(String message) {
super(message);
}
public SanityException(String message, Throwable cause) {
super(message, cause);
}
}
}

Data Models

Response Data Classes

package com.sanity.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
// Query Response
@JsonIgnoreProperties(ignoreUnknown = true)
public class QueryResponse<T> {
private String query;
private long ms;
private T result;
// Getters and setters
public String getQuery() { return query; }
public void setQuery(String query) { this.query = query; }
public long getMs() { return ms; }
public void setMs(long ms) { this.ms = ms; }
public T getResult() { return result; }
public void setResult(T result) { this.result = result; }
}
// Mutation Result
@JsonIgnoreProperties(ignoreUnknown = true)
class MutationResult {
private String transactionId;
private List<MutationResultItem> results;
// Getters and setters
public String getTransactionId() { return transactionId; }
public void setTransactionId(String transactionId) { this.transactionId = transactionId; }
public List<MutationResultItem> getResults() { return results; }
public void setResults(List<MutationResultItem> results) { this.results = results; }
}
// Mutation Result Item
@JsonIgnoreProperties(ignoreUnknown = true)
class MutationResultItem {
private String id;
private String operation;
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getOperation() { return operation; }
public void setOperation(String operation) { this.operation = operation; }
}
// Asset Upload Result
@JsonIgnoreProperties(ignoreUnknown = true)
class AssetUploadResult {
private String id;
private String url;
private String path;
private String originalFilename;
private long size;
private String mimeType;
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getOriginalFilename() { return originalFilename; }
public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; }
public long getSize() { return size; }
public void setSize(long size) { this.size = size; }
public String getMimeType() { return mimeType; }
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
}
// Project Information
@JsonIgnoreProperties(ignoreUnknown = true)
class ProjectInfo {
private String id;
private String displayName;
private String studioHost;
private String organizationId;
private boolean isBlocked;
private boolean isDisabled;
private boolean isDisabledByUser;
private String createdAt;
private String updatedAt;
private String membership;
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getStudioHost() { return studioHost; }
public void setStudioHost(String studioHost) { this.studioHost = studioHost; }
public String getOrganizationId() { return organizationId; }
public void setOrganizationId(String organizationId) { this.organizationId = organizationId; }
public boolean isBlocked() { return isBlocked; }
public void setBlocked(boolean blocked) { isBlocked = blocked; }
public boolean isDisabled() { return isDisabled; }
public void setDisabled(boolean disabled) { isDisabled = disabled; }
public boolean isDisabledByUser() { return isDisabledByUser; }
public void setDisabledByUser(boolean disabledByUser) { isDisabledByUser = disabledByUser; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
public String getMembership() { return membership; }
public void setMembership(String membership) { this.membership = membership; }
}
// Sanity Document Base Class
@JsonIgnoreProperties(ignoreUnknown = true)
class SanityDocument {
@JsonProperty("_id")
private String id;
@JsonProperty("_type")
private String type;
@JsonProperty("_createdAt")
private String createdAt;
@JsonProperty("_updatedAt")
private String updatedAt;
@JsonProperty("_rev")
private String revision;
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
public String getRevision() { return revision; }
public void setRevision(String revision) { this.revision = revision; }
}
// Example Content Types
class Post extends SanityDocument {
private String title;
private String slug;
private String body;
private Author author;
private List<String> categories;
private SanityImage mainImage;
private String publishedAt;
// Getters and setters
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
public Author getAuthor() { return author; }
public void setAuthor(Author author) { this.author = author; }
public List<String> getCategories() { return categories; }
public void setCategories(List<String> categories) { this.categories = categories; }
public SanityImage getMainImage() { return mainImage; }
public void setMainImage(SanityImage mainImage) { this.mainImage = mainImage; }
public String getPublishedAt() { return publishedAt; }
public void setPublishedAt(String publishedAt) { this.publishedAt = publishedAt; }
}
class Author extends SanityDocument {
private String name;
private String bio;
private SanityImage image;
private String email;
// Getters and setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public SanityImage getImage() { return image; }
public void setImage(SanityImage image) { this.image = image; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
class SanityImage {
@JsonProperty("_type")
private String type = "image";
private Asset asset;
// Getters and setters
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public Asset getAsset() { return asset; }
public void setAsset(Asset asset) { this.asset = asset; }
// Helper method to generate image URL
public String getUrl() {
if (asset != null && asset.getRef() != null) {
return "https://cdn.sanity.io/images/" + 
extractProjectId(asset.getRef()) + "/" +
extractDataset(asset.getRef()) + "/" +
extractAssetId(asset.getRef());
}
return null;
}
public String getUrlWithOptions(int width, int height) {
String baseUrl = getUrl();
if (baseUrl != null) {
return baseUrl + "?w=" + width + "&h=" + height + "&fit=crop";
}
return null;
}
private String extractProjectId(String ref) {
// Implementation to extract project ID from asset reference
return ref.split("-")[0];
}
private String extractDataset(String ref) {
// Implementation to extract dataset from asset reference
return "production"; // Default dataset
}
private String extractAssetId(String ref) {
// Implementation to extract asset ID from asset reference
return ref;
}
}
class Asset {
@JsonProperty("_ref")
private String ref;
@JsonProperty("_type")
private String type = "reference";
// Getters and setters
public String getRef() { return ref; }
public void setRef(String ref) { this.ref = ref; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}

Real-time Subscription Service

Live Query Subscriptions

package com.sanity.realtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
public class SanityRealtimeService {
private static final Logger logger = LoggerFactory.getLogger(SanityRealtimeService.class);
private final String projectId;
private final String dataset;
private final String token;
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
private final Map<String, Subscription> subscriptions;
private final AtomicInteger listenerIdCounter;
public SanityRealtimeService(String projectId, String dataset, String token) {
this.projectId = projectId;
this.dataset = dataset;
this.token = token;
this.httpClient = new OkHttpClient();
this.objectMapper = new ObjectMapper();
this.subscriptions = new ConcurrentHashMap<>();
this.listenerIdCounter = new AtomicInteger(1);
}
/**
* Subscribe to real-time updates for a GROQ query
*/
public <T> String subscribe(String query, Map<String, Object> params, 
RealtimeListener<T> listener, Class<T> resultType) {
String subscriptionId = generateSubscriptionId();
Subscription subscription = new Subscription(
subscriptionId, query, params, listener, resultType
);
subscriptions.put(subscriptionId, subscription);
startSubscription(subscription);
return subscriptionId;
}
/**
* Unsubscribe from real-time updates
*/
public void unsubscribe(String subscriptionId) {
Subscription subscription = subscriptions.remove(subscriptionId);
if (subscription != null) {
subscription.close();
}
}
/**
* Start a subscription using Server-Sent Events (SSE)
*/
private <T> void startSubscription(Subscription<T> subscription) {
try {
String query = subscription.getQuery();
Map<String, Object> params = subscription.getParams();
// Build listen URL
HttpUrl.Builder urlBuilder = HttpUrl.parse(
"https://" + projectId + ".api.sanity.io/v1/data/listen/" + dataset
).newBuilder();
urlBuilder.addQueryParameter("query", query);
if (params != null && !params.isEmpty()) {
String paramsJson = objectMapper.writeValueAsString(params);
urlBuilder.addQueryParameter("$params", paramsJson);
}
Request request = new Request.Builder()
.url(urlBuilder.build())
.addHeader("Authorization", "Bearer " + token)
.addHeader("Accept", "text/event-stream")
.build();
RealTimeSSEListener sseListener = new RealTimeSSEListener<>(subscription);
subscription.setEventSource(new EventSource(request, sseListener));
} catch (Exception e) {
logger.error("Failed to start subscription: {}", e.getMessage(), e);
subscription.getListener().onError(e);
}
}
private String generateSubscriptionId() {
return "sub-" + System.currentTimeMillis() + "-" + listenerIdCounter.getAndIncrement();
}
// Subscription class
private static class Subscription<T> {
private final String id;
private final String query;
private final Map<String, Object> params;
private final RealtimeListener<T> listener;
private final Class<T> resultType;
private EventSource eventSource;
public Subscription(String id, String query, Map<String, Object> params, 
RealtimeListener<T> listener, Class<T> resultType) {
this.id = id;
this.query = query;
this.params = params;
this.listener = listener;
this.resultType = resultType;
}
public void close() {
if (eventSource != null) {
eventSource.cancel();
}
}
// Getters and setters
public String getId() { return id; }
public String getQuery() { return query; }
public Map<String, Object> getParams() { return params; }
public RealtimeListener<T> getListener() { return listener; }
public Class<T> getResultType() { return resultType; }
public EventSource getEventSource() { return eventSource; }
public void setEventSource(EventSource eventSource) { this.eventSource = eventSource; }
}
// SSE Listener for real-time updates
private class RealTimeSSEListener<T> extends EventSourceListener {
private final Subscription<T> subscription;
private final ObjectMapper objectMapper;
public RealTimeSSEListener(Subscription<T> subscription) {
this.subscription = subscription;
this.objectMapper = new ObjectMapper();
}
@Override
public void onOpen(EventSource eventSource, Response response) {
logger.info("Real-time connection opened for subscription: {}", subscription.getId());
subscription.getListener().onConnected();
}
@Override
public void onEvent(EventSource eventSource, String id, String type, String data) {
try {
if ("message".equals(type)) {
handleMessage(data);
}
} catch (Exception e) {
logger.error("Error processing real-time event: {}", e.getMessage(), e);
subscription.getListener().onError(e);
}
}
@Override
public void onClosed(EventSource eventSource) {
logger.info("Real-time connection closed for subscription: {}", subscription.getId());
subscription.getListener().onDisconnected();
}
@Override
public void onFailure(EventSource eventSource, Throwable t, Response response) {
logger.error("Real-time connection failed for subscription: {}", subscription.getId(), t);
subscription.getListener().onError(t);
}
private void handleMessage(String data) throws IOException {
Map<String, Object> message = objectMapper.readValue(data, Map.class);
String messageType = (String) message.get("type");
switch (messageType) {
case "welcome":
handleWelcome(message);
break;
case "mutation":
handleMutation(message);
break;
case "disconnect":
handleDisconnect(message);
break;
default:
logger.debug("Unknown message type: {}", messageType);
}
}
private void handleWelcome(Map<String, Object> message) {
logger.info("Received welcome message for subscription: {}", subscription.getId());
}
@SuppressWarnings("unchecked")
private void handleMutation(Map<String, Object> message) {
try {
Map<String, Object> result = (Map<String, Object>) message.get("result");
if (result != null) {
T typedResult = objectMapper.convertValue(result, subscription.getResultType());
subscription.getListener().onUpdate(typedResult);
}
} catch (Exception e) {
logger.error("Error processing mutation: {}", e.getMessage(), e);
}
}
private void handleDisconnect(Map<String, Object> message) {
String reason = (String) message.get("reason");
logger.warn("Disconnected from real-time updates: {}", reason);
}
}
// Custom EventSource implementation for SSE
private static class EventSource {
private final Request request;
private final EventSourceListener listener;
private okhttp3.EventSource okhttpEventSource;
public EventSource(Request request, EventSourceListener listener) {
this.request = request;
this.listener = listener;
}
public void start(OkHttpClient client) {
okhttp3.EventSource.Factory factory = okhttp3.EventSource.Factory.create(client);
okhttpEventSource = factory.newEventSource(request, new okhttp3.EventSourceListener() {
@Override
public void onOpen(okhttp3.EventSource eventSource, Response response) {
listener.onOpen(EventSource.this, response);
}
@Override
public void onEvent(okhttp3.EventSource eventSource, String id, String type, String data) {
listener.onEvent(EventSource.this, id, type, data);
}
@Override
public void onClosed(okhttp3.EventSource eventSource) {
listener.onClosed(EventSource.this);
}
@Override
public void onFailure(okhttp3.EventSource eventSource, Throwable t, Response response) {
listener.onFailure(EventSource.this, t, response);
}
});
}
public void cancel() {
if (okhttpEventSource != null) {
okhttpEventSource.cancel();
}
}
}
// Event Source Listener interface
private abstract static class EventSourceListener {
public void onOpen(EventSource eventSource, Response response) {}
public void onEvent(EventSource eventSource, String id, String type, String data) {}
public void onClosed(EventSource eventSource) {}
public void onFailure(EventSource eventSource, Throwable t, Response response) {}
}
}
// Real-time Listener Interface
interface RealtimeListener<T> {
void onConnected();
void onDisconnected();
void onUpdate(T data);
void onError(Throwable error);
}

Advanced Query Builder

GROQ Query Builder

package com.sanity.query;
import java.util.*;
import java.util.stream.Collectors;
public class GroqQueryBuilder {
private final StringBuilder query;
private final Map<String, Object> parameters;
private final Set<String> projections;
private final List<String> filters;
private final List<String> orders;
private Integer limit;
private Integer offset;
public GroqQueryBuilder() {
this.query = new StringBuilder();
this.parameters = new HashMap<>();
this.projections = new LinkedHashSet<>();
this.filters = new ArrayList<>();
this.orders = new ArrayList<>();
}
/**
* Start building a query for a specific document type
*/
public static GroqQueryBuilder forType(String documentType) {
GroqQueryBuilder builder = new GroqQueryBuilder();
builder.query.append("*[_type == \"").append(documentType).append("\"]");
return builder;
}
/**
* Start building a query with custom base
*/
public static GroqQueryBuilder from(String baseQuery) {
GroqQueryBuilder builder = new GroqQueryBuilder();
builder.query.append(baseQuery);
return builder;
}
/**
* Add field projection
*/
public GroqQueryBuilder project(String field) {
projections.add(field);
return this;
}
/**
* Add multiple field projections
*/
public GroqQueryBuilder project(String... fields) {
projections.addAll(Arrays.asList(fields));
return this;
}
/**
* Add field projection with alias
*/
public GroqQueryBuilder project(String field, String alias) {
projections.add(alias + ": " + field);
return this;
}
/**
* Add filter condition
*/
public GroqQueryBuilder filter(String condition) {
filters.add(condition);
return this;
}
/**
* Add equality filter
*/
public GroqQueryBuilder whereEquals(String field, Object value) {
String paramName = addParameter(value);
filters.add(field + " == $" + paramName);
return this;
}
/**
* Add inequality filter
*/
public GroqQueryBuilder whereNotEquals(String field, Object value) {
String paramName = addParameter(value);
filters.add(field + " != $" + paramName);
return this;
}
/**
* Add range filter (greater than)
*/
public GroqQueryBuilder whereGreaterThan(String field, Object value) {
String paramName = addParameter(value);
filters.add(field + " > $" + paramName);
return this;
}
/**
* Add range filter (less than)
*/
public GroqQueryBuilder whereLessThan(String field, Object value) {
String paramName = addParameter(value);
filters.add(field + " < $" + paramName);
return this;
}
/**
* Add contains filter for arrays
*/
public GroqQueryBuilder whereContains(String field, Object value) {
String paramName = addParameter(value);
filters.add("$" + paramName + " in " + field);
return this;
}
/**
* Add ordering
*/
public GroqQueryBuilder orderBy(String field, OrderDirection direction) {
orders.add(field + " " + direction.name().toLowerCase());
return this;
}
/**
* Add ordering with nulls first/last
*/
public GroqQueryBuilder orderBy(String field, OrderDirection direction, NullsOrder nullsOrder) {
orders.add(field + " " + direction.name().toLowerCase() + " " + nullsOrder.name().replace('_', ' ').toLowerCase());
return this;
}
/**
* Set result limit
*/
public GroqQueryBuilder limit(int limit) {
this.limit = limit;
return this;
}
/**
* Set result offset
*/
public GroqQueryBuilder offset(int offset) {
this.offset = offset;
return this;
}
/**
* Build the final GROQ query string
*/
public String build() {
StringBuilder finalQuery = new StringBuilder(query);
// Add filters
if (!filters.isEmpty()) {
finalQuery.append("[").append(String.join(" && ", filters)).append("]");
}
// Add projections
if (!projections.isEmpty()) {
finalQuery.append("{").append(String.join(", ", projections)).append("}");
}
// Add ordering
if (!orders.isEmpty()) {
finalQuery.append(" | order(").append(String.join(", ", orders)).append(")");
}
// Add limit
if (limit != null) {
finalQuery.append("[0...").append(limit).append("]");
}
// Add offset
if (offset != null) {
if (limit == null) {
finalQuery.append("[").append(offset).append("]");
} else {
// If both limit and offset are specified, use range syntax
finalQuery.setLength(finalQuery.length() - (String.valueOf(limit).length() + 4));
finalQuery.append("[").append(offset).append("...").append(offset + limit).append("]");
}
}
return finalQuery.toString();
}
/**
* Get query parameters
*/
public Map<String, Object> getParameters() {
return Collections.unmodifiableMap(parameters);
}
/**
* Add parameter and return parameter name
*/
private String addParameter(Object value) {
String paramName = "param" + (parameters.size() + 1);
parameters.put(paramName, value);
return paramName;
}
public enum OrderDirection {
ASC, DESC
}
public enum NullsOrder {
NULLS_FIRST, NULLS_LAST
}
}

Spring Boot Integration

Configuration Class

package com.sanity.config;
import com.sanity.client.SanityClient;
import com.sanity.realtime.SanityRealtimeService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SanityConfig {
@Value("${sanity.project.id}")
private String projectId;
@Value("${sanity.dataset:production}")
private String dataset;
@Value("${sanity.token:}")
private String token;
@Value("${sanity.use-cdn:true}")
private boolean useCdn;
@Bean
public SanityClient sanityClient() {
return new SanityClient(projectId, dataset, token, useCdn);
}
@Bean
public SanityRealtimeService sanityRealtimeService() {
return new SanityRealtimeService(projectId, dataset, token);
}
}

REST Controller

package com.sanity.controller;
import com.sanity.client.SanityClient;
import com.sanity.model.Post;
import com.sanity.query.GroqQueryBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/content")
public class ContentController {
@Autowired
private SanityClient sanityClient;
@GetMapping("/posts")
public ResponseEntity<Map<String, Object>> getAllPosts(
@RequestParam(defaultValue = "10") int limit,
@RequestParam(defaultValue = "0") int offset) {
Map<String, Object> response = new HashMap<>();
try {
// Build query using GroqQueryBuilder
String query = GroqQueryBuilder.forType("post")
.project("_id", "title", "slug", "body", "publishedAt", "mainImage")
.orderBy("publishedAt", GroqQueryBuilder.OrderDirection.DESC)
.limit(limit)
.offset(offset)
.build();
var result = sanityClient.queryList(query, Post.class);
response.put("success", true);
response.put("posts", result.getResult());
response.put("total", result.getResult().size());
response.put("queryTime", result.getMs());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
@GetMapping("/posts/{slug}")
public ResponseEntity<Map<String, Object>> getPostBySlug(@PathVariable String slug) {
Map<String, Object> response = new HashMap<>();
try {
String query = GroqQueryBuilder.forType("post")
.project("_id", "title", "slug", "body", "publishedAt", "mainImage", "author")
.whereEquals("slug.current", slug)
.limit(1)
.build();
var result = sanityClient.queryList(query, Post.class);
if (result.getResult().isEmpty()) {
response.put("success", false);
response.put("error", "Post not found");
return ResponseEntity.notFound().build();
}
response.put("success", true);
response.put("post", result.getResult().get(0));
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
@GetMapping("/posts/search")
public ResponseEntity<Map<String, Object>> searchPosts(@RequestParam String query) {
Map<String, Object> response = new HashMap<>();
try {
String groqQuery = GroqQueryBuilder.forType("post")
.project("_id", "title", "slug", "body", "publishedAt")
.filter("title match $searchQuery || body match $searchQuery")
.orderBy("publishedAt", GroqQueryBuilder.OrderDirection.DESC)
.limit(20)
.build();
Map<String, Object> params = new HashMap<>();
params.put("searchQuery", query + "*");
var result = sanityClient.queryList(groqQuery, params, Post.class);
response.put("success", true);
response.put("posts", result.getResult());
response.put("count", result.getResult().size());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
@PostMapping("/posts")
public ResponseEntity<Map<String, Object>> createPost(@RequestBody Post post) {
Map<String, Object> response = new HashMap<>();
try {
var result = sanityClient.create(post);
response.put("success", true);
response.put("documentId", result.getResults().get(0).getId());
response.put("transactionId", result.getTransactionId());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
}

Application Properties

# Sanity.io Configuration
sanity.project.id=your-project-id
sanity.dataset=production
sanity.token=your-sanit-token
sanity.use-cdn=true
# Server Configuration
server.port=8080
# Logging
logging.level.com.sanity=INFO

Usage Examples

Basic Usage

package com.sanity.examples;
import com.sanity.client.SanityClient;
import com.sanity.model.Post;
import com.sanity.query.GroqQueryBuilder;
import java.util.List;
import java.util.Map;
public class SanityExamples {
public static void main(String[] args) {
// Initialize client
SanityClient client = new SanityClient(
"your-project-id", 
"production", 
"your-token"
);
try {
// Example 1: Query all posts
String query = GroqQueryBuilder.forType("post")
.project("_id", "title", "slug", "publishedAt")
.orderBy("publishedAt", GroqQueryBuilder.OrderDirection.DESC)
.limit(10)
.build();
var result = client.queryList(query, Post.class);
List<Post> posts = result.getResult();
// Example 2: Query with parameters
String paramQuery = GroqQueryBuilder.forType("post")
.project("title", "slug")
.whereEquals("publishedAt", "2023-01-01")
.build();
Map<String, Object> params = Map.of("publishedAt", "2023-01-01");
var paramResult = client.queryList(paramQuery, params, Post.class);
// Example 3: Create a new post
Post newPost = new Post();
newPost.setTitle("New Blog Post");
newPost.setSlug("new-blog-post");
newPost.setBody("This is the content of the new blog post.");
var createResult = client.create(newPost);
System.out.println("Created post with ID: " + createResult.getResults().get(0).getId());
} catch (Exception e) {
e.printStackTrace();
}
}
}

Conclusion

This comprehensive Sanity.io Java client provides:

  1. Complete API coverage for queries, mutations, and assets
  2. Real-time subscriptions for live content updates
  3. Type-safe models for Java integration
  4. Query builder for constructing GROQ queries
  5. Spring Boot integration for easy configuration
  6. Error handling and comprehensive logging

Key features:

  • GROQ query execution with parameter support
  • Document mutations for create, update, and delete operations
  • Asset management for file uploads
  • Real-time updates using Server-Sent Events
  • Type-safe response parsing with Jackson
  • Flexible query building with the GroqQueryBuilder

This implementation enables Java applications to seamlessly integrate with Sanity.io headless CMS, providing robust content management capabilities with real-time updates and type safety.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper