Is it possible to concisely implement a single HAL-JSON & JSON endpoints in Spring Boot 2? The goal is to have:
curl -v http://localhost:8091/books
return this application/hal+json result:
{
"_embedded" : {
"bookList" : [ {
"title" : "The As",
"author" : "ab",
"isbn" : "A"
}, {
"title" : "The Bs",
"author" : "ab",
"isbn" : "B"
}, {
"title" : "The Cs",
"author" : "cd",
"isbn" : "C"
} ]
}
and for this (and/or the HTTP Accept header since this is a REST API):
curl -v http://localhost:8091/books?format=application/json
to return the plain application/json result:
[ {
"title" : "The As",
"author" : "ab",
"isbn" : "A"
}, {
"title" : "The Bs",
"author" : "ab",
"isbn" : "B"
}, {
"title" : "The Cs",
"author" : "cd",
"isbn" : "C"
} ]
with minimal controller code. These endpoints work as expected:
#GetMapping("/asJson")
public Collection<Book> booksAsJson() {
return _books();
}
#GetMapping("/asHalJson")
public CollectionModel<Book> booksAsHalJson() {
return _halJson(_books());
}
#GetMapping
public ResponseEntity<?> booksWithParam(
#RequestParam(name="format", defaultValue="application/hal+json")
String format) {
return _selectedMediaType(_books(), format);
}
#GetMapping("/asDesired")
public ResponseEntity<?> booksAsDesired() {
return _selectedMediaType(_books(), _format());
}
with the following helpers:
private String _format() {
// TODO: something clever here...perhaps Spring's content-negotiation?
return MediaTypes.HAL_JSON_VALUE;
}
private <T> static CollectionModel<T> _halJson(Collection<T> items) {
return CollectionModel.of(items);
}
private <T> static ResponseEntity<?> _selectedMediaType(
Collection<T> items, String format) {
return ResponseEntity.ok(switch(format.toLowerCase()) {
case MediaTypes.HAL_JSON_VALUE -> _halJson(items);
case MediaType.APPLICATION_JSON_VALUE -> items;
default -> throw _unknownFormat(format);
});
}
but the booksWithParam implementation is too messy to duplicate for each endpoint. Is there a way to get to, or close to, something like the booksAsDesired implementation or something similarly concise?
One way you could tell Spring that you want to support plain JSON is by adding a custom converter for such media types. This can be done by overwriting the extendMessageConverters method of WebMvcConfigurer and adding your custom converters there like in the sample below:
import ...PlainJsonHttpMessageConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.web.config.EnableSpringDataWebSuport;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servelt.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
import javax.annotation.Nonnull;
#Configuration
#EnableSpringeDataWebSupport
public class WebMvcConfiguration implements WebMvcConfigurer {
#Override
public void extendMessageConverters(#Nonnull final List<HttpMessageConverter<?>> converters) {
converters.add(new PlainJsonHttpMessageConverter());
}
}
The message converter itself is also no rocket-science as can be seen by the PlainJsonHttpMessageConverter sample below:
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsr310.JavaTimeModule;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;
import javax.annotation.Nonnull;
#Component
public class PlainJsonHttpMessageConverter extends AbstractJackson2HttpMessageConverter {
public PlainJsonHttpMessageConverter() {
super(new ObjectMapper(), MediaType.APPLICATION_JSON);
// add support for date and time format conversion to ISO 8601 and others
this.defaultObjectMapper.registerModule(new JavaTimeModule());
// return JSON payload in pretty format
this.defaultObjectMapper.enable(SerializationFeature.INDENT_OUTPUT);
}
#Override
protected boolean supports(#Nonnull final Class<?> clazz) {
return RepresentationModel.class.isAssignableFrom(clazz);
}
}
This should enable plain JSON support besides HAL-JSON without you having to do any further branching or custom media-type specific conversion within your domain logic or service code.
I.e. let's take a simple task as example case. Within a TaskController you might have a code like this
#GetMapping(path = "/{taskId:.+}", produces = {
MediaTypes.HAL_JSON_VALUE,
MediaType.APPLICATION_JSON_VALUE,
MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE
})
public ResponseEntity<?> task(#PathVariable("taskId") String taskId,
#RequestParam(required = false) Map<String, String> queryParams,
HttpServletRequest request) {
if (queryParams == null) {
queryParams = new HashMap<>();
}
Pageable pageable = RequestUtils.getPageableForInput(queryParams);
final String caseId = queryParams.get("caseId");
...
final Query query = buildSearchCriteria(taskId, caseId, ...);
query.with(pageable);
List<Task> matches = mongoTemplate.find(query, Task.class);
if (!matches.isEmpty()) {
final Task task = matches.get(0);
return ResponseEntity.ok()
.eTag(Long.toString(task.getVersion())
.body(TASK_ASSEMBLER.toModel(task));
} else {
if (request.getHeader("Accept").contains(MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaTypes.HTTP_PROBLEM_DETAILS_JSON)
.body(generateNotFoundProblem(request, taskId));
} else {
final String msg = "No task with ID " + taskId + " found";
throw new ResponseStatusException(HttpStatus.NOT_FOUND, msg);
}
}
}
which simply retrieves an arbitrary task via its unique identifier and returns the representation for it according to the one specified in the Accept HTTP header. The TASK_ASSEMBLER here is just a custom Spring HATEOAS RepresentationModelAssembler<Task, TaskResource> class that converts task objects to task resources by adding links for certain related things.
This can now be easily tested via Spring MVC tests such as
#Test
public void halJson() throws Exception {
given(mongoTemplate.find(any(Query.class), eq(Task.class)))
.willReturn(setupSingleTaskList());
final ResultActions result = mockMvc.perform(
get("/api/tasks/taskId")
.accept(MediaTypes.HAL_JSON_VALUE)
);
result.andExpect(status().isOk())
.andExpect(content().contentType(MediaTypes.HAL_JSON_VALUE));
// see raw payload received by commenting out below line
// System.err.println(result.andReturn().getResponse().getContentAsString());
verifyHalJson(result);
}
#Test
public void plainJson() throws Exception {
given(mongoTemplate.find(any(Query.class), eq(Task.class)))
.willReturn(setupSingleTaskList());
final ResultActions result = mockMvc.perform(
get("/api/tasks/taskId")
.accept(MediaType.APPLICATION_JSON_VALUE)
);
result.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE));
// see raw payload received by commenting out below line
// System.err.println(result.andReturn().getResponse().getContentAsString());
verifyPlainJson(result);
}
...
private void verifyHalJson(final ResultActions action) throws Exception {
action.andExpect(jsonPath("taskId", is("taskId")))
.andExpect(jsonPath("caseId", is("caseId")))
...
.andExpect(jsonPath("_links.self.href", is(BASE_URI + "/tasks/taskId")))
.andExpect(jsonPath("_links.up.href", is(BASE_URI + "/tasks")));
}
rivate void verifyPlainJson(final ResultActions action) throws Exception {
action.andExpect(jsonPath("taskId", is("taskId")))
.andExpect(jsonPath("caseId", is("caseId")))
...
.andExpect(jsonPath("links[0].rel", is("self")))
.andExpect(jsonPath("links[0].href", is(BASE_URI + "/tasks/taskId")))
.andExpect(jsonPath("links[1].rel", is("up")))
.andExpect(jsonPath("links[1].href", is(BASE_URI + "/tasks")));
}
Note how links are presented here differently depending on which media type you've selected.
Related
This bounty has ended. Answers to this question are eligible for a +100 reputation bounty. Bounty grace period ends in 22 hours.
James Wierzba wants to draw more attention to this question.
I'm running a dropwizard/jersey java restful web app.
I have an endpoint that is defined like this in api.yaml:
swagger: '2.0'
info:
version: 0.0.1
basePath: /
schemes:
- https
- http
consumes:
- application/json
- application/x-protobuf
produces:
- application/json
- application/x-protobuf
paths:
/v1/event:
post:
summary: receive a event
operationId: receiveEvent ## this value names the generated Java method
parameters:
- name: event
in: body
schema:
$ref: "#/definitions/Event"
responses:
200:
description: success
schema:
type: object
$ref: '#/definitions/EventResponse'
definitions:
Stream:
properties:
vendor:
type: "string"
Event:
properties:
eventCity:
type: "string"
streams:
type: "array"
items:
$ref: "#/definitions/Stream"
EventResponse:
required:
- statusCode
properties:
statusCode:
type: "integer"
Endpoint is defined like so
#POST
#Consumes({ "application/json", "application/x-protobuf" })
#Produces({ "application/json", "application/x-protobuf" })
#Path("/event")
void receiveEvent(
#Suspended AsyncResponse response,
#Valid Event.EventModel event
);
When issuing json POST request, I cannot get the streams field to get serialized/deserialized property.
This is the payload
{
"eventCity": "San Diego",
"streams": [
{
"vendor": "CBS"
}
]
}
I test like this with curl
curl -X POST -H "Content-Type: application/json" -d '{"eventCity": "San Diego", "streams": [{"vendor": "CBS"}]}' https://localhost:8990/v1/event
In the server request handler:
#Override
public void receiveEvent(AsyncResponse response, Event.EventModel event) {
System.out.println(event.getEventCity());
System.out.println(event.getStreamCount()); // <-- this returns 0? why is the inner 'streams' list not getting serialized? it should have one element
}
And the output:
San Diego
0
Another observation, is that when I issue the same post, but with a protobuf payload, it works. The streams list is populated.
The protobuf was generated like so
// create proto
Stream.StreamModel stream = Stream.StreamModel.newBuilder()
.setVendor("CBS")
.build();
Event.EventModel event = Event.EventModel.newBuilder()
.setEventCity("San Diego")
.addStream(stream)
.build();
// write to file
FileOutputStream fos = new FileOutputStream("/Users/jameswierzba/temp/proto.out");
stream.writeTo(fos);
The output in the endpoint is as expected:
San Diego
1
This is the full generated code for the Event class: https://gist.github.com/wierzba3/84face6c21c4fb6ce554f90707ba6ef9
This is the full generated doe for the Stream class: https://gist.github.com/wierzba3/32664312df87c64049b281daab928f94
You can't use the same class as the payload for protobuf and json.
If you inspect the Event generated for protobuf processing you will notice that the the stream is stored as stream_ and has a setStream that returns a Builder as follows. A JSON deserialiser can't work with it:
/**
* <code>repeated .com.apple.amp.social.linearmasterplaylist.refresh.model.StreamModel stream = 2;</code>
*/
public Builder setStream(
int index, com.apple.amp.social.linearmasterplaylist.refresh.model.Stream.StreamModel value) {
if (streamBuilder_ == null) {
if (value == null) {
throw new NullPointerException();
}
ensureStreamIsMutable();
stream_.set(index, value);
onChanged();
} else {
streamBuilder_.setMessage(index, value);
}
return this;
}
A JSON deserialiser (probably jackson-databind) needs a definition of Event like this generated with openapi-generator-cli. In this case the stream and its setter look like this: (note that the #JsonProperty("streams") is redundant as the property is named streams)
#JsonProperty("streams")
#Valid
private List<Stream> streams = null;
and
public void setStreams(List<Stream> streams) {
this.streams = streams;
}
I have included the other model definitions here to allow you to try them with you controller to show that JSON is correctly consumed.
It is possible to write code to inspect the incoming media type and fork the processing. Eg the following generated by openapi-generator. I would recommend a separate controller method for each media type and some sort of mapping so that a single service layer call will suffice.
#RequestMapping(
method = RequestMethod.POST,
value = "/v1/event",
produces = { "application/json", "application/x-protobuf" },
consumes = { "application/json", "application/x-protobuf" }
)
default ResponseEntity<EventResponse> receiveEvent(
#Parameter(name = "event", description = "") #Valid #RequestBody(required = false) Event event
) {
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
String exampleString = "{ \"statusCode\" : 0 }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
if (mediaType.isCompatibleWith(MediaType.valueOf("application/x-protobuf"))) {
String exampleString = "Custom MIME type example not yet supported: application/x-protobuf";
ApiUtil.setExampleResponse(request, "application/x-protobuf", exampleString);
break;
}
}
});
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
On a separate note, but I doubt it will help: it looks like the protobuf is using stream (singular) (eg getStreamList()) and the swagger get uses streams (plural).
So, the issue turned out to be a bug in our companies internal implementation of the code generation module.
Workflow looks like this: api.yaml specified -> .proto file generated -> .java code generated.
To be explicit. Using the schema in question. Note I changed the list to have name myStreamArrayInput to illustrate the issue at hand.
Event:
properties:
eventCity:
type: "string"
myStreamArrayInput:
type: "array"
items:
$ref: "#/definitions/Stream"
This would generate a proto like this:
message Event {
optional string eventCity = 1;
/*
this name should be `myStreamArrayInput` or something
close to it. but it is using the name of the TYPE
instead of the name specified by the dev in api.yaml
*/
repeated StreamModel stream;
}
And then this would generate a java class like this
public final class Event {
public static final class EventModel extends
com.google.protobuf.GeneratedMessageV3 implements EventModelOrBuilder {
private EventModel() {
eventCity_ = "";
stream_ = "";
}
}
}
YMMV, since this is an bug in our own internal code generation engine
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
#Path("/myresource")
public class MyResource {
#GET
#Produces(MediaType.APPLICATION_JSON)
public Response getJson() {
ObjectMapper mapper = new ObjectMapper();
List<MyObject> myObjects = new ArrayList<>();
MyObject myObject = new MyObject("foo", new NestedObject("bar"));
myObjects.add(myObject);
String json;
try {
json = mapper.writeValueAsString(myObjects);
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
return Response.ok(json, MediaType.APPLICATION_JSON).build();
}
private static class MyObject {
private String name;
private NestedObject nestedObject;
public MyObject(String name, NestedObject nestedObject) {
this.name = name;
this.nestedObject = nestedObject;
}
#JsonProperty("name")
public String getName() {
return name;
}
#JsonProperty("nested_object")
public NestedObject getNestedObject() {
return nestedObject;
}
}
private static class NestedObject {
private String value;
public NestedObject(String value) {
this.value = value;
}
#JsonProperty("value")
public String getValue() {
return value;
}
}
}
I am using a Spring Boot application.
I have a method in my controller that returns some Resources:
#ResponseBody
#Transactional(rollbackFor = Exception.class)
#GetMapping(value="data/{itemId}/items", produces="application/json")
public Resources<DataExcerpt> listMyData(#PathVariable("debateId") UUID debateId)){
List<DataExcerpt> dataExcerpts = dataService
.listMyData(id)
.stream()
.map(d -> this.projectionFactory.createProjection(DataExcerpt.class, d))
.collect(Collectors.toList());
return new Resources<>(dataExcerpts);
}
This returns something in the form of:
{
"_embedded" : {
"items" : [ {
"position" : {
"name" : "Oui",
"id" : "325cd3b7-1666-4c44-a55f-1e7cc936a3aa",
"color" : "#51B63D",
"usedForPositionType" : "FOR_CON"
},
"id" : "5aa48cfb-5505-43b6-b0a9-5481c895e2bf",
"item" : [ {
"index" : 0,
"id" : "43c2dcd0-6bdb-43b0-be97-2a40b99bc753",
"description" : {
"id" : "021ad7cd-4bf1-4dce-9ea7-10980440a049",
"title" : "Item description",
"modificationCount" : 0
}
} ],
"title" : "Item title",
"originalMaker" : {
"username" : "jeremieca",
"id" : "cfae1a04-cb00-4ad4-b4e8-6971eff64807",
"avatarUrl" : "user-16",
"_links" : {
"self" : {
"href" : "http://some-api-link"
}
}
},
"itemState" : {
"itemState" : "LIVE",
},
"opinionImprovements" : [ ],
"sourcesJson" : [ ],
"makers" : [ {
"username" : "jeremieca",
"id" : "cfae1a04-cb00-4ad4-b4e8-6971eff64807",
"avatarUrl" : "user-16",
"_links" : {
"self" : {
"href" : "http://some-api-link"
}
}
} ],
"modificationsCounter" : 1,
"originalBuyer" : "fd9b68f9-7c0c-4120-869c-c63d1680e7f0",
"updateTrace" : {
"createdOn" : "2020-05-25T08:12:56.846+0000",
"createdBy" : "cfae1a04-cb00-4ad4-b4e8-6971eff64807",
"updatedOn" : "2020-05-25T08:12:56.845+0000",
"updatedBy" : "cfae1a04-cb00-4ad4-b4e8-6971eff64807"
},
"_links" : {
"self" : {
"href" : "some-api-link",
"templated" : true
},
"newEditions" : {
"href" : "some-api-link",
"templated" : true
},
"makers" : {
"href" : "http://some-api-link"
},
"originalMaker" : {
"href" : "http://some-api-link"
}
}
} ]
}
}
On the other end, I also want to cache these sort of answers inside Redis to avoid running the whole process every time. To do that, I am using Jackson's ObjectMapper to convert my Resources to a string
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.writeValueAsString(controller.listMyData(id)); // the same function as above
writeValueAsString output's structure is different from the first one:
"{content: [...], _links: []}"
So, when I return from my API with the cache content, the structure is not the same from the structure the controller sends me without the cache.
Why is that?
Is Jackson not able to correctly write as string the Resources Hateoas structures?
Am I missing something?
EDIT:
Here is the Resources.class:
package org.springframework.hateoas;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import javax.xml.bind.annotation.XmlAnyElement;
import javax.xml.bind.annotation.XmlElementWrapper;
import javax.xml.bind.annotation.XmlRootElement;
import org.springframework.util.Assert;
#XmlRootElement(name = "entities")
public class Resources<T> extends ResourceSupport implements Iterable<T> {
private final Collection<T> content;
protected Resources() {
this(new ArrayList(), (Link[])());
}
public Resources(Iterable<T> content, Link... links) {
this(content, (Iterable) Arrays.asList(links));
}
public Resources(Iterable<T> content, Iterable<Link> links) {
Assert.notNull(content, "Content must not be null!");
this.content = new ArrayList();
Iterator var3 = content.iterator();
while (var3.hasNext()) {
T element = var3.next();
this.content.add(element);
}
this.add(links);
}
public static <T extends Resource<S>, S> Resources<T> wrap(Iterable<S> content) {
Assert.notNull(content, "Content must not be null!");
ArrayList<T> resources = new ArrayList();
Iterator var2 = content.iterator();
while (var2.hasNext()) {
S element = var2.next();
resources.add(new Resource(element, new Link[0]));
}
return new Resources(resources, new Link[0]);
}
#XmlAnyElement
#XmlElementWrapper
#JsonProperty("content")
public Collection<T> getContent() {
return Collections.unmodifiableCollection(this.content);
}
public Iterator<T> iterator() {
return this.content.iterator();
}
public String toString() {
return String.format("Resources { content: %s, %s }", this.getContent(), super.toString());
}
public boolean equals(Object obj) {
if (obj == this) {
return true;
} else if (obj != null && obj.getClass().equals(this.getClass())) {
Resources<?> that = (Resources) obj;
boolean contentEqual = this.content == null ? that.content == null : this.content.equals(that.content);
return contentEqual ? super.equals(obj) : false;
} else {
return false;
}
}
public int hashCode() {
int result = super.hashCode();
result += this.content == null ? 0 : 17 * this.content.hashCode();
return result;
}
}
Thank you.
The reason is that when Spring configures your MVC or Spring Boot application with HATEOAS, among other things, it will configure custom Jackson modules for handling the serialization and deserialization process of the Resources class and the rest of the object model exposed by the API.
If you want to obtain a similar result, you can do something like the following:
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
// ...
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new Jackson2HalModule());
objectMapper.writeValueAsString(controller.listMyData(id));
My advice would be to provide POJOs, extending hateoas' ResourceSupport, through which the (de-)serialization would go through, eg.
ResourcesJson (the root element)
public class ResourcesJson extends ResourceSupport {
#JsonProperty("_embedded")
private ResourcesEmbeddedListJson embedded;
//getters and setters
}
Embedded "wrapper"
public class ResourcesEmbeddedListJson extends ResourceSupport {
private Collection<T> content;
//getters and setters
}
or if you want to make it less ugly, there's this org.springframework.hateoas.client.Traverson component.
I have defined a global RequestSpecification with some pathParams and using the spec in all requests. But not all the params used in the global spec would be used in requests.
Assume I have mentioned 3 in the globalSpec and in some requests I would only be needing 2 params and in some 1 and in some 0 params.
But Rest-Assured is throwing NoParameterValue exception
Invalid number of path parameters. Expected 1, was 2. Redundant path parameters are: customerOrderId=io.restassured.internal.NoParameterValue#753b84c6.
java.lang.IllegalArgumentException: Invalid number of path parameters. Expected 1, was 2. Redundant path parameters are
Is there any inbuilt config/ignore mechanism that I can use to make rest-assured not throw exceptions?
Sample Code
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import static io.restassured.RestAssured.given;
public class TestRestAssuredParams {
private RequestSpecification defaultRequestSpec = new RequestSpecBuilder()
.addPathParams(
"env", "value1",
"customer", "value2",
"order", "value3"
)
.setContentType(ContentType.JSON)
.build();
private void reqWithNoParam() {
given()
.spec(defaultRequestSpec)
.get("https://www.testapi.com/dev");
}
private void reqWith1Param() {
given()
.spec(defaultRequestSpec)
.get("https://www.testapi.com/{env}");
}
private void reqWith2Param() {
given()
.spec(defaultRequestSpec)
.get("https://www.testapi.com/{env}/{customer}");
}
private void reqWith3Param() {
given()
.spec(defaultRequestSpec)
.get("https://www.testapi.com/{env}/{customer}/order/{order}");
}
public static void main(String[] args) {
TestRestAssuredParams testRestAssuredParams = new TestRestAssuredParams();
testRestAssuredParams.reqWithNoParam();
testRestAssuredParams.reqWith1Param();
testRestAssuredParams.reqWith2Param();
testRestAssuredParams.reqWith3Param();
}
}
How to make sure RestAssured handle the above scenario.
RestAssured version - 4.3.0
Java - 11.0.5
You will have to use the removeParam of FilterableRequestSpecification,
private void reqWithNoParam() {
given().filter((requestSpec, responseSpec, ctx) -> {
requestSpec.removePathParam("env");
requestSpec.removePathParam("customer");
requestSpec.removePathParam("order");
return ctx.next(requestSpec, responseSpec);
}).spec(defaultRequestSpec).log().all().get("https://www.testapi.com/dev");
}
private void reqWith1Param() {
given().filter((requestSpec, responseSpec, ctx) -> {
requestSpec.removePathParam("customer");
requestSpec.removePathParam("order");
return ctx.next(requestSpec, responseSpec);
}).spec(defaultRequestSpec).log().all().get("https://www.testapi.com/{env}");
}
private void reqWith2Param() {
given().filter((requestSpec, responseSpec, ctx) -> {
requestSpec.removePathParam("order");
return ctx.next(requestSpec, responseSpec);
}).spec(defaultRequestSpec).log().all().get("https://www.testapi.com/{env}/{customer}");
}
private void reqWith3Param() {
given().spec(defaultRequestSpec).log().all().get("https://www.testapi.com/{env}/{customer}/order/{order}");
}
removePathParam in the filter does the trick.
FilterableRequestSpecification has a methond getPathParamPlaceholders which will give the params from the url mapped in the request. I removed the unneeded pathParams using that.
private RequestSpecification defaultRequestSpec = new RequestSpecBuilder()
.addPathParams(
"env", "value1",
"customer", "value2",
"order", "value3"
)
.addFilter((requestSpec, responseSpec, ctx) -> {
var paramsFromRequest = requestSpec.getPathParamPlaceholders();
var finalRequestSpec = requestSpec;
finalRequestSpec
.getPathParams()
.forEach((key, val) -> {
if (!paramsFromRequest.contains(key)) {
finalRequestSpec.removePathParam(key);
}
});
return ctx.next(finalRequestSpec, responseSpec);
})
.setContentType(ContentType.JSON)
.build();
How would I convert the following MongoDB query into a query to be used by my Java Spring application? I can't find a way to use pipeline with the provided lookup method.
Here is the query I am attempting to convert. I also want to note that I didn't use $unwind as I wanted the deliveryZipCodeTimings to stay as a grouped collection in the return object.
db.getCollection('fulfillmentChannel').aggregate([
{
$match: {
"dayOfWeek": "SOME_VARIABLE_STRING_1"
}
},
{
$lookup: {
from: "deliveryZipCodeTiming",
let: { location_id: "$fulfillmentLocationId" },
pipeline: [{
$match: {
$expr: {
$and: [
{$eq: ["$fulfillmentLocationId", "$$location_id"]},
{$eq: ["$zipCode", "SOME_VARIABLE_STRING_2"]}
]
}
}
},
{
$project: { _id: 0, zipCode: 1, cutoffTime: 1 }
}],
as: "deliveryZipCodeTimings"
}
},
{
$match: {
"deliveryZipCodeTimings": {$ne: []}
}
}
])
Building upon the info given by #dnickless, I was able to solve this. I'll post the complete solution in the hopes it helps someone else in the future.
I'm using mongodb-driver:3.6.4
First, I had to create a custom aggregation operation class so that I could pass in a custom JSON mongodb query to be used in the aggregation operation. This will allow me to use pipeline within a $lookup which is not supported with the driver version I am using.
public class CustomProjectAggregationOperation implements AggregationOperation {
private String jsonOperation;
public CustomProjectAggregationOperation(String jsonOperation) {
this.jsonOperation = jsonOperation;
}
#Override
public Document toDocument(AggregationOperationContext aggregationOperationContext) {
return aggregationOperationContext.getMappedObject(Document.parse(jsonOperation));
}
}
Now that we have the ability to pass a custom JSON query into our mongodb spring implementation, all that is left is to plug those values into a TypedAggregation query.
public List<FulfillmentChannel> getFulfillmentChannels(
String SOME_VARIABLE_STRING_1,
String SOME_VARIABLE_STRING_2) {
AggregationOperation match = Aggregation.match(
Criteria.where("dayOfWeek").is(SOME_VARIABLE_STRING_1));
AggregationOperation match2 = Aggregation.match(
Criteria.where("deliveryZipCodeTimings").ne(Collections.EMPTY_LIST));
String query =
"{ $lookup: { " +
"from: 'deliveryZipCodeTiming'," +
"let: { location_id: '$fulfillmentLocationId' }," +
"pipeline: [{" +
"$match: {$expr: {$and: [" +
"{ $eq: ['$fulfillmentLocationId', '$$location_id']}," +
"{ $eq: ['$zipCode', '" + SOME_VARIABLE_STRING_2 + "']}]}}}," +
"{ $project: { _id: 0, zipCode: 1, cutoffTime: 1 } }]," +
"as: 'deliveryZipCodeTimings'}}";
TypedAggregation<FulfillmentChannel> aggregation = Aggregation.newAggregation(
FulfillmentChannel.class,
match,
new CustomProjectAggregationOperation(query),
match2
);
AggregationResults<FulfillmentChannel> results =
mongoTemplate.aggregate(aggregation, FulfillmentChannel.class);
return results.getMappedResults();
}
I would like to add this my solution which is repeating in some aspect the solutions posted before.
Mongo driver v3.x
For Mongo driver v3.x I came to the following solution:
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.mongodb.BasicDBList;
import com.mongodb.BasicDBObject;
import com.mongodb.util.JSON;
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext;
public class JsonOperation implements AggregationOperation {
private List<Document> documents;
public JsonOperation(String json) {
Object root = JSON.parse(json);
documents = root instanceof BasicDBObject
? Collections.singletonList(new Document(((BasicDBObject) root).toMap()))
: ((BasicDBList) root).stream().map(item -> new Document((Map<String, Object>) ((BasicDBObject) item).toMap())).collect(Collectors.toList());
}
#Override
public Document toDocument(AggregationOperationContext context) {
// Not necessary to return anything as we override toPipelineStages():
return null;
}
#Override
public List<Document> toPipelineStages(AggregationOperationContext context) {
return documents;
}
}
and then provided that aggregation steps are given in some resource aggregations.json:
[
{
$match: {
"userId": "..."
}
},
{
$lookup: {
let: {
...
},
from: "another_collection",
pipeline: [
...
],
as: "things"
}
},
{
$sort: {
"date": 1
}
}
]
one can use above class as follows:
import static org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation;
Collection<ResultDao> results = mongoTemplate.aggregate(newAggregation(new JsonOperation(resourceToString("aggregations.json", StandardCharsets.UTF_8))), "some_collection", ResultDao.class).getMappedResults();
Mongo driver v4.x
As JSON class was removed from Mongo v4, I have rewritten the class as follows:
import java.util.Collections;
import java.util.List;
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext;
public class JsonOperation implements AggregationOperation {
private List<Document> documents;
private static final String DUMMY_KEY = "dummy";
public JsonOperation(String json) {
documents = parseJson(json);
}
static final List<Document> parseJson(String json) {
return (json.startsWith("["))
? Document.parse("{\"" + DUMMY_KEY + "\": " + json + "}").getList(DUMMY_KEY, Document.class)
: Collections.singletonList(Document.parse(json));
}
#Override
public Document toDocument(AggregationOperationContext context) {
// Not necessary to return anything as we override toPipelineStages():
return null;
}
#Override
public List<Document> toPipelineStages(AggregationOperationContext context) {
return documents;
}
#Override
public String getOperator() {
return documents.iterator().next().keySet().iterator().next();
}
}
but implementation is now a bit ugly because of string manipulations. If somebody has a better idea of how to parse array of objects in a more elegant way, please edit this post or drop a comment. Ideally there should be some method in Mongo core that allows to parse either JSON object or list (returns BasicDBObject/BasicDBList or Document/List<Document>).
Also note that I have skipped the step of transforming Document instances in toPipelineStages() method as it is not necessary in my case:
#Override
public List<Document> toPipelineStages(AggregationOperationContext context) {
return documents.stream().map(document -> context.getMappedObject(document)).collect(Collectors.toList());
}
The drivers are pretty much always a little bit behind the current language features that MongoDB provides - hence some of the latest and greatest features are simply not nicely accessible through the API yet. I am afraid this is one of those cases and you'll need to resort to using strings. Kind of like so (untested):
AggregationOperation match = Aggregation.match(Criteria.where("dayOfWeek").is("SOME_VARIABLE_STRING_1"));
AggregationOperation match2 = Aggregation.match(Criteria.where("deliveryZipCodeTimings").ne([]));
String query = "{ $lookup: { from: 'deliveryZipCodeTiming', let: { location_id: '$fulfillmentLocationId' }, pipeline: [{ $match: { $expr: { $and: [ { $eq: ['$fulfillmentLocationId', '$$location_id']}, { $eq: ['$zipCode', 'SOME_VARIABLE_STRING_2']} ]} } }, { $project: { _id: 0, zipCode: 1, cutoffTime: 1 } }], as: 'deliveryZipCodeTimings' } }";
Aggregation.newAggregation(match, (DBObject) JSON.parse(query), match2);
I faced some JSON parsing exceptions when I used the way explained in the accepted answer, so I dig deep the default MongoDB java driver(version 3) Document class to build up aggregation query and found out any aggregation query can be build u as follows,
Replace each of the element in the mongo console query as follows
Curly braces({) -> new Document()
parameter names are same
Colon(:) -> Coma(,)
Coma(,) -> .append()
Square bracket([) -> Arrays.asList()
AggregationOperation customLookupOperation = new AggregationOperation() {
#Override
public Document toDocument(AggregationOperationContext context) {
return new Document(
"$lookup",
new Document("from", "deliveryZipCodeTiming")
.append("let",new Document("location_id", "$fulfillmentLocationId"))
.append("pipeline", Arrays.<Object> asList(
new Document("$match", new Document("$expr", new Document("$and",
Arrays.<Object>asList(
new Document("$eq", Arrays.<Object>asList("$fulfillmentLocationId", "$$location_id")),
new Document("$eq", Arrays.<Object>asList("$zipCode", "SOME_VARIABLE_STRING_2"))
)))),
new Document("$project", new Document("_id",0).append("zipCode", 1)
.append("cutoffTime", 1)
)
))
.append("as", "deliveryZipCodeTimings")
);
}
};
Finally you can use the aggregation operation in the aggrgation pipeline,
Aggregation aggregation = Aggregation.newAggregation(matchOperation,customLookupOperation,matchOperation2);
For anyone who finds a simple solution and don't want to bother with raw json queries here is wrapper:
#RequiredArgsConstructor
public class PipelineLookUpWrapper implements AggregationOperation {
private final LookupOperation lookup;
private final Aggregation pipelineAggregation;
#Override
public Document toDocument(AggregationOperationContext context) {
return lookup.toDocument(context);
}
#Override
public String getOperator() {
return lookup.getOperator();
}
#Override
public List<Document> toPipelineStages(AggregationOperationContext context) {
List<Document> lookUpPipelineStages = lookup.toPipelineStages(context);
Document lookUp = (Document) lookUpPipelineStages.iterator().next().get(getOperator());
lookUp.append("pipeline", pipelineAggregation.getPipeline().getOperations()
.stream()
.flatMap(operation -> operation.toPipelineStages(context).stream())
.toList());
return lookUpPipelineStages;
}
}
Usage:
var originalLookUp = Aggregation.lookup("from", "localField", "foreignField", "as");
Aggregation pipelineAggregation = Aggregation.newAggregation(Aggregation.match(new Criteria()), Aggregation.skip(1));
AggregationOperation lookUpWithPipeline = new PipelineLookUpWrapper(originalLookUp, pipelineAggregation);
I want to generate a swagger from a JAX-RS endpoint with an external enumeration definition however the generated swagger directly includes the enumeration into the definition of the model. It implies that the enumeration documentation is not generated but also that the same enumeration is duplicated on the client side.
I use the swagger-jaxrs dependency to scan my endpoint and generate the swagger json file. This GitHub repository can be used to reproduce the problem. I also have created a GitHub issue on the swagger-core repository.
The JAX-RS endpoint
#Api("hello")
#Path("/helloSwagger")
public class HelloSwagger {
#ApiOperation(value = "Get all unique customers", notes = "Get all customers matching the given search string.", responseContainer = "Set", response = User.class)
#GET
#Path("/getUniqueUsers")
#Produces(MediaType.APPLICATION_JSON)
public Set<User> getUniqueUsers(
#ApiParam(value = "The search string is used to find customer by their name. Not case sensitive.") #QueryParam("search") String searchString,
#ApiParam(value = "Limits the size of the result set", defaultValue = "50") #QueryParam("limit") int limit
) {
return new HashSet<>(Arrays.asList(new User(), new User()));
}
}
The model with the enumeration
public class User {
private String name = "unknown";
private SynchronizationStatus ldap1 = SynchronizationStatus.UNKNOWN;
private SynchronizationStatus ldap2 = SynchronizationStatus.OFFLINE;
#ApiModelProperty(value = "The user name")
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
#ApiModelProperty(value = "The synchronization status with the LDAP1")
public SynchronizationStatus getLdap1() {
return ldap1;
}
public void setLdap1(SynchronizationStatus ldap1) {
this.ldap1 = ldap1;
}
public SynchronizationStatus getLdap2() {
return ldap2;
}
public void setLdap2(SynchronizationStatus ldap2) {
this.ldap2 = ldap2;
}
}
#ApiModel("The synchronization status with LDAP instance.")
public enum SynchronizationStatus {
UNKNOWN,
SYNC,
OFFLINE,
CONFLICT
}
An extract of the swagger generated
{
(...)
},
"definitions" : {
"User" : {
"type" : "object",
"properties" : {
"name" : {
"type" : "string",
"description" : "The user name"
},
"ldap1" : {
"type" : "string",
"description" : "The synchronization status with the LDAP1",
"enum" : [ "UNKNOWN", "SYNC", "OFFLINE", "CONFLICT" ]
},
"ldap2" : {
"type" : "string",
"enum" : [ "UNKNOWN", "SYNC", "OFFLINE", "CONFLICT" ]
}
}
}
}
}
Expected result
{
(...)
"definitions" : {
"SynchronizationStatus" : {
"description" : "The synchronization status with LDAP instance.",
"enum" : [ "UNKNOWN", "SYNC", "OFFLINE", "CONFLICT" ],
"type" : "string"
},
"User" : {
"type" : "object",
"properties" : {
"name" : {
"type" : "string",
"description" : "The user name"
},
"ldap1" : {
"$ref" : "#/definitions/SynchronizationStatus"
},
"ldap2" : {
"$ref" : "#/definitions/SynchronizationStatus"
}
}
}
}
}
Am I doing something wrong or is it a 'feature' of the swagger-jaxrs library ?
Thanks for your help
Am I doing something wrong or is it a 'feature' of the swagger-jaxrs
library ?
Enum value are treat as primitive value type by swagger and swagger out-of-the-box does not generate model definition for enum type (see code line 209 under). So the is a feature and not related with swagger-jaxrs.
However, you can generate the swagger definition, as per your expectation, by providing the custom model converter(io.swagger.converter.ModelConverter).
But it seems to me a nice feature to be available in swagger out-of-the-box.
Following is a ruff implementation which can help you to generate the expected swagger definition.
package nhenneaux.test.swagger.ext;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.util.Iterator;
import java.util.List;
import com.fasterxml.jackson.databind.JavaType;
import io.swagger.annotations.ApiModel;
import io.swagger.converter.ModelConverter;
import io.swagger.converter.ModelConverterContext;
import io.swagger.jackson.ModelResolver;
import io.swagger.models.Model;
import io.swagger.models.ModelImpl;
import io.swagger.models.properties.Property;
import io.swagger.models.properties.RefProperty;
import io.swagger.models.properties.StringProperty;
import io.swagger.util.Json;
public class EnumAsModelAwareResolver extends ModelResolver {
static final EnumAsModelAwareResolver INSTANCE = new EnumAsModelAwareResolver();
public EnumAsModelAwareResolver() {
super(Json.mapper());
}
#Override
public Property resolveProperty(Type type, ModelConverterContext context, Annotation[] annotations,
Iterator<ModelConverter> chain) {
if (isEnumAnApiModel(type)) {
String name = findName(type);
// ask context to resolver enum type (for adding model definition
// for enum under definitions section
context.resolve(type);
return new RefProperty(name);
}
return chain.next().resolveProperty(type, context, annotations, chain);
}
private String findName(Type type) {
JavaType javaType = _mapper.constructType(type);
Class<?> rawClass = javaType.getRawClass();
ApiModel annotation = rawClass.getAnnotation(ApiModel.class);
String name = annotation.value();
if (name == null || name.length() == 0) {
name = rawClass.getSimpleName();
}
return name;
}
private boolean isEnumAnApiModel(Type type) {
JavaType javaType = _mapper.constructType(type);
return javaType.isEnumType()
&& javaType.getRawClass().isAnnotationPresent(ApiModel.class);
}
#Override
public Model resolve(Type type, ModelConverterContext context, Iterator<ModelConverter> chain) {
JavaType javaType = Json.mapper().constructType(type);
if (javaType.isEnumType()) {
ModelImpl model = new ModelImpl();
Class<?> rawClass = javaType.getRawClass();
ApiModel annotation = rawClass.getAnnotation(ApiModel.class);
String name = annotation.value();
if (name == null || name.length() == 0) {
name = rawClass.getSimpleName();
}
model.setName(name);
model.setDescription(annotation.description());
model.setType(StringProperty.TYPE);
List<String> constants = findEnumConstants(rawClass);
model.setEnum(constants);
return model;
}
return chain.next().resolve(type, context, chain);
}
private List<String> findEnumConstants(Class<?> rawClass) {
StringProperty p = new StringProperty();
_addEnumProps(rawClass, p);
return p.getEnum();
}
}
package nhenneaux.test.swagger.ext;
import io.swagger.converter.ModelConverters;
import io.swagger.jaxrs.config.BeanConfig;
import nhenneaux.test.swagger.ext.EnumAsModelAwareResolver;
public class EnumModelAwareBeanConfig extends BeanConfig {
public EnumModelAwareBeanConfig() {
registerResolver();
}
private void registerResolver() {
ModelConverters modelConverters = ModelConverters.getInstance();
// remove and add; in case it is called multiple times.
// should find a better way to register this.
modelConverters.removeConverter(EnumAsModelAwareResolver.INSTANCE);
modelConverters.addConverter(EnumAsModelAwareResolver.INSTANCE);
}
}
In your test use:
final BeanConfig beanConfig = new nhenneaux.test.endpoint.model.EnumModelAwareBeanConfig();
Hops this helps.
You could try the reference attribute of the #ApiModelProperty annotation:
#ApiModelProperty(reference = "#/definitions/SynchronizationStatus")
public SynchronizationStatus getLdap1() {
return ldap1;
}
Based on this mailing list post from last year I believe it is not trivial and one may have to extend the appropriate Swagger resources. The only other option would be to manually reference the model as per Cássio Mazzochi Molin's answer (just be careful that renaming SynchronizationStatus doesn't break the API docs due to the forced use of a non-generated string)
I was able to achieve this with (using swagger-jaxrs-2.1.3)
System.setProperty(ModelResolver.SET_PROPERTY_OF_ENUMS_AS_REF, "true");
Reader reader = new Reader();
OpenAPI api = reader.read(...);