diff --git a/agrest-spring-boot-starter/pom.xml b/agrest-spring-boot-starter/pom.xml
new file mode 100644
index 000000000..6c9ee06a2
--- /dev/null
+++ b/agrest-spring-boot-starter/pom.xml
@@ -0,0 +1,80 @@
+
+
+
+ agrest-parent
+ io.agrest
+ 5.0.M8-SNAPSHOT
+
+ 4.0.0
+
+ agrest-spring-boot-starter
+ agrest-spring-boot-starter: Spring Boot starter for Agrest
+ Spring Boot starter for Agrest
+
+
+
+ io.agrest
+ agrest-annotations
+ ${project.version}
+
+
+ io.agrest
+ agrest-engine
+ ${project.version}
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure-processor
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring.boot.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ build-tests
+
+ test-jar
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgRuntimeCustomizer.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgRuntimeCustomizer.java
new file mode 100644
index 000000000..6e39bfe59
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgRuntimeCustomizer.java
@@ -0,0 +1,8 @@
+package io.agrest.spring.starter;
+
+import io.agrest.runtime.AgRuntimeBuilder;
+
+public interface AgRuntimeCustomizer {
+
+ void customize(AgRuntimeBuilder builder);
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgrestAutoConfiguration.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgrestAutoConfiguration.java
new file mode 100644
index 000000000..3b1270681
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgrestAutoConfiguration.java
@@ -0,0 +1,69 @@
+package io.agrest.spring.starter;
+
+import io.agrest.meta.AgSchema;
+import io.agrest.runtime.AgRuntime;
+import io.agrest.runtime.jackson.IJacksonService;
+import io.agrest.runtime.protocol.IEntityUpdateParser;
+import io.agrest.spring.starter.exceptions.RestResponseEntityExceptionHandler;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConditionalOnWebApplication
+@ConditionalOnProperty(prefix = "agrest", name = "enabled")
+public class AgrestAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public AgRuntime agRuntime(ObjectProvider customizerProvider) {
+ var builder = AgRuntime.builder();
+ customizerProvider.orderedStream().forEach((customizer) -> customizer.customize(builder));
+ return builder.build();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public IJacksonService jacksonService(AgRuntime runtime) {
+ return runtime.service(IJacksonService.class);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public DataResponseWriter dataResponseWriter(IJacksonService jacksonService) {
+ return new DataResponseWriter(jacksonService);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public EntityUpdateReaderProcessor entityUpdateReaderProcessor(AgRuntime runtime) {
+ return new EntityUpdateReaderProcessor(runtime.service(IEntityUpdateParser.class), runtime.service(AgSchema.class));
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public EntityUpdateReader entityUpdateReader(EntityUpdateReaderProcessor processor) {
+ return new EntityUpdateReader(processor);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public EntityUpdateCollectionReader entityUpdateCollectionReader(EntityUpdateReaderProcessor processor) {
+ return new EntityUpdateCollectionReader(processor);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public SimpleResponseWriter simpleResponseWriter(IJacksonService jacksonService) {
+ return new SimpleResponseWriter(jacksonService);
+ }
+
+ @Bean
+ public RestResponseEntityExceptionHandler exceptionHandler() {
+ return new RestResponseEntityExceptionHandler();
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgrestProperties.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgrestProperties.java
new file mode 100644
index 000000000..82ada3579
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/AgrestProperties.java
@@ -0,0 +1,18 @@
+package io.agrest.spring.starter;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "agrest")
+public class AgrestProperties {
+
+ private boolean enabled = false;
+
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/DataResponseWriter.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/DataResponseWriter.java
new file mode 100644
index 000000000..0cd4fab42
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/DataResponseWriter.java
@@ -0,0 +1,54 @@
+package io.agrest.spring.starter;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import io.agrest.DataResponse;
+import io.agrest.runtime.jackson.IJacksonService;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+
+public class DataResponseWriter implements HttpMessageConverter> {
+
+ private final IJacksonService jacksonService;
+
+ public DataResponseWriter(IJacksonService jacksonService) {
+ this.jacksonService = jacksonService;
+ }
+
+ @Override
+ public boolean canRead(Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public boolean canWrite(Class> clazz, MediaType mediaType) {
+ return DataResponse.class.isAssignableFrom(clazz);
+ }
+
+ @Override
+ public List getSupportedMediaTypes() {
+ return null;
+ }
+
+ @Override
+ public DataResponse> read(Class extends DataResponse>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
+ return null;
+ }
+
+ @Override
+ public void write(DataResponse> dataResponse, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+ var entityStream = outputMessage.getBody(); // todo: do we need to close this?
+ jacksonService.outputJson(out -> writeData(dataResponse, out), entityStream);
+ }
+
+ protected void writeData(DataResponse> t, JsonGenerator out) throws IOException {
+ t.getEncoder().encode(null, t, out);
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateCollectionReader.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateCollectionReader.java
new file mode 100644
index 000000000..28bb41701
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateCollectionReader.java
@@ -0,0 +1,82 @@
+package io.agrest.spring.starter;
+
+import io.agrest.AgException;
+import io.agrest.EntityUpdate;
+import io.agrest.reflect.Types;
+
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.List;
+
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+
+public class EntityUpdateCollectionReader implements GenericHttpMessageConverter>> {
+
+ private final EntityUpdateReaderProcessor reader;
+
+ public EntityUpdateCollectionReader(EntityUpdateReaderProcessor reader) {this.reader = reader;}
+
+ @Override
+ public boolean canRead(Type type, Class> contextClass, MediaType mediaType) {
+ var classForType = Types.getClassForType(type).orElse(Object.class);
+ if (!Collection.class.isAssignableFrom(classForType) || !MediaType.APPLICATION_JSON.isCompatibleWith(mediaType)) {
+ return false;
+ }
+
+ return Types.unwrapTypeArgument(type)
+ .filter(collectionParam -> collectionParam instanceof ParameterizedType)
+ .map(collectionParam -> (ParameterizedType) collectionParam)
+ .filter(collectionParam -> EntityUpdate.class.equals((collectionParam).getRawType()))
+ .isPresent();
+ }
+
+ @Override
+ public Collection> read(Type type, Class> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
+ Type entityUpdateType = Types.unwrapTypeArgument(type)
+ .orElseThrow(() -> AgException.internalServerError("Invalid request entity collection type: %s", type));
+
+ return reader.read(entityUpdateType, inputMessage.getBody());
+ }
+
+ @Override
+ public boolean canWrite(Type type, Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public void write(Collection> entityUpdates, Type type, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+
+ }
+
+ @Override
+ public boolean canRead(Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public boolean canWrite(Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public List getSupportedMediaTypes() {
+ return null;
+ }
+
+ @Override
+ public Collection> read(Class extends Collection>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
+ return null;
+ }
+
+ @Override
+ public void write(Collection> entityUpdates, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateReader.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateReader.java
new file mode 100644
index 000000000..3ca744b14
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateReader.java
@@ -0,0 +1,95 @@
+package io.agrest.spring.starter;
+
+import io.agrest.AgException;
+import io.agrest.EntityUpdate;
+import io.agrest.reflect.Types;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.List;
+
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+
+public class EntityUpdateReader implements GenericHttpMessageConverter> {
+
+ private final EntityUpdateReaderProcessor reader;
+
+ public EntityUpdateReader(EntityUpdateReaderProcessor reader) {this.reader = reader;}
+
+ @Override
+ public boolean canRead(Class> clazz, MediaType mediaType) {
+ // this is not called but we must implement it
+ return false;
+ }
+
+ @Override
+ public boolean canWrite(Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public List getSupportedMediaTypes() {
+ return List.of(MediaType.APPLICATION_JSON);
+ }
+
+ @Override
+ public EntityUpdate> read(Class extends EntityUpdate>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
+ var entityStream = inputMessage.getBody();
+ //todo this clazz here may be wrong
+ Collection> updates = reader.read(clazz, entityStream);
+
+ if (updates.isEmpty()) {
+ throw AgException.badRequest("No update");
+ }
+
+ if (updates.size() > 1) {
+ throw AgException.badRequest("Expected single update. Found: %s", updates.size());
+ }
+
+ return updates.iterator().next();
+ }
+
+ @Override
+ public void write(EntityUpdate> entityUpdate, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+ throw new IllegalStateException("Should never be called!");
+ }
+
+ @Override
+ public boolean canRead(Type type, Class> contextClass, MediaType mediaType) {
+ return EntityUpdate.class.equals(Types.getClassForType(type).orElse(Object.class))
+ && MediaType.APPLICATION_JSON.isCompatibleWith(mediaType);
+ }
+
+ @Override
+ public EntityUpdate> read(Type type, Class> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
+ var entityStream = inputMessage.getBody();
+ //todo this clazz here may be wrong
+ Collection> updates = reader.read(type, entityStream);
+
+ if (updates.isEmpty()) {
+ throw AgException.badRequest("No update");
+ }
+
+ if (updates.size() > 1) {
+ throw AgException.badRequest("Expected single update. Found: %s", updates.size());
+ }
+
+ return updates.iterator().next();
+ }
+
+ @Override
+ public boolean canWrite(Type type, Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public void write(EntityUpdate> entityUpdate, Type type, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+ throw new IllegalStateException("Should never be called!");
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateReaderProcessor.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateReaderProcessor.java
new file mode 100644
index 000000000..47af6e3c6
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/EntityUpdateReaderProcessor.java
@@ -0,0 +1,26 @@
+package io.agrest.spring.starter;
+
+import io.agrest.EntityUpdate;
+import io.agrest.meta.AgSchema;
+import io.agrest.reflect.Types;
+import io.agrest.runtime.protocol.IEntityUpdateParser;
+
+import java.io.InputStream;
+import java.lang.reflect.Type;
+import java.util.Collection;
+
+class EntityUpdateReaderProcessor {
+
+ private IEntityUpdateParser parser;
+ private AgSchema schema;
+
+ EntityUpdateReaderProcessor(IEntityUpdateParser parser, AgSchema schema) {
+ this.parser = parser;
+ this.schema = schema;
+ }
+
+ Collection> read(Type entityUpdateType, InputStream entityStream) {
+ Class typeClass = (Class) Types.getClassForTypeArgument(entityUpdateType).orElse(Object.class);
+ return parser.parse(schema.getEntity(typeClass), entityStream);
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/SimpleResponseWriter.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/SimpleResponseWriter.java
new file mode 100644
index 000000000..e1724460c
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/SimpleResponseWriter.java
@@ -0,0 +1,58 @@
+package io.agrest.spring.starter;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import io.agrest.SimpleResponse;
+import io.agrest.runtime.jackson.IJacksonService;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+
+public class SimpleResponseWriter implements HttpMessageConverter {
+
+ private final IJacksonService jacksonService;
+
+ public SimpleResponseWriter(IJacksonService jacksonService) {
+ this.jacksonService = jacksonService;
+ }
+
+ @Override
+ public boolean canRead(Class> clazz, MediaType mediaType) {
+ return false;
+ }
+
+ @Override
+ public boolean canWrite(Class> clazz, MediaType mediaType) {
+ return SimpleResponse.class.isAssignableFrom(clazz);
+ }
+
+ @Override
+ public List getSupportedMediaTypes() {
+ return null;
+ }
+
+ @Override
+ public SimpleResponse read(Class extends SimpleResponse> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
+ return null;
+ }
+
+ @Override
+ public void write(SimpleResponse simpleResponse, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
+ var entityStream = outputMessage.getBody();
+ jacksonService.outputJson(out -> writeData(simpleResponse, out), entityStream);
+ }
+
+ protected void writeData(SimpleResponse t, JsonGenerator out) throws IOException {
+ out.writeStartObject();
+ if (t.getMessage() != null) {
+ out.writeStringField("message", t.getMessage());
+ }
+ out.writeEndObject();
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/exceptions/ApiError.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/exceptions/ApiError.java
new file mode 100644
index 000000000..1fbbea8f7
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/exceptions/ApiError.java
@@ -0,0 +1,15 @@
+package io.agrest.spring.starter.exceptions;
+
+public class ApiError {
+
+ private final String message;
+
+ public ApiError(String message) {
+ super();
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/exceptions/RestResponseEntityExceptionHandler.java b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/exceptions/RestResponseEntityExceptionHandler.java
new file mode 100644
index 000000000..9503dffbb
--- /dev/null
+++ b/agrest-spring-boot-starter/src/main/java/io/agrest/spring/starter/exceptions/RestResponseEntityExceptionHandler.java
@@ -0,0 +1,27 @@
+package io.agrest.spring.starter.exceptions;
+
+import io.agrest.AgException;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+@ControllerAdvice
+public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
+
+ @ExceptionHandler(value = {AgException.class})
+ protected ResponseEntity