From fe599fd2c4f0006dbe4e0d2f5dffb18953828d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 24 Mar 2023 09:36:52 +0000 Subject: [PATCH 1/7] sss --- .../client/StatementHttpMessageWriter.java | 353 ++++++++++++++++++ .../dev/learning/xapi/client/XapiClient.java | 24 +- 2 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java new file mode 100644 index 00000000..69beae5c --- /dev/null +++ b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java @@ -0,0 +1,353 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.client; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.reactivestreams.Publisher; +import org.springframework.core.ResolvableType; +import org.springframework.core.ResolvableTypeProvider; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.Hints; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.log.LogFormatUtils; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.FormHttpMessageWriter; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.http.codec.multipart.MultipartWriterSupport; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * {@link HttpMessageWriter} for writing a {@code MultiValueMap} as multipart form data, + * i.e. {@code "multipart/form-data"}, to the body of a request. + *

+ * The serialization of individual parts is delegated to other writers. By default only + * {@link String} and {@link Resource} parts are supported but you can configure others through a + * constructor argument. + *

+ * This writer can be configured with a {@link FormHttpMessageWriter} to delegate to. It is the + * preferred way of supporting both form data and multipart data (as opposed to registering each + * writer separately) so that when the {@link MediaType} is not specified and generics are not + * present on the target element type, we can inspect the values in the actual map and decide + * whether to write plain form data (String values only) or otherwise. + * + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + * @since 5.0 + * @see FormHttpMessageWriter + */ +public class StatementHttpMessageWriter extends MultipartWriterSupport + implements HttpMessageWriter { + + /** Suppress logging from individual part writers (full map logged at this level). */ + private static final Map DEFAULT_HINTS = Hints.from(Hints.SUPPRESS_LOGGING_HINT, true); + + private final Supplier>> partWritersSupplier; + + @Nullable + private final HttpMessageWriter> formWriter; + + /** + * Constructor with a default list of part writers (String and Resource). + */ + public StatementHttpMessageWriter() { + // TODO: set encoders properly + this(Arrays.asList(new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()), + new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()))); + } + + /** + * Constructor with explicit list of writers for serializing parts. + */ + public StatementHttpMessageWriter(List> partWriters) { + this(partWriters, new FormHttpMessageWriter()); + } + + /** + * Constructor with explicit list of writers for serializing parts and a writer for plain form + * data to fall back when no media type is specified and the actual map consists of String values + * only. + * + * @param partWriters the writers for serializing parts + * @param formWriter the fallback writer for form data, {@code null} by default + */ + public StatementHttpMessageWriter(List> partWriters, + @Nullable HttpMessageWriter> formWriter) { + + this(() -> partWriters, formWriter); + } + + /** + * Constructor with a supplier for an explicit list of writers for serializing parts and a writer + * for plain form data to fall back when no media type is specified and the actual map consists of + * String values only. + * + * @param partWritersSupplier the supplier for writers for serializing parts + * @param formWriter the fallback writer for form data, {@code null} by default + * @since 6.0.3 + */ + public StatementHttpMessageWriter(Supplier>> partWritersSupplier, + @Nullable HttpMessageWriter> formWriter) { + + super(initMediaTypes(formWriter)); + this.partWritersSupplier = partWritersSupplier; + this.formWriter = formWriter; + } + + static final List MIME_TYPES = List.of(MediaType.MULTIPART_MIXED); + + private static List initMediaTypes(@Nullable HttpMessageWriter formWriter) { + final List result = new ArrayList<>(MIME_TYPES); + if (formWriter != null) { + result.addAll(formWriter.getWritableMediaTypes()); + } + return Collections.unmodifiableList(result); + } + + /** + * Return the configured part writers. + * + * @since 5.0.7 + */ + public List> getPartWriters() { + return Collections.unmodifiableList(this.partWritersSupplier.get()); + } + + /** + * Return the configured form writer. + * + * @since 5.1.13 + */ + @Nullable + public HttpMessageWriter> getFormWriter() { + return this.formWriter; + } + + @Override + public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { + System.err.println(elementType.toClass()); + if (MultiValueMap.class.isAssignableFrom(elementType.toClass())) { + if (mediaType == null) { + return true; + } + for (final MediaType supportedMediaType : getWritableMediaTypes()) { + if (supportedMediaType.isCompatibleWith(mediaType)) { + return true; + } + } + } + return false; + } + + @Override + public Mono write(Publisher inputStream, ResolvableType elementType, + @Nullable MediaType mediaType, ReactiveHttpOutputMessage outputMessage, + Map hints) { + + return Mono.from(inputStream).flatMap(object -> { + if (this.formWriter == null || isMultipart(map, mediaType)) { + return writeMultipart(map, outputMessage, mediaType, hints); + } else { + @SuppressWarnings("unchecked") + final Mono< + MultiValueMap> input = Mono.just((MultiValueMap) map); + return this.formWriter.write(input, elementType, mediaType, outputMessage, hints); + } + }); + } + + private boolean isMultipart(MultiValueMap map, @Nullable MediaType contentType) { + if (contentType != null) { + return contentType.getType().equalsIgnoreCase("multipart"); + } + for (final List values : map.values()) { + for (final Object value : values) { + if (value != null && !(value instanceof String)) { + return true; + } + } + } + return false; + } + + private Mono writeMultipart(Object map, ReactiveHttpOutputMessage outputMessage, + @Nullable MediaType mediaType, Map hints) { + + final byte[] boundary = generateMultipartBoundary(); + + mediaType = getMultipartMediaType(mediaType, boundary); + outputMessage.getHeaders().setContentType(mediaType); + + LogFormatUtils.traceDebug(logger, + traceOn -> Hints.getLogPrefix(hints) + "Encoding " + + (isEnableLoggingRequestDetails() ? LogFormatUtils.formatValue(map, !traceOn) + : "parts " + map.keySet() + " (content masked)")); + + final DataBufferFactory bufferFactory = outputMessage.bufferFactory(); + + Flux body = Flux.fromIterable(map.entrySet()) + .concatMap( + entry -> encodePartValues(boundary, entry.getKey(), entry.getValue(), bufferFactory)) + .concatWith(generateLastLine(boundary, bufferFactory)) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + + if (logger.isDebugEnabled()) { + body = body.doOnNext(buffer -> Hints.touchDataBuffer(buffer, hints, logger)); + } + + return outputMessage.writeWith(body); + } + + private Flux encodePartValues(byte[] boundary, String name, List values, + DataBufferFactory bufferFactory) { + + return Flux.fromIterable(values) + .concatMap(value -> encodePart(boundary, name, value, bufferFactory)); + } + + @SuppressWarnings("unchecked") + private Flux encodePart(byte[] boundary, String name, T value, + DataBufferFactory factory) { + final MultipartHttpOutputMessage message = new MultipartHttpOutputMessage(factory); + final HttpHeaders headers = message.getHeaders(); + + T body; + ResolvableType resolvableType = null; + if (value instanceof HttpEntity) { + final HttpEntity httpEntity = (HttpEntity) value; + headers.putAll(httpEntity.getHeaders()); + body = httpEntity.getBody(); + Assert.state(body != null, "MultipartHttpMessageWriter only supports HttpEntity with body"); + if (httpEntity instanceof ResolvableTypeProvider) { + resolvableType = ((ResolvableTypeProvider) httpEntity).getResolvableType(); + } + } else { + body = value; + } + if (resolvableType == null) { + resolvableType = ResolvableType.forClass(body.getClass()); + } + + if (!headers.containsKey(HttpHeaders.CONTENT_DISPOSITION)) { + if (body instanceof Resource) { + headers.setContentDispositionFormData(name, ((Resource) body).getFilename()); + } else if (resolvableType.resolve() == Resource.class) { + body = (T) Mono.from((Publisher) body).doOnNext( + o -> headers.setContentDispositionFormData(name, ((Resource) o).getFilename())); + } else { + headers.setContentDispositionFormData(name, null); + } + } + + final MediaType contentType = headers.getContentType(); + + final ResolvableType finalBodyType = resolvableType; + final Optional> writer = this.partWritersSupplier.get().stream() + .filter(partWriter -> partWriter.canWrite(finalBodyType, contentType)).findFirst(); + + if (!writer.isPresent()) { + return Flux.error(new CodecException("No suitable writer found for part: " + name)); + } + + final Publisher< + T> bodyPublisher = body instanceof Publisher ? (Publisher) body : Mono.just(body); + + // The writer will call MultipartHttpOutputMessage#write which doesn't actually write + // but only stores the body Flux and returns Mono.empty(). + + final Mono partContentReady = ((HttpMessageWriter) writer.get()).write(bodyPublisher, + resolvableType, contentType, message, DEFAULT_HINTS); + + // After partContentReady, we can access the part content from MultipartHttpOutputMessage + // and use it for writing to the actual request body + + final Flux partContent = partContentReady.thenMany(Flux.defer(message::getBody)); + + return Flux.concat(generateBoundaryLine(boundary, factory), partContent, + generateNewLine(factory)); + } + + private class MultipartHttpOutputMessage implements ReactiveHttpOutputMessage { + + private final DataBufferFactory bufferFactory; + + private final HttpHeaders headers = new HttpHeaders(); + + private final AtomicBoolean committed = new AtomicBoolean(); + + @Nullable + private Flux body; + + public MultipartHttpOutputMessage(DataBufferFactory bufferFactory) { + this.bufferFactory = bufferFactory; + } + + @Override + public HttpHeaders getHeaders() { + return this.body != null ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers; + } + + @Override + public DataBufferFactory bufferFactory() { + return this.bufferFactory; + } + + @Override + public void beforeCommit(Supplier> action) { + this.committed.set(true); + } + + @Override + public boolean isCommitted() { + return this.committed.get(); + } + + @Override + public Mono writeWith(Publisher body) { + if (this.body != null) { + return Mono.error(new IllegalStateException("Multiple calls to writeWith() not supported")); + } + this.body = generatePartHeaders(this.headers, this.bufferFactory).concatWith(body); + + // We don't actually want to write (just save the body Flux) + return Mono.empty(); + } + + @Override + public Mono writeAndFlushWith(Publisher> body) { + return Mono.error(new UnsupportedOperationException()); + } + + public Flux getBody() { + return this.body != null ? this.body + : Flux.error(new IllegalStateException("Body has not been written yet")); + } + + @Override + public Mono setComplete() { + return Mono.error(new UnsupportedOperationException()); + } + } + +} diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java index 7eeb9361..491db7c0 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java @@ -54,7 +54,13 @@ public XapiClient(WebClient.Builder builder, ObjectMapper objectMapper) { .defaultHeader("X-Experience-API-Version", "1.0.3") - .build(); + .codecs(configurer -> { + + // configurer.defaultCodecs(); + + configurer.customCodecs().register(new StatementHttpMessageWriter()); + + }).build(); } // Statement Resource @@ -114,15 +120,15 @@ public Mono> postStatement(PostStatementRequest request) { final Map queryParams = new HashMap<>(); - final var requestSpec = this.webClient + return this.webClient .method(request.getMethod()) - .uri(u -> request.url(u, queryParams).build(queryParams)); + .uri(u -> request.url(u, queryParams).build(queryParams)) - multipartService.addBody(requestSpec, request.getStatement()); + .bodyValue(request.getStatement()) - return requestSpec.retrieve() + .retrieve() .toEntity(LIST_UUID_TYPE) @@ -161,15 +167,15 @@ public Mono>> postStatements(PostStatementsRequest req final Map queryParams = new HashMap<>(); - final var requestSpec = this.webClient + return this.webClient .method(request.getMethod()) - .uri(u -> request.url(u, queryParams).build(queryParams)); + .uri(u -> request.url(u, queryParams).build(queryParams)) - multipartService.addBody(requestSpec, request.getStatements()); + .bodyValue(request.getStatements()) - return requestSpec.retrieve() + .retrieve() .toEntity(LIST_UUID_TYPE); From eea2f5bdde46a692d38fba4d2494efae6833727b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 24 Mar 2023 16:21:02 +0000 Subject: [PATCH 2/7] working --- .../client/AttachmentHttpMessageWriter.java | 107 +++++++ .../xapi/client/PostStatementsRequest.java | 16 +- .../client/StatementHttpMessageWriter.java | 290 ++++++------------ .../dev/learning/xapi/client/XapiClient.java | 13 +- .../XapiClientAutoConfiguration.java | 5 +- .../xapi/client/XapiClientMultipartTests.java | 8 +- .../learning/xapi/client/XapiClientTests.java | 195 ++++++------ .../dev/learning/xapi/model/Attachment.java | 2 + 8 files changed, 325 insertions(+), 311 deletions(-) create mode 100644 xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java new file mode 100644 index 00000000..350920e7 --- /dev/null +++ b/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.client; + +import dev.learning.xapi.model.Attachment; +import java.util.List; +import java.util.Map; +import org.reactivestreams.Publisher; +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.multipart.MultipartWriterSupport; +import org.springframework.lang.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * {@link HttpMessageWriter} for writing {@link Attachment} data into multipart/mixed requests. + * + * @author István Rátkai (Selindek) + */ +public class AttachmentHttpMessageWriter extends MultipartWriterSupport + implements HttpMessageWriter { + + public AttachmentHttpMessageWriter() { + super(List.of(MediaType.MULTIPART_MIXED)); + } + + @Override + public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { + if (Attachment.class.isAssignableFrom(elementType.toClass())) { + if (mediaType == null) { + return true; + } + System.err.println(" attachment mediaType: " + mediaType); + for (final MediaType supportedMediaType : getWritableMediaTypes()) { + if (supportedMediaType.isCompatibleWith(mediaType)) { + return true; + } + } + } + return false; + } + + @Override + public Mono write(Publisher parts, ResolvableType elementType, + @Nullable MediaType mediaType, ReactiveHttpOutputMessage outputMessage, + Map hints) { + + final var headers = new HttpHeaders(); + + final Flux body = Flux.from(parts) + + .doOnNext(part -> { + // outputMessage.getHeaders().setContentType(MediaType.valueOf(part.getContentType())); + // outputMessage.getHeaders().set("Content-Transfer-Encoding", "binary"); + // outputMessage.getHeaders().set("X-Experience-API-Hash", part.getSha2()); + }) + + .concatMap(part -> encodePart(headers, part, outputMessage.bufferFactory())) + + // .concatWith(generateLastLine(boundary, outputMessage.bufferFactory())) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + + + return body.singleOrEmpty().flatMap(buffer -> { + outputMessage.getHeaders().addAll(headers); + return outputMessage + .writeWith(Mono.just(buffer).doOnDiscard(DataBuffer.class, DataBufferUtils::release)); + }).doOnDiscard(DataBuffer.class, DataBufferUtils::release); + + + // return outputMessage.writeWith(body); + + } + + private Flux encodePart(HttpHeaders headers, Attachment part, + DataBufferFactory bufferFactory) { + headers.setContentType(MediaType.valueOf(part.getContentType())); + headers.set("Content-Transfer-Encoding", "binary"); + headers.set("X-Experience-API-Hash", part.getSha2()); + + return Flux.concat( + + // Mono.fromCallable(() -> { + // final var buffer = bufferFactory.allocateBuffer(part.getContent().length); + // buffer.write(part.getContent()); + // return buffer; + // }), + + Mono.fromCallable(() -> { + final var buffer = bufferFactory.allocateBuffer(part.getContent().length); + buffer.write(part.getContent()); + return buffer; + }) + + ); + } + +} diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index 8e9751d9..b64e27a3 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,7 +5,9 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; import lombok.Builder; @@ -26,7 +28,7 @@ @Getter public class PostStatementsRequest implements Request { - private final List statements; + private final StatementList statements; @Override public HttpMethod getMethod() { @@ -57,7 +59,7 @@ public static class Builder { * @see PostStatementsRequest#statements */ public Builder statements(List statements) { - this.statements = statements; + this.statements = new StatementList(statements); return this; } @@ -71,10 +73,18 @@ public Builder statements(List statements) { * @see PostStatementsRequest#statements */ public Builder statements(Statement... statements) { - this.statements = Arrays.asList(statements); + this.statements = new StatementList(Arrays.asList(statements)); return this; } } + public static final class StatementList extends ArrayList { + + private static final long serialVersionUID = 1013923414137485168L; + + public StatementList(Collection statements) { + super(statements); + } + } } diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java index 69beae5c..41f74924 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java @@ -4,158 +4,81 @@ package dev.learning.xapi.client; +import dev.learning.xapi.client.PostStatementsRequest.StatementList; +import dev.learning.xapi.model.Attachment; +import dev.learning.xapi.model.Statement; +import dev.learning.xapi.model.SubStatement; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; +import java.util.stream.Stream; import org.reactivestreams.Publisher; import org.springframework.core.ResolvableType; -import org.springframework.core.ResolvableTypeProvider; import org.springframework.core.codec.CodecException; import org.springframework.core.codec.Hints; -import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.core.log.LogFormatUtils; -import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpOutputMessage; import org.springframework.http.codec.EncoderHttpMessageWriter; -import org.springframework.http.codec.FormHttpMessageWriter; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.codec.multipart.MultipartWriterSupport; import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.MultiValueMap; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** - * {@link HttpMessageWriter} for writing a {@code MultiValueMap} as multipart form data, - * i.e. {@code "multipart/form-data"}, to the body of a request. + * {@link HttpMessageWriter} for writing a {@link Statement} or {@link StatementList} *

- * The serialization of individual parts is delegated to other writers. By default only - * {@link String} and {@link Resource} parts are supported but you can configure others through a - * constructor argument. - *

- * This writer can be configured with a {@link FormHttpMessageWriter} to delegate to. It is the - * preferred way of supporting both form data and multipart data (as opposed to registering each - * writer separately) so that when the {@link MediaType} is not specified and generics are not - * present on the target element type, we can inspect the values in the actual map and decide - * whether to write plain form data (String values only) or otherwise. + * If any of the provided statements contains an {@link Attachment} with real data, then this writer + * creates a multipart/mixed output otherwise it writes the data as application/json. + *

+ * + * @author István Rátkai (Selindek) * - * @author Sebastien Deleuze - * @author Rossen Stoyanchev - * @since 5.0 - * @see FormHttpMessageWriter + * @see AttachmentHttpMessageWriter */ public class StatementHttpMessageWriter extends MultipartWriterSupport implements HttpMessageWriter { /** Suppress logging from individual part writers (full map logged at this level). */ - private static final Map DEFAULT_HINTS = Hints.from(Hints.SUPPRESS_LOGGING_HINT, true); + private static final Map DEFAULT_HINTS = + Hints.from(Hints.SUPPRESS_LOGGING_HINT, true); private final Supplier>> partWritersSupplier; + private final HttpMessageWriter defaultWriter; - @Nullable - private final HttpMessageWriter> formWriter; - - /** - * Constructor with a default list of part writers (String and Resource). - */ public StatementHttpMessageWriter() { - // TODO: set encoders properly - this(Arrays.asList(new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()), - new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()))); - } - - /** - * Constructor with explicit list of writers for serializing parts. - */ - public StatementHttpMessageWriter(List> partWriters) { - this(partWriters, new FormHttpMessageWriter()); - } - - /** - * Constructor with explicit list of writers for serializing parts and a writer for plain form - * data to fall back when no media type is specified and the actual map consists of String values - * only. - * - * @param partWriters the writers for serializing parts - * @param formWriter the fallback writer for form data, {@code null} by default - */ - public StatementHttpMessageWriter(List> partWriters, - @Nullable HttpMessageWriter> formWriter) { - - this(() -> partWriters, formWriter); - } - - /** - * Constructor with a supplier for an explicit list of writers for serializing parts and a writer - * for plain form data to fall back when no media type is specified and the actual map consists of - * String values only. - * - * @param partWritersSupplier the supplier for writers for serializing parts - * @param formWriter the fallback writer for form data, {@code null} by default - * @since 6.0.3 - */ - public StatementHttpMessageWriter(Supplier>> partWritersSupplier, - @Nullable HttpMessageWriter> formWriter) { - - super(initMediaTypes(formWriter)); - this.partWritersSupplier = partWritersSupplier; - this.formWriter = formWriter; - } - - static final List MIME_TYPES = List.of(MediaType.MULTIPART_MIXED); - - private static List initMediaTypes(@Nullable HttpMessageWriter formWriter) { - final List result = new ArrayList<>(MIME_TYPES); - if (formWriter != null) { - result.addAll(formWriter.getWritableMediaTypes()); - } - return Collections.unmodifiableList(result); - } - - /** - * Return the configured part writers. - * - * @since 5.0.7 - */ - public List> getPartWriters() { - return Collections.unmodifiableList(this.partWritersSupplier.get()); - } - /** - * Return the configured form writer. - * - * @since 5.1.13 - */ - @Nullable - public HttpMessageWriter> getFormWriter() { - return this.formWriter; + super(List.of(MediaType.MULTIPART_MIXED, MediaType.APPLICATION_JSON)); + // TODO: create partwriters for Attachment and Statement / StatementList + this.partWritersSupplier = () -> Arrays.asList(new AttachmentHttpMessageWriter(), + new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder())); + this.defaultWriter = new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()); } @Override public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { - System.err.println(elementType.toClass()); - if (MultiValueMap.class.isAssignableFrom(elementType.toClass())) { - if (mediaType == null) { - return true; - } - for (final MediaType supportedMediaType : getWritableMediaTypes()) { - if (supportedMediaType.isCompatibleWith(mediaType)) { - return true; - } - } + // System.err.println(elementType.toClass()); + if (Statement.class.equals(elementType.toClass()) + || StatementList.class.isAssignableFrom(elementType.toClass())) { + // TODO: test if catches correct types + System.err.println("***" + elementType.resolve()); + return true; + // if (mediaType == null) { + // return true; + // } + // for (final MediaType supportedMediaType : getWritableMediaTypes()) { + // if (supportedMediaType.isCompatibleWith(mediaType)) { + // return true; + // } + // } } return false; } @@ -166,51 +89,34 @@ public Mono write(Publisher inputStream, ResolvableType Map hints) { return Mono.from(inputStream).flatMap(object -> { - if (this.formWriter == null || isMultipart(map, mediaType)) { - return writeMultipart(map, outputMessage, mediaType, hints); + final var list = getParts(object); + if (list.size() > 1) { + // Has attachments + return writeMultipart(list, outputMessage, hints); } else { - @SuppressWarnings("unchecked") - final Mono< - MultiValueMap> input = Mono.just((MultiValueMap) map); - return this.formWriter.write(input, elementType, mediaType, outputMessage, hints); + // No attachments + final Mono input = Mono.just(list.get(0)); + return this.defaultWriter.write(input, elementType, mediaType, outputMessage, hints); } }); } - private boolean isMultipart(MultiValueMap map, @Nullable MediaType contentType) { - if (contentType != null) { - return contentType.getType().equalsIgnoreCase("multipart"); - } - for (final List values : map.values()) { - for (final Object value : values) { - if (value != null && !(value instanceof String)) { - return true; - } - } - } - return false; - } - private Mono writeMultipart(Object map, ReactiveHttpOutputMessage outputMessage, - @Nullable MediaType mediaType, Map hints) { - final byte[] boundary = generateMultipartBoundary(); + private Mono writeMultipart(List list, ReactiveHttpOutputMessage outputMessage, + Map hints) { - mediaType = getMultipartMediaType(mediaType, boundary); - outputMessage.getHeaders().setContentType(mediaType); + final var boundary = generateMultipartBoundary(); - LogFormatUtils.traceDebug(logger, - traceOn -> Hints.getLogPrefix(hints) + "Encoding " - + (isEnableLoggingRequestDetails() ? LogFormatUtils.formatValue(map, !traceOn) - : "parts " + map.keySet() + " (content masked)")); + final var mediaType = getMultipartMediaType(MediaType.MULTIPART_MIXED, boundary); + outputMessage.getHeaders().setContentType(mediaType); - final DataBufferFactory bufferFactory = outputMessage.bufferFactory(); + final var bufferFactory = outputMessage.bufferFactory(); - Flux body = Flux.fromIterable(map.entrySet()) - .concatMap( - entry -> encodePartValues(boundary, entry.getKey(), entry.getValue(), bufferFactory)) - .concatWith(generateLastLine(boundary, bufferFactory)) - .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + Flux body = + Flux.fromIterable(list).concatMap(element -> encodePart(boundary, element, bufferFactory)) + .concatWith(generateLastLine(boundary, bufferFactory)) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); if (logger.isDebugEnabled()) { body = body.doOnNext(buffer -> Hints.touchDataBuffer(buffer, hints, logger)); @@ -219,64 +125,32 @@ private Mono writeMultipart(Object map, ReactiveHttpOutputMessage outputMe return outputMessage.writeWith(body); } - private Flux encodePartValues(byte[] boundary, String name, List values, - DataBufferFactory bufferFactory) { + @SuppressWarnings("unchecked") + private Flux encodePart(byte[] boundary, Object body, DataBufferFactory factory) { + final var message = new MultipartHttpOutputMessage(factory); + final var headers = message.getHeaders(); - return Flux.fromIterable(values) - .concatMap(value -> encodePart(boundary, name, value, bufferFactory)); - } + final var resolvableType = ResolvableType.forClass(body.getClass()); - @SuppressWarnings("unchecked") - private Flux encodePart(byte[] boundary, String name, T value, - DataBufferFactory factory) { - final MultipartHttpOutputMessage message = new MultipartHttpOutputMessage(factory); - final HttpHeaders headers = message.getHeaders(); - - T body; - ResolvableType resolvableType = null; - if (value instanceof HttpEntity) { - final HttpEntity httpEntity = (HttpEntity) value; - headers.putAll(httpEntity.getHeaders()); - body = httpEntity.getBody(); - Assert.state(body != null, "MultipartHttpMessageWriter only supports HttpEntity with body"); - if (httpEntity instanceof ResolvableTypeProvider) { - resolvableType = ((ResolvableTypeProvider) httpEntity).getResolvableType(); - } - } else { - body = value; - } - if (resolvableType == null) { - resolvableType = ResolvableType.forClass(body.getClass()); - } - if (!headers.containsKey(HttpHeaders.CONTENT_DISPOSITION)) { - if (body instanceof Resource) { - headers.setContentDispositionFormData(name, ((Resource) body).getFilename()); - } else if (resolvableType.resolve() == Resource.class) { - body = (T) Mono.from((Publisher) body).doOnNext( - o -> headers.setContentDispositionFormData(name, ((Resource) o).getFilename())); - } else { - headers.setContentDispositionFormData(name, null); - } - } + final var contentType = headers.getContentType(); - final MediaType contentType = headers.getContentType(); + final var finalBodyType = resolvableType; - final ResolvableType finalBodyType = resolvableType; - final Optional> writer = this.partWritersSupplier.get().stream() + final var writer = this.partWritersSupplier.get().stream() .filter(partWriter -> partWriter.canWrite(finalBodyType, contentType)).findFirst(); if (!writer.isPresent()) { - return Flux.error(new CodecException("No suitable writer found for part: " + name)); + return Flux.error( + new CodecException("No suitable writer found for part: " + resolvableType.toClass())); } - final Publisher< - T> bodyPublisher = body instanceof Publisher ? (Publisher) body : Mono.just(body); + final var bodyPublisher = body instanceof Publisher ? (Publisher) body : Mono.just(body); // The writer will call MultipartHttpOutputMessage#write which doesn't actually write // but only stores the body Flux and returns Mono.empty(). - final Mono partContentReady = ((HttpMessageWriter) writer.get()).write(bodyPublisher, + final var partContentReady = ((HttpMessageWriter) writer.get()).write(bodyPublisher, resolvableType, contentType, message, DEFAULT_HINTS); // After partContentReady, we can access the part content from MultipartHttpOutputMessage @@ -350,4 +224,42 @@ public Mono setComplete() { } } + private List getParts(Object object) { + final var list = new ArrayList<>(); + + // first part is the statement / list of statements + list.add(object); + + Stream attachments; + if (object instanceof final Statement statement) { + attachments = getRealAttachments(statement); + } else { + attachments = ((StatementList) object).stream().flatMap(this::getRealAttachments); + } + + list.addAll(attachments.distinct().toList()); + return list; + } + + /** + * Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}. + * + * @param statement a {@link Statement} object + * + * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. + */ + private Stream getRealAttachments(Statement statement) { + + // handle the rare scenario when a sub-statement has an attachment + var stream = statement.getObject() instanceof final SubStatement substatement + && substatement.getAttachments() != null ? substatement.getAttachments().stream() + : Stream.empty(); + + if (statement.getAttachments() != null) { + stream = Stream.concat(stream, statement.getAttachments().stream()); + } + + return stream.filter(a -> a.getContent() != null); + } + } diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java index 491db7c0..07fa0a56 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java @@ -4,7 +4,6 @@ package dev.learning.xapi.client; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.learning.xapi.model.About; import dev.learning.xapi.model.Activity; import dev.learning.xapi.model.Person; @@ -34,7 +33,6 @@ public class XapiClient { private final WebClient webClient; - private final MultipartService multipartService; private static final ParameterizedTypeReference> LIST_UUID_TYPE = new ParameterizedTypeReference<>() {}; @@ -48,19 +46,16 @@ public class XapiClient { * @param builder a {@link WebClient.Builder} object. The caller must set the baseUrl and the * authorization header. */ - public XapiClient(WebClient.Builder builder, ObjectMapper objectMapper) { - this.multipartService = new MultipartService(objectMapper); + public XapiClient(WebClient.Builder builder) { this.webClient = builder .defaultHeader("X-Experience-API-Version", "1.0.3") - .codecs(configurer -> { + .codecs(configurer -> - // configurer.defaultCodecs(); + configurer.customCodecs().register(new StatementHttpMessageWriter()) - configurer.customCodecs().register(new StatementHttpMessageWriter()); - - }).build(); + ).build(); } // Statement Resource diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java b/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java index 9e83e8fd..ab2438ac 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/configuration/XapiClientAutoConfiguration.java @@ -4,7 +4,6 @@ package dev.learning.xapi.client.configuration; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.learning.xapi.client.XapiClient; import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -29,7 +28,7 @@ public class XapiClientAutoConfiguration { @Bean @ConditionalOnMissingBean public XapiClient xapiClient(XapiClientProperties properties, WebClient.Builder builder, - List configurers, ObjectMapper objectMapper) { + List configurers) { if (properties.getAuthorization() != null) { builder.defaultHeader(HttpHeaders.AUTHORIZATION, properties.getAuthorization()); @@ -45,7 +44,7 @@ public XapiClient xapiClient(XapiClientProperties properties, WebClient.Builder configurers.forEach(c -> c.accept(builder)); - return new XapiClient(builder, objectMapper); + return new XapiClient(builder); } diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java index c89b9666..aaa342fd 100644 --- a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java +++ b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java @@ -8,7 +8,6 @@ import static org.hamcrest.core.StringStartsWith.startsWith; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.learning.xapi.model.Activity; import dev.learning.xapi.model.Agent; import dev.learning.xapi.model.Statement; @@ -39,9 +38,6 @@ class XapiClientMultipartTests { @Autowired private WebClient.Builder webClientBuilder; - @Autowired - private ObjectMapper objectMapper; - private MockWebServer mockWebServer; private XapiClient client; @@ -52,7 +48,7 @@ void setUp() throws Exception { webClientBuilder.baseUrl(mockWebServer.url("").toString()); - client = new XapiClient(webClientBuilder, objectMapper); + client = new XapiClient(webClientBuilder); } @@ -266,8 +262,6 @@ void whenPostingStatementsWithAttachmentsThenBodyIsExpected() throws Interrupted "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n[{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\"}]},{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\"},{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}]\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary\r\nContent-Type:application/octet-stream\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\r\n\r\n@ABCDE\r\n--xapi-learning-dev-boundary--")); } - - @Test void whenPostingStatementsWithTimestampAndAttachmentThenNoExceptionIsThrown() throws InterruptedException { diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java index bce73ac9..d06ab622 100644 --- a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java +++ b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientTests.java @@ -7,7 +7,6 @@ import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsInstanceOf.instanceOf; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.learning.xapi.model.About; import dev.learning.xapi.model.Activity; import dev.learning.xapi.model.Person; @@ -23,7 +22,6 @@ import lombok.Getter; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -45,9 +43,6 @@ class XapiClientTests { @Autowired private WebClient.Builder webClientBuilder; - @Autowired - private ObjectMapper objectMapper; - private MockWebServer mockWebServer; private XapiClient client; @@ -58,7 +53,7 @@ void setUp() throws Exception { webClientBuilder.baseUrl(mockWebServer.url("").toString()); - client = new XapiClient(webClientBuilder, objectMapper); + client = new XapiClient(webClientBuilder); } @@ -77,7 +72,7 @@ void whenGettingStatementThenMethodIsGet() throws InterruptedException { // When Getting Statements client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -91,7 +86,7 @@ void whenGettingStatementThenPathIsExpected() throws InterruptedException { // When Getting Statement client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -108,8 +103,8 @@ void whenGettingStatementThenBodyIsInstanceOfStatement() throws InterruptedExcep .addHeader("Content-Type", "application/json; charset=utf-8")); // When Getting Statement - final var response = client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6")) - .block(); + final var response = + client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6")).block(); // Then Body Is Instance Of Statement assertThat(response.getBody(), instanceOf(Statement.class)); @@ -124,7 +119,7 @@ void whenGettingStatementWithAttachmentsThenPathIsExpected() throws InterruptedE client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6").attachments(true)) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -142,7 +137,7 @@ void whenGettingStatementWithCanonicalFormatThenPathIsExpected() throws Interrup r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6").format(StatementFormat.CANONICAL)) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -174,7 +169,7 @@ void whenPostingStatementsThenMethodIsPost() throws InterruptedException { // When posting Statements client.postStatements(r -> r.statements(statements)).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("POST")); @@ -201,7 +196,7 @@ void whenPostingStatementsThenBodyIsExpected() throws InterruptedException { // When Posting Statements client.postStatements(r -> r.statements(attemptedStatement, passedStatement)).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected assertThat(recordedRequest.getBody().readUtf8(), is( @@ -231,7 +226,7 @@ void whenPostingStatementsArrayThenBodyIsExpected() throws InterruptedException // When Posting Statements Array client.postStatements(r -> r.statements(statements)).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected assertThat(recordedRequest.getBody().readUtf8(), is( @@ -261,7 +256,7 @@ void whenPostingStatementsThenContentTypeHeaderIsApplicationJson() throws Interr // When Posting Statements client.postStatements(r -> r.statements(statements)).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -316,7 +311,7 @@ void whenPostingStatementThenMethodIsPost() throws InterruptedException { .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement"))))) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("POST")); @@ -339,7 +334,7 @@ void whenPostingStatementThenBodyIsExpected() throws InterruptedException { .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement"))))) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected assertThat(recordedRequest.getBody().readUtf8(), is( @@ -363,7 +358,7 @@ void whenPostingStatementThenContentTypeHeaderIsApplicationJson() throws Interru .definition(d -> d.addName(Locale.ENGLISH, "Simple Statement"))))) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -380,7 +375,7 @@ void whenGettingVoidedStatementThenMethodIsGet() throws InterruptedException { client.getVoidedStatement( r -> r.voidedId(UUID.fromString("4df42866-40e7-45b6-bf7c-8d5fccbdccd6"))).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -394,7 +389,7 @@ void whenGettingVoidedStatementThenPathIsExpected() throws InterruptedException // When Getting Voided Statement client.getVoidedStatement(r -> r.voidedId("4df42866-40e7-45b6-bf7c-8d5fccbdccd6")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -410,7 +405,7 @@ void whenGettingVoidedStatementWithAttachmentsThenPathIsExpected() throws Interr client.getStatement(r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6").attachments(true)) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -429,7 +424,7 @@ void whenGettingVoidedStatementWithCanonicalFormatThenPathIsExpected() r -> r.id("4df42866-40e7-45b6-bf7c-8d5fccbdccd6").format(StatementFormat.CANONICAL)) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -446,7 +441,7 @@ void whenGettingStatementsThenMethodIsGet() throws InterruptedException { // When Getting Statements client.getStatements().block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -460,7 +455,7 @@ void whenGettingStatementsThenPathIsExpected() throws InterruptedException { // When Getting Statements client.getStatements().block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is("/statements")); @@ -500,7 +495,7 @@ void whenGettingStatementsWithAllParametersThenPathIsExpected() throws Interrupt ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -519,7 +514,7 @@ void whenGettingStatementsWithAgentParameterThenPathIsExpected() throws Interrup ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -538,7 +533,7 @@ void whenGettingStatementsWithVerbParameterThenPathIsExpected() throws Interrupt ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -557,7 +552,7 @@ void whenGettingStatementsWithActivityParameterThenPathIsExpected() throws Inter ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -576,7 +571,7 @@ void whenGettingMoreStatementsThenRequestMethodIsGet() throws InterruptedExcepti ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -594,7 +589,7 @@ void whenGettingMoreStatementsThenRequestURLExpected() throws InterruptedExcepti ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Request URL Is Expected assertThat(recordedRequest.getRequestUrl(), @@ -617,7 +612,7 @@ void whenGettingASingleStateThenMethodIsGet() throws InterruptedException { .stateId("bookmark"), String.class).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -637,7 +632,7 @@ void whenGettingASingleStateThenPathIsExpected() throws InterruptedException { .stateId("bookmark"), String.class).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -658,7 +653,7 @@ void whenGettingASingleStateWithoutRegistrationThenMethodIsGet() throws Interrup .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -678,7 +673,7 @@ void whenGettingASingleStateWithoutRegistrationThenPathIsExpected() throws Inter .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -751,7 +746,7 @@ void whenPostingASingleStateThenMethodIsPost() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("POST")); @@ -775,7 +770,7 @@ void whenPostingASingleStateThenPathIsExpected() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -803,7 +798,7 @@ void whenPostingASingleStateWithContentTypeTextPlainThenContentTypeHeaderIsTextP .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Text Plain assertThat(recordedRequest.getHeader("content-type"), is("text/plain")); @@ -828,7 +823,7 @@ void whenPostingASingleStateWithoutContentTypeThenContentTypeHeaderIsApplication .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -850,7 +845,7 @@ void whenPostingASingleStateWithoutRegistrationThenMethodIsPost() throws Interru .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("POST")); @@ -872,7 +867,7 @@ void whenPostingASingleStateWithoutRegistrationThenPathIsExpected() throws Inter .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -899,7 +894,7 @@ void whenPuttingASingleStateThenMethodIsPut() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("PUT")); @@ -923,7 +918,7 @@ void whenPuttingASingleStateThenPathIsExpected() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -951,7 +946,7 @@ void whenPuttingASingleStateWithContentTypeTextPlainThenContentTypeHeaderIsTextP .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Text Plain assertThat(recordedRequest.getHeader("content-type"), is("text/plain")); @@ -976,7 +971,7 @@ void whenPuttingASingleStateWithoutContentTypeThenContentTypeHeaderIsApplication .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -998,7 +993,7 @@ void whenPuttingASingleStateWithoutRegistrationThenMethodIsPut() throws Interrup .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("PUT")); @@ -1020,7 +1015,7 @@ void whenPuttingASingleStateWithoutRegistrationThenPathIsExpected() throws Inter .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1043,7 +1038,7 @@ void whenDeletingASingleStateThenMethodIsDelete() throws InterruptedException { .stateId("bookmark")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Delete assertThat(recordedRequest.getMethod(), is("DELETE")); @@ -1063,7 +1058,7 @@ void whenDeletingASingleStateThenPathIsExpected() throws InterruptedException { .stateId("bookmark")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1082,7 +1077,7 @@ void whenDeletingASingleStateWithoutRegistrationThenMethodIsDelete() throws Inte .stateId("bookmark")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Delete assertThat(recordedRequest.getMethod(), is("DELETE")); @@ -1102,7 +1097,7 @@ void whenDeletingASingleStateWithoutRegistrationThenPathIsExpected() throws Inte .stateId("bookmark")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1127,7 +1122,7 @@ void whenGettingMultipleStatesThenMethodIsGet() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1149,7 +1144,7 @@ void whenGettingMultipleStatesThenPathIsExpected() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1170,7 +1165,7 @@ void whenGettingMultipleStatesWithoutRegistrationThenMethodIsGet() throws Interr .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1191,7 +1186,7 @@ void whenGettingMultipleStatesWithoutRegistrationThenPathIsExpected() .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1258,7 +1253,7 @@ void whenDeletingMultipleStatesThenMethodIsDelete() throws InterruptedException ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Delete assertThat(recordedRequest.getMethod(), is("DELETE")); @@ -1278,7 +1273,7 @@ void whenDeletingMultipleStatesThenPathIsExpected() throws InterruptedException ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1298,7 +1293,7 @@ void whenDeletingMultipleStatesWithoutRegistrationThenMethodIsDelete() ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Delete assertThat(recordedRequest.getMethod(), is("DELETE")); @@ -1317,7 +1312,7 @@ void whenDeletingMultipleStatesWithoutRegistrationThenPathIsExpected() ).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1336,7 +1331,7 @@ void whenGettingASingleAgentProfileThenMethodIsGet() throws InterruptedException .profileId("greeting"), String.class).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1356,7 +1351,7 @@ void whenGettingASingleAgentProfileThenPathIsExpected() throws InterruptedExcept .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1379,7 +1374,7 @@ void whenDeletingASingleAgentProfileThenMethodIsDelete() throws InterruptedExcep .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Delete assertThat(recordedRequest.getMethod(), is("DELETE")); @@ -1397,7 +1392,7 @@ void whenDeletetingASingleAgentProfileThenPathIsExpected() throws InterruptedExc .profileId("greeting")) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1420,7 +1415,7 @@ void whenPuttingASingleAgentProfileThenMethodIsPut() throws InterruptedException .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Put assertThat(recordedRequest.getMethod(), is("PUT")); @@ -1440,7 +1435,7 @@ void whenPuttingASingleAgentProfileThenPathIsExpected() throws InterruptedExcept .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1464,7 +1459,7 @@ void whenPuttingASingleAgentProfileWithContentTypeTextPlainThenContentTypeHeader .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Text Plain assertThat(recordedRequest.getHeader("content-type"), is("text/plain")); @@ -1485,7 +1480,7 @@ void whenPuttingASingleAgentProfileWithoutContentTypeThenContentTypeHeaderIsAppl .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -1508,7 +1503,7 @@ void whenPuttingASingleAgentProfileWithoutContentTypeThenBodyIsExpected() .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected assertThat(recordedRequest.getBody().readUtf8(), @@ -1532,7 +1527,7 @@ void whenPostingASingleAgentProfileThenMethodIsPost() throws InterruptedExceptio .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("POST")); @@ -1553,7 +1548,7 @@ void whenPostingASingleAgentProfileThenPathIsExpected() throws InterruptedExcept .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1578,7 +1573,7 @@ void whenPostingASingleAgentProfileWithContentTypeTextPlainThenContentTypeHeader .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Text Plain assertThat(recordedRequest.getHeader("content-type"), is("text/plain")); @@ -1600,7 +1595,7 @@ void whenPostingASingleAgentProfileWithoutContentTypeThenContentTypeHeaderIsAppl .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -1624,7 +1619,7 @@ void whenPostingASingleAgentProfileWithoutContentTypeThenBodyIsExpected() .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected assertThat(recordedRequest.getBody().readUtf8(), @@ -1644,7 +1639,7 @@ void whenGettingProfilesThenMethodIsGet() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1663,7 +1658,7 @@ void whenGettingProfilesThenPathIsExpected() throws InterruptedException { .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1685,7 +1680,7 @@ void whenGettingProfilesWithSinceParameterThenPathIsExpected() throws Interrupte .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1704,7 +1699,7 @@ void whenGettingActivityByUriThenMethodIsGet() throws InterruptedException { .getActivity(r -> r.activityId(URI.create("https://example.com/activity/simplestatement"))) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1718,7 +1713,7 @@ void whenGettingActivityByStringThenMethodIsGet() throws InterruptedException { // When Getting Activity By String client.getActivity(r -> r.activityId("https://example.com/activity/simplestatement")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1734,7 +1729,7 @@ void whenGettingActivityThenPathIsExpected() throws InterruptedException { .getActivity(r -> r.activityId(URI.create("https://example.com/activity/simplestatement"))) .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), @@ -1769,7 +1764,7 @@ void whenGettingAgentsThenMethodIsGet() throws InterruptedException { // When Getting Agents client.getAgents(r -> r.agent(a -> a.mbox("mailto:another@example.com"))).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1783,7 +1778,7 @@ void whenGettingAgentsThenPathIsExpected() throws InterruptedException { // When Getting Agents client.getAgents(r -> r.agent(a -> a.mbox("mailto:another@example.com"))).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1800,8 +1795,8 @@ void whenGettingAgentsThenBodyIsInstanceOfPerson() throws InterruptedException { .addHeader("Content-Type", "application/json; charset=utf-8")); // When Getting Agents - final var response = client.getAgents(r -> r.agent(a -> a.mbox("mailto:another@example.com"))) - .block(); + final var response = + client.getAgents(r -> r.agent(a -> a.mbox("mailto:another@example.com"))).block(); // Then Body Is Instance Of Activity assertThat(response.getBody(), instanceOf(Person.class)); @@ -1821,7 +1816,7 @@ void whenGettingAboutThenMethodIsGet() throws InterruptedException { // When Getting About client.getAbout().block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1839,7 +1834,7 @@ void whenGettingAboutThenPathIsExpected() throws InterruptedException { // When Getting About client.getAbout().block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is("/about")); @@ -1873,7 +1868,7 @@ void whenGettingASingleActivityProfileThenMethodIsGet() throws InterruptedExcept .profileId("bookmark"), String.class).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -1889,7 +1884,7 @@ void whenGettingASingleActivityProfileThenPathIsExpected() throws InterruptedExc .profileId("bookmark"), String.class).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1952,7 +1947,7 @@ void whenPostingASingleActivityProfileThenMethodIsPost() throws InterruptedExcep .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("POST")); @@ -1972,7 +1967,7 @@ void whenPostingASingleActivityProfileThenPathIsExpected() throws InterruptedExc .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -1996,7 +1991,7 @@ void whenPostingASingleActivityProfileWithContentTypeTextPlainThenContentTypeHea .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Text Plain assertThat(recordedRequest.getHeader("content-type"), is("text/plain")); @@ -2017,7 +2012,7 @@ void whenPostingASingleActivityProfileWithoutContentTypeThenContentTypeHeaderIsA .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -2039,7 +2034,7 @@ void whenPuttingASingleActivityProfileThenMethodIsPut() throws InterruptedExcept .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Post assertThat(recordedRequest.getMethod(), is("PUT")); @@ -2059,7 +2054,7 @@ void whenPuttingASingleActivityProfileThenPathIsExpected() throws InterruptedExc .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -2083,7 +2078,7 @@ void whenPuttingASingleActivityProfileWithContentTypeTextPlainThenContentTypeHea .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Text Plain assertThat(recordedRequest.getHeader("content-type"), is("text/plain")); @@ -2104,7 +2099,7 @@ void whenPuttingASingleActivityProfileWithoutContentTypeThenContentTypeHeaderIsA .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Content Type Header Is Application Json assertThat(recordedRequest.getHeader("content-type"), is("application/json")); @@ -2122,7 +2117,7 @@ void whenDeletingASingleActivityProfileThenMethodIsDelete() throws InterruptedEx .profileId("bookmark")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Delete assertThat(recordedRequest.getMethod(), is("DELETE")); @@ -2138,7 +2133,7 @@ void whenDeletingASingleActivityProfileThenPathIsExpected() throws InterruptedEx .profileId("bookmark")).block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -2158,7 +2153,7 @@ void whenGettingActivityProfilesWithSinceParameterThenMethodIsGet() throws Inter .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Method Is Get assertThat(recordedRequest.getMethod(), is("GET")); @@ -2178,7 +2173,7 @@ void whenGettingActivityProfilesWithSinceParameterThenPathIsExpected() .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), is( @@ -2197,7 +2192,7 @@ void whenGettingActivityProfilesWithoutSinceParameterThenPathIsExpected() .block(); - final RecordedRequest recordedRequest = mockWebServer.takeRequest(); + final var recordedRequest = mockWebServer.takeRequest(); // Then Path Is Expected assertThat(recordedRequest.getPath(), diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java index 45b7dfb9..f40d697c 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java @@ -14,6 +14,7 @@ import java.security.NoSuchAlgorithmException; import java.util.Locale; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Value; /** @@ -27,6 +28,7 @@ @Value @Builder @JsonInclude(Include.NON_EMPTY) +@EqualsAndHashCode(of = "sha2") public class Attachment { /** From a547f63ce37135eb8bacec90450bbc8866e2e40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Fri, 24 Mar 2023 17:16:13 +0000 Subject: [PATCH 3/7] some simplifications --- .../client/AttachmentHttpMessageWriter.java | 69 ++++--------------- .../client/StatementHttpMessageWriter.java | 38 ++-------- 2 files changed, 21 insertions(+), 86 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java index 350920e7..376b30f4 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/AttachmentHttpMessageWriter.java @@ -12,13 +12,11 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpOutputMessage; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.multipart.MultipartWriterSupport; import org.springframework.lang.Nullable; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** @@ -35,18 +33,7 @@ public AttachmentHttpMessageWriter() { @Override public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { - if (Attachment.class.isAssignableFrom(elementType.toClass())) { - if (mediaType == null) { - return true; - } - System.err.println(" attachment mediaType: " + mediaType); - for (final MediaType supportedMediaType : getWritableMediaTypes()) { - if (supportedMediaType.isCompatibleWith(mediaType)) { - return true; - } - } - } - return false; + return Attachment.class.isAssignableFrom(elementType.toClass()); } @Override @@ -54,54 +41,26 @@ public Mono write(Publisher parts, ResolvableType el @Nullable MediaType mediaType, ReactiveHttpOutputMessage outputMessage, Map hints) { - final var headers = new HttpHeaders(); + return Mono.from(parts).flatMap(part -> { + // set attachment part headers + outputMessage.getHeaders().setContentType(MediaType.valueOf(part.getContentType())); + outputMessage.getHeaders().set("Content-Transfer-Encoding", "binary"); + outputMessage.getHeaders().set("X-Experience-API-Hash", part.getSha2()); - final Flux body = Flux.from(parts) - - .doOnNext(part -> { - // outputMessage.getHeaders().setContentType(MediaType.valueOf(part.getContentType())); - // outputMessage.getHeaders().set("Content-Transfer-Encoding", "binary"); - // outputMessage.getHeaders().set("X-Experience-API-Hash", part.getSha2()); - }) - - .concatMap(part -> encodePart(headers, part, outputMessage.bufferFactory())) - - // .concatWith(generateLastLine(boundary, outputMessage.bufferFactory())) - .doOnDiscard(DataBuffer.class, DataBufferUtils::release); - - - return body.singleOrEmpty().flatMap(buffer -> { - outputMessage.getHeaders().addAll(headers); - return outputMessage - .writeWith(Mono.just(buffer).doOnDiscard(DataBuffer.class, DataBufferUtils::release)); + // write attachment content + return outputMessage.writeWith(encodePart(part, outputMessage.bufferFactory())); }).doOnDiscard(DataBuffer.class, DataBufferUtils::release); - - // return outputMessage.writeWith(body); - } - private Flux encodePart(HttpHeaders headers, Attachment part, - DataBufferFactory bufferFactory) { - headers.setContentType(MediaType.valueOf(part.getContentType())); - headers.set("Content-Transfer-Encoding", "binary"); - headers.set("X-Experience-API-Hash", part.getSha2()); - - return Flux.concat( - - // Mono.fromCallable(() -> { - // final var buffer = bufferFactory.allocateBuffer(part.getContent().length); - // buffer.write(part.getContent()); - // return buffer; - // }), + private Mono encodePart(Attachment part, DataBufferFactory bufferFactory) { - Mono.fromCallable(() -> { - final var buffer = bufferFactory.allocateBuffer(part.getContent().length); - buffer.write(part.getContent()); - return buffer; - }) + return Mono.fromCallable(() -> { + final var buffer = bufferFactory.allocateBuffer(part.getContent().length); + buffer.write(part.getContent()); + return buffer; + }); - ); } } diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java index 41f74924..585088eb 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java @@ -51,36 +51,21 @@ public class StatementHttpMessageWriter extends MultipartWriterSupport private static final Map DEFAULT_HINTS = Hints.from(Hints.SUPPRESS_LOGGING_HINT, true); - private final Supplier>> partWritersSupplier; + private final List> partWritersSupplier; private final HttpMessageWriter defaultWriter; public StatementHttpMessageWriter() { super(List.of(MediaType.MULTIPART_MIXED, MediaType.APPLICATION_JSON)); - // TODO: create partwriters for Attachment and Statement / StatementList - this.partWritersSupplier = () -> Arrays.asList(new AttachmentHttpMessageWriter(), + this.partWritersSupplier = Arrays.asList(new AttachmentHttpMessageWriter(), new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder())); this.defaultWriter = new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()); } @Override public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { - // System.err.println(elementType.toClass()); - if (Statement.class.equals(elementType.toClass()) - || StatementList.class.isAssignableFrom(elementType.toClass())) { - // TODO: test if catches correct types - System.err.println("***" + elementType.resolve()); - return true; - // if (mediaType == null) { - // return true; - // } - // for (final MediaType supportedMediaType : getWritableMediaTypes()) { - // if (supportedMediaType.isCompatibleWith(mediaType)) { - // return true; - // } - // } - } - return false; + return Statement.class.equals(elementType.toClass()) + || StatementList.class.isAssignableFrom(elementType.toClass()); } @Override @@ -101,8 +86,6 @@ public Mono write(Publisher inputStream, ResolvableType }); } - - private Mono writeMultipart(List list, ReactiveHttpOutputMessage outputMessage, Map hints) { @@ -113,15 +96,11 @@ private Mono writeMultipart(List list, ReactiveHttpOutputMessage o final var bufferFactory = outputMessage.bufferFactory(); - Flux body = + final Flux body = Flux.fromIterable(list).concatMap(element -> encodePart(boundary, element, bufferFactory)) .concatWith(generateLastLine(boundary, bufferFactory)) .doOnDiscard(DataBuffer.class, DataBufferUtils::release); - if (logger.isDebugEnabled()) { - body = body.doOnNext(buffer -> Hints.touchDataBuffer(buffer, hints, logger)); - } - return outputMessage.writeWith(body); } @@ -132,13 +111,10 @@ private Flux encodePart(byte[] boundary, Object body, DataBuffer final var resolvableType = ResolvableType.forClass(body.getClass()); - final var contentType = headers.getContentType(); - final var finalBodyType = resolvableType; - - final var writer = this.partWritersSupplier.get().stream() - .filter(partWriter -> partWriter.canWrite(finalBodyType, contentType)).findFirst(); + final var writer = this.partWritersSupplier.stream() + .filter(partWriter -> partWriter.canWrite(resolvableType, contentType)).findFirst(); if (!writer.isPresent()) { return Flux.error( From b03ebccb906752f85f9f1d9a781d9efe83f63106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 27 Mar 2023 13:32:37 +0100 Subject: [PATCH 4/7] refactor StatementHttpMessageWriter use default writers as fallback no need of StatementList --- .../xapi/client/PostStatementsRequest.java | 16 +- .../client/StatementHttpMessageWriter.java | 155 ++++++++++-------- .../dev/learning/xapi/client/XapiClient.java | 2 +- 3 files changed, 93 insertions(+), 80 deletions(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java index b64e27a3..8e9751d9 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/PostStatementsRequest.java @@ -5,9 +5,7 @@ package dev.learning.xapi.client; import dev.learning.xapi.model.Statement; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.List; import java.util.Map; import lombok.Builder; @@ -28,7 +26,7 @@ @Getter public class PostStatementsRequest implements Request { - private final StatementList statements; + private final List statements; @Override public HttpMethod getMethod() { @@ -59,7 +57,7 @@ public static class Builder { * @see PostStatementsRequest#statements */ public Builder statements(List statements) { - this.statements = new StatementList(statements); + this.statements = statements; return this; } @@ -73,18 +71,10 @@ public Builder statements(List statements) { * @see PostStatementsRequest#statements */ public Builder statements(Statement... statements) { - this.statements = new StatementList(Arrays.asList(statements)); + this.statements = Arrays.asList(statements); return this; } } - public static final class StatementList extends ArrayList { - - private static final long serialVersionUID = 1013923414137485168L; - - public StatementList(Collection statements) { - super(statements); - } - } } diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java index 585088eb..1c5aa967 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java @@ -4,12 +4,10 @@ package dev.learning.xapi.client; -import dev.learning.xapi.client.PostStatementsRequest.StatementList; import dev.learning.xapi.model.Attachment; import dev.learning.xapi.model.Statement; import dev.learning.xapi.model.SubStatement; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -18,16 +16,14 @@ import org.reactivestreams.Publisher; import org.springframework.core.ResolvableType; import org.springframework.core.codec.CodecException; -import org.springframework.core.codec.Hints; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpOutputMessage; -import org.springframework.http.codec.EncoderHttpMessageWriter; import org.springframework.http.codec.HttpMessageWriter; -import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; import org.springframework.http.codec.multipart.MultipartWriterSupport; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; @@ -39,6 +35,12 @@ * If any of the provided statements contains an {@link Attachment} with real data, then this writer * creates a multipart/mixed output otherwise it writes the data as application/json. *

+ *

+ * This message-writer accepts ALL objects, so all the default (and any other + * custom) {@link HttpMessageWriter} must be passed to its constructor. If the object to be written + * is not a {@link Statement} or List of Statements with real {@link Attachment}s, then this list of + * writers will be used. + *

* * @author István Rátkai (Selindek) * @@ -47,28 +49,33 @@ public class StatementHttpMessageWriter extends MultipartWriterSupport implements HttpMessageWriter { - /** Suppress logging from individual part writers (full map logged at this level). */ - private static final Map DEFAULT_HINTS = - Hints.from(Hints.SUPPRESS_LOGGING_HINT, true); - - private final List> partWritersSupplier; - private final HttpMessageWriter defaultWriter; + private final List> writers = new ArrayList<>(); - public StatementHttpMessageWriter() { + /** + * Constructor. + * + * @param list list of the original {@link HttpMessageWriter}s. This list is used if the object to + * write is not a {@link Statement} or list of statements or there are no any + * {@link Attachment}s with real data in the statements. + */ + public StatementHttpMessageWriter(List> list) { super(List.of(MediaType.MULTIPART_MIXED, MediaType.APPLICATION_JSON)); - this.partWritersSupplier = Arrays.asList(new AttachmentHttpMessageWriter(), - new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder())); - this.defaultWriter = new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()); + + // Add special writer for attachments + this.writers.add(new AttachmentHttpMessageWriter()); + // ... but otherwise use the default list of writers + this.writers.addAll(list); + } @Override public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { - return Statement.class.equals(elementType.toClass()) - || StatementList.class.isAssignableFrom(elementType.toClass()); + return true; } @Override + @SuppressWarnings("unchecked") public Mono write(Publisher inputStream, ResolvableType elementType, @Nullable MediaType mediaType, ReactiveHttpOutputMessage outputMessage, Map hints) { @@ -76,12 +83,16 @@ public Mono write(Publisher inputStream, ResolvableType return Mono.from(inputStream).flatMap(object -> { final var list = getParts(object); if (list.size() > 1) { - // Has attachments + // Has attachments -> process as multipart return writeMultipart(list, outputMessage, hints); + } else { - // No attachments - final Mono input = Mono.just(list.get(0)); - return this.defaultWriter.write(input, elementType, mediaType, outputMessage, hints); + // No attachments -> pass the original object to the default list of writers + + return ((HttpMessageWriter) writers.stream() + .filter(partWriter -> partWriter.canWrite(elementType, mediaType)).findFirst().get()) + .write(inputStream, elementType, mediaType, outputMessage, hints); + } }); } @@ -96,16 +107,17 @@ private Mono writeMultipart(List list, ReactiveHttpOutputMessage o final var bufferFactory = outputMessage.bufferFactory(); - final Flux body = - Flux.fromIterable(list).concatMap(element -> encodePart(boundary, element, bufferFactory)) - .concatWith(generateLastLine(boundary, bufferFactory)) - .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + final Flux body = Flux.fromIterable(list) + .concatMap(element -> encodePart(boundary, element, bufferFactory, hints)) + .concatWith(generateLastLine(boundary, bufferFactory)) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); return outputMessage.writeWith(body); } @SuppressWarnings("unchecked") - private Flux encodePart(byte[] boundary, Object body, DataBufferFactory factory) { + private Flux encodePart(byte[] boundary, Object body, DataBufferFactory factory, + Map hints) { final var message = new MultipartHttpOutputMessage(factory); final var headers = message.getHeaders(); @@ -113,7 +125,7 @@ private Flux encodePart(byte[] boundary, Object body, DataBuffer final var contentType = headers.getContentType(); - final var writer = this.partWritersSupplier.stream() + final var writer = this.writers.stream() .filter(partWriter -> partWriter.canWrite(resolvableType, contentType)).findFirst(); if (!writer.isPresent()) { @@ -127,7 +139,7 @@ private Flux encodePart(byte[] boundary, Object body, DataBuffer // but only stores the body Flux and returns Mono.empty(). final var partContentReady = ((HttpMessageWriter) writer.get()).write(bodyPublisher, - resolvableType, contentType, message, DEFAULT_HINTS); + resolvableType, contentType, message, hints); // After partContentReady, we can access the part content from MultipartHttpOutputMessage // and use it for writing to the actual request body @@ -138,6 +150,55 @@ private Flux encodePart(byte[] boundary, Object body, DataBuffer generateNewLine(factory)); } + + @SuppressWarnings("unchecked") + private List getParts(Object object) { + + final var list = new ArrayList<>(); + + Stream attachments; + if (object instanceof final Statement statement) { + attachments = getRealAttachments(statement); + } else if (object instanceof final List statements && !statements.isEmpty() + && statements.get(0) instanceof Statement) { + attachments = ((List) statements).stream().flatMap(this::getRealAttachments); + } else { + // The object is not a statement or list of statements + return list; + } + + // first part is the statement / list of statements + list.add(object); + + list.addAll(attachments.distinct().toList()); + return list; + } + + /** + * Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}. + * + * @param statement a {@link Statement} object + * + * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. + */ + private Stream getRealAttachments(Statement statement) { + + // handle the rare scenario when a sub-statement has an attachment + var stream = statement.getObject() instanceof final SubStatement substatement + && substatement.getAttachments() != null ? substatement.getAttachments().stream() + : Stream.empty(); + + if (statement.getAttachments() != null) { + stream = Stream.concat(stream, statement.getAttachments().stream()); + } + + return stream.filter(a -> a.getContent() != null); + } + + /** + * This class was copied from the {@link MultipartHttpMessageWriter} class. Unfortunately it's a + * private class, so I cannot use it directly. + */ private class MultipartHttpOutputMessage implements ReactiveHttpOutputMessage { private final DataBufferFactory bufferFactory; @@ -200,42 +261,4 @@ public Mono setComplete() { } } - private List getParts(Object object) { - final var list = new ArrayList<>(); - - // first part is the statement / list of statements - list.add(object); - - Stream attachments; - if (object instanceof final Statement statement) { - attachments = getRealAttachments(statement); - } else { - attachments = ((StatementList) object).stream().flatMap(this::getRealAttachments); - } - - list.addAll(attachments.distinct().toList()); - return list; - } - - /** - * Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}. - * - * @param statement a {@link Statement} object - * - * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. - */ - private Stream getRealAttachments(Statement statement) { - - // handle the rare scenario when a sub-statement has an attachment - var stream = statement.getObject() instanceof final SubStatement substatement - && substatement.getAttachments() != null ? substatement.getAttachments().stream() - : Stream.empty(); - - if (statement.getAttachments() != null) { - stream = Stream.concat(stream, statement.getAttachments().stream()); - } - - return stream.filter(a -> a.getContent() != null); - } - } diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java index 07fa0a56..1c122478 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/XapiClient.java @@ -53,7 +53,7 @@ public XapiClient(WebClient.Builder builder) { .codecs(configurer -> - configurer.customCodecs().register(new StatementHttpMessageWriter()) + configurer.customCodecs().register(new StatementHttpMessageWriter(configurer.getWriters())) ).build(); } From 9238587217036327d66562226f1113e01fd86f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 27 Mar 2023 14:35:43 +0100 Subject: [PATCH 5/7] remove unused mUltipartHelper --- .../xapi/client/MultipartService.java | 191 ------------------ 1 file changed, 191 deletions(-) delete mode 100644 xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java b/xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java deleted file mode 100644 index 8e5bb97e..00000000 --- a/xapi-client/src/main/java/dev/learning/xapi/client/MultipartService.java +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. - */ - -package dev.learning.xapi.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.learning.xapi.model.Attachment; -import dev.learning.xapi.model.Statement; -import dev.learning.xapi.model.SubStatement; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.util.FastByteArrayOutputStream; -import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; - -/** - * Helper methods for creating multipart message from statements. - * - * @author István Rátkai (Selindek) - */ -@Slf4j -@RequiredArgsConstructor -public final class MultipartService { - - private static final String MULTIPART_BOUNDARY = "xapi-learning-dev-boundary"; - private static final String MULTIPART_CONTENT_TYPE = "multipart/mixed; boundary=" - + MULTIPART_BOUNDARY; - private static final String CRLF = "\r\n"; - private static final String BOUNDARY_PREFIX = "--"; - private static final String BODY_SEPARATOR = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + CRLF; - private static final String BODY_FOOTER = BOUNDARY_PREFIX + MULTIPART_BOUNDARY + BOUNDARY_PREFIX; - private static final String CONTENT_TYPE = HttpHeaders.CONTENT_TYPE + ":"; - - private static final byte[] BA_APP_JSON_HEADER = (CONTENT_TYPE + MediaType.APPLICATION_JSON_VALUE - + CRLF + CRLF).getBytes(StandardCharsets.UTF_8); - private static final byte[] BA_CRLF = CRLF.getBytes(StandardCharsets.UTF_8); - private static final byte[] BA_BODY_SEPARATOR = BODY_SEPARATOR.getBytes(StandardCharsets.UTF_8); - private static final byte[] BA_BODY_FOOTER = BODY_FOOTER.getBytes(StandardCharsets.UTF_8); - private static final byte[] BA_CONTENT_TYPE = CONTENT_TYPE.getBytes(StandardCharsets.UTF_8); - private static final byte[] BA_ENCODING_HEADER = ("Content-Transfer-Encoding:binary" + CRLF) - .getBytes(StandardCharsets.UTF_8); - private static final byte[] BA_X_API_HASH = "X-Experience-API-Hash:" - .getBytes(StandardCharsets.UTF_8); - - public static final MediaType MULTIPART_MEDIATYPE = MediaType.valueOf(MULTIPART_CONTENT_TYPE); - - private final ObjectMapper objectMapper; - - /** - *

- * Add a Statement to the request. - *

- * This method adds the statement and its attachments if there are any to the request body. Also - * sets the content-type to multipart/mixed if needed. - * - * @param requestSpec a {@link RequestBodySpec} object. - * @param statement a {@link Statement} to add. - */ - public void addBody(RequestBodySpec requestSpec, Statement statement) { - - addBody(requestSpec, statement, getRealAttachments(statement)); - - } - - /** - *

- * Adds a List of {@link Statement}s to the request. - *

- * This method adds the statements and their attachments if there are any to the request body. - * Also sets the content-type to multipart/mixed if needed. - * - * @param requestSpec a {@link RequestBodySpec} object. - * @param statements list of {@link Statement}s to add. - */ - public void addBody(RequestBodySpec requestSpec, List statements) { - - addBody(requestSpec, statements, statements.stream().flatMap(this::getRealAttachments)); - - } - - private void addBody(RequestBodySpec requestSpec, Object statements, - Stream attachments) { - - final var attachmentsBody = writeAttachments(attachments); - - if (attachmentsBody.length == 0) { - // add body directly, content-type is default application/json - requestSpec.bodyValue(statements); - } else { - // has at least one attachment with actual data -> set content-type - requestSpec.contentType(MULTIPART_MEDIATYPE); - // construct whole multipart body manually - requestSpec.bodyValue(createMultipartBody(statements, attachmentsBody)); - } - - } - - /** - * Gets {@link Attachment}s of a {@link Statement} which has data property as a {@link Stream}. - * - * @param statement a {@link Statement} object - * @return {@link Attachment} of a {@link Statement} which has data property as a {@link Stream}. - */ - private Stream getRealAttachments(Statement statement) { - - // handle the rare scenario when a sub-statement has an attachment - Stream stream = statement.getObject() instanceof final SubStatement substatement - && substatement.getAttachments() != null ? substatement.getAttachments().stream() - : Stream.empty(); - - if (statement.getAttachments() != null) { - stream = Stream.concat(stream, statement.getAttachments().stream()); - } - - return stream.filter(a -> a.getContent() != null); - } - - private byte[] createMultipartBody(Object statements, byte[] attachments) { - - try (var stream = new FastByteArrayOutputStream()) { - // Multipart Boundary - stream.write(BA_BODY_SEPARATOR); - - // Header of first part - stream.write(BA_APP_JSON_HEADER); - - // Body of first part - stream.write(objectMapper.writeValueAsBytes(statements)); - stream.write(BA_CRLF); - - // Body of attachments - stream.write(attachments); - - // Footer - stream.write(BA_BODY_FOOTER); - - return stream.toByteArrayUnsafe(); - } catch (final IOException e) { - log.error("Cannot create multipart body", e); - return new byte[] {}; - } - } - - /* - * Writes attachments to a byte array. If there are no attachments in the stream then returns an - * empty array. - */ - private static byte[] writeAttachments(Stream attachments) { - - try (var stream = new FastByteArrayOutputStream()) { - - // Write each sha2-identical attachments only once - attachments.collect(Collectors.toMap(Attachment::getSha2, v -> v, (k, v) -> v)).values() - .forEach(a -> { - try { - // Multipart Boundary - stream.write(BA_BODY_SEPARATOR); - - // Multipart headers - stream.write(BA_CONTENT_TYPE); - stream.write(a.getContentType().getBytes(StandardCharsets.UTF_8)); - stream.write(BA_CRLF); - - stream.write(BA_ENCODING_HEADER); - - stream.write(BA_X_API_HASH); - stream.write(a.getSha2().getBytes(StandardCharsets.UTF_8)); - stream.write(BA_CRLF); - stream.write(BA_CRLF); - - // Multipart body - stream.write(a.getContent()); - stream.write(BA_CRLF); - } catch (final IOException e) { - log.error("Cannot create multipart body", e); - } - - }); - - return stream.toByteArrayUnsafe(); - } - } - -} From c898e6ff09139c9cf6550819da3c70e3f22b53e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 27 Mar 2023 16:47:44 +0100 Subject: [PATCH 6/7] fix tests --- .../xapi/client/XapiClientMultipartTests.java | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java index aaa342fd..63ed5895 100644 --- a/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java +++ b/xapi-client/src/test/java/dev/learning/xapi/client/XapiClientMultipartTests.java @@ -109,8 +109,13 @@ void whenPostingStatementWithTextAttachmentThenBodyIsExpected() throws Interrupt final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected - assertThat(recordedRequest.getBody().readUtf8(), is( - "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary--")); + final var boundary = "--" + recordedRequest.getHeader("content-type").substring(25); + + assertThat(recordedRequest.getBody().readUtf8(), is(boundary + + "\r\nContent-Type: application/json\r\nContent-Length: 531\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}\r\n" + + boundary + + "\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\nX-Experience-API-Hash: b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n" + + boundary + "--\r\n")); } @Test @@ -138,8 +143,13 @@ void whenPostingStatementWithBinaryAttachmentThenBodyIsExpected() throws Interru final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected - assertThat(recordedRequest.getBody().readUtf8(), is( - "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\"}]}\r\n--xapi-learning-dev-boundary\r\nContent-Type:application/octet-stream\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\r\n\r\n@ABCD�\r\n--xapi-learning-dev-boundary--")); + final var boundary = "--" + recordedRequest.getHeader("content-type").substring(25); + + assertThat(recordedRequest.getBody().readUtf8(), is(boundary + + "\r\nContent-Type: application/json\r\nContent-Length: 546\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\"}]}\r\n" + + boundary + + "\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: binary\r\nX-Experience-API-Hash: 0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\r\n\r\n@ABCD�\r\n" + + boundary + "--\r\n")); } @Test @@ -204,8 +214,13 @@ void whenPostingSubStatementWithTextAttachmentThenBodyIsExpected() throws Interr final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected - assertThat(recordedRequest.getBody().readUtf8(), is( - "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"https://w3id.org/xapi/adl/verbs/abandoned\",\"display\":{\"und\":\"abandoned\"}},\"object\":{\"objectType\":\"SubStatement\",\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attended\",\"display\":{\"und\":\"attended\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}}\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary--")); + final var boundary = "--" + recordedRequest.getHeader("content-type").substring(25); + + assertThat(recordedRequest.getBody().readUtf8(), is(boundary + + "\r\nContent-Type: application/json\r\nContent-Length: 742\r\n\r\n{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"https://w3id.org/xapi/adl/verbs/abandoned\",\"display\":{\"und\":\"abandoned\"}},\"object\":{\"objectType\":\"SubStatement\",\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attended\",\"display\":{\"und\":\"attended\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}}\r\n" + + boundary + + "\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\nX-Experience-API-Hash: b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n" + + boundary + "--\r\n")); } @Test @@ -220,7 +235,7 @@ void whenPostingStatementsWithAttachmentsThenBodyIsExpected() throws Interrupted .actor(a -> a.name("A N Other").mbox("mailto:another@example.com")) - .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, 69}).length(6) + .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, (byte) 255}).length(6) .contentType("application/octet-stream") .usageType(URI.create("http://adlnet.gov/expapi/attachments/code")) .addDisplay(Locale.ENGLISH, "binary attachment")) @@ -236,7 +251,7 @@ void whenPostingStatementsWithAttachmentsThenBodyIsExpected() throws Interrupted .actor(a -> a.name("A N Other").mbox("mailto:another@example.com")) - .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, 69}).length(6) + .addAttachment(a -> a.content(new byte[] {64, 65, 66, 67, 68, (byte) 255}).length(6) .contentType("application/octet-stream") .usageType(URI.create("http://adlnet.gov/expapi/attachments/code")) .addDisplay(Locale.ENGLISH, "binary attachment")) @@ -258,8 +273,17 @@ void whenPostingStatementsWithAttachmentsThenBodyIsExpected() throws Interrupted final var recordedRequest = mockWebServer.takeRequest(); // Then Body Is Expected - assertThat(recordedRequest.getBody().readUtf8(), is( - "--xapi-learning-dev-boundary\r\nContent-Type:application/json\r\n\r\n[{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\"}]},{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\"},{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}]\r\n--xapi-learning-dev-boundary\r\nContent-Type:text/plain\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n--xapi-learning-dev-boundary\r\nContent-Type:application/octet-stream\r\nContent-Transfer-Encoding:binary\r\nX-Experience-API-Hash:0ff3c6749b3eeaae17254fdf0e2de1f32b21c592f474bf39b62b398e8a787eef\r\n\r\n@ABCDE\r\n--xapi-learning-dev-boundary--")); + final var boundary = "--" + recordedRequest.getHeader("content-type").substring(25); + + assertThat(recordedRequest.getBody().readUtf8(), is(boundary + + "\r\nContent-Type: application/json\r\nContent-Length: 1301\r\n\r\n[{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\"}]},{\"actor\":{\"objectType\":\"Agent\",\"name\":\"A N Other\",\"mbox\":\"mailto:another@example.com\"},\"verb\":{\"id\":\"http://adlnet.gov/expapi/verbs/attempted\",\"display\":{\"und\":\"attempted\"}},\"object\":{\"objectType\":\"Activity\",\"id\":\"https://example.com/activity/simplestatement\",\"definition\":{\"name\":{\"en\":\"Simple Statement\"}}},\"attachments\":[{\"usageType\":\"http://adlnet.gov/expapi/attachments/code\",\"display\":{\"en\":\"binary attachment\"},\"contentType\":\"application/octet-stream\",\"length\":6,\"sha2\":\"0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\"},{\"usageType\":\"http://adlnet.gov/expapi/attachments/text\",\"display\":{\"en\":\"text attachment\"},\"contentType\":\"text/plain\",\"length\":17,\"sha2\":\"b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\"}]}]\r\n" + + boundary + + "\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: binary\r\nX-Experience-API-Hash: 0f4b9b79ad9e0572dbc7ce7d4dd38b96dc66d28ca87d7fd738ec8f9a30935bf6\r\n\r\n@ABCD�\r\n" + + boundary + + "\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\nX-Experience-API-Hash: b154d3fd46a5068da42ba05a8b9c971688ab5a57eb5c3a0e50a23c42a86786e5\r\n\r\nSimple attachment\r\n" + + boundary + "--\r\n")); + + } @Test From a80b643a82d3992b6ebf06c17aa213ef8e343cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20R=C3=A1tkai?= Date: Mon, 27 Mar 2023 16:49:47 +0100 Subject: [PATCH 7/7] fcs --- .../dev/learning/xapi/client/StatementHttpMessageWriter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java index 1c5aa967..31e21d49 100644 --- a/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java +++ b/xapi-client/src/main/java/dev/learning/xapi/client/StatementHttpMessageWriter.java @@ -30,7 +30,7 @@ import reactor.core.publisher.Mono; /** - * {@link HttpMessageWriter} for writing a {@link Statement} or {@link StatementList} + * {@link HttpMessageWriter} for writing a {@link Statement} or {@link StatementList}. *

* If any of the provided statements contains an {@link Attachment} with real data, then this writer * creates a multipart/mixed output otherwise it writes the data as application/json.