Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

collapse-type-hierarchy="true" fixes issue with @JsonIgnoreProperties, but creates issue with json-client. #1114

Open
matthew-pakulski opened this issue Apr 7, 2022 · 3 comments
Assignees
Labels

Comments

@matthew-pakulski
Copy link

matthew-pakulski commented Apr 7, 2022

I'm at a bit of an impasse.

I have a situation where I want to use collapse-type-hierarchy="true" when generating the docs, but not when generating the java json client. This is related to what #539 is requesting support for.

I have a model structure that's highly relational, and sometimes cyclical eg:

public class Person() {

  // empty marker interfaces used by JsonView, more on these later
  public interface Summary {};
  public interface Detailed {};

  private String id;
  private String personName;

  @JsonView(Detailed.class) // more on these later
  private List<Job> jobs;

  @JsonView(Detailed.class)
  private Supervisor supervisor;
}
public class Job() {

  public interface Summary {};
  public interface Detailed {};

  private String id;
  private String jobName;

  @JsonView(Detailed.class)
  private Location location;
}
public class Location () {

  public interface Summary {};
  public interface Detailed {};

  private String id;
  private String locationName;

  /* Pretend this expands on for quite a few more relationships */
  @JsonView(Detailed.class)
  private Pretend pretend;
}

When requesting a single person from my endpoint, we use @JsonView to restrict how deep the information being returned will go. The models are JPA entities. Without some restrictions in place via @JsonView, when serializing the models they would just keep fetching and fetching until it has reached the end of the relationship line, and the whole kitchen sink is returned, eg:

/**
 * @returnWapped com.stoicflame.model.Person
 */
@GET 
@Path("person/{id}")
public Response get(@PathParam("id") String id) {
    return Response.status(Status.OK.entity(service.find(id)).build();
}
{
  "id": 123,
  "personName": "Ralph",
  "jobs": [{
    "id": 124,
    "jobName": "Parking Repavement",
    "location": {
      "id": 125,
      "locationName": "Ace Hardware Site #125",
      "pretend": {
         "#more-nested-fields": "and so on...",
         "#pretend": "pretend that this goes really deep, and some responses are literally megabytes large",
      }
    }, 
    { ... }]
  },
  "supervisor": { ... }
}

Through @JsonView, I can achieve shortening the depth of the API response.

/**
 * Returns a Person by id. The resulting Persons returned only have one level deep of their
 * non-object fields returned by using @JsonView. If a field being serialized is marked with 
 * @JsonView, it only will be serialized if the class specified in the @JsonView of the field is
 * or inherits from People.Detailed.class.
 * 
 * @returnWrapped com.stoicflame.model.Person
 */
@GET 
@Path("person/{id}")
@JsonView(People.Detailed.class)
public Response get(@PathParam("id") String id) {
    return Response.status(Status.OK.entity(service.find(id)).build();
}
{
  "id": 123,
  "personName": "Ralph",
  "jobs": {
    "id": 124,
    "jobName": "Parking Repavement"
  },
  "supervisor": { 
    "id": 126,
    "personName": "Glen",
  }
}

Note that the following fields are returned:

  • person.id
  • person.personName
  • person.jobs[]
  • person.jobs[].id
  • person.jobs[].jobName
  • person.supervisor
  • person.supervisor.id
  • person.supervisor.personName

And the following fields are not returned because they have @JsonView specified on the field whose value (the class(es) specified in the annotation) does not match or is not a superclass of @JsonView(People.Detailed.class), which is specified on the endpoint.

  • person.jobs[].location
  • person.supervisor.jobs[]

The jobs[].location field is marked with @JsonView(Job.Detailed.class) which is not an ancestor of the requested view @JsonView(People.Detailed.class).
The supervisor.jobs[] field is marked with @JsonView(Supervisor.Detailed.class) which is not an ancestor of the requested view @JsonView(People.Detailed.class). (I didn't actually write the Supervisor class in the example)

Now, enunciate doesn't support the @JsonView, so what I have done is used @TypeHint and created classes specifically for the API docs to more accurately describe what's actually being returned from the API when generating the docs and the docs examples.

public class Person {

  //...

  // For API docs
  public class PersonDetailed extends Person {
    @TypeHint(Job.JobSummary.class)
    @Override
    public List<Job> getJobs( return super.getJobs(); );

    @TypeHint(Supervisor.SupervisorSummary.class)
    @Override
    public Person getSupervisor( return super.getSupervisor(); );
  }

  @JsonIgnoreProperties( { "jobs", "supervisor" } )
  public class PersonSummary extends DetailedView {
  }
}

Then, I can tell the docs to use this model in the API docs as the response so that the API docs shows it properly:

/**
 * Change:
 * @returnWrapped com.stoicflame.model.Person
 * to:
 * @returnWrapped com.stoicflame.model.Person.PersonDetailed
 */
@GET 
@Path("person/{id}")

PersonDetailed extends Person so that it may inherit any future properties, and so that I don't have to duplicate all the fields to create this representation of what's actually being returned. This model is solely for correcting the API docs.

Here's another example, where I'd just want the summary view of the person since I'm returning a list of people.

/**
 * Returns a list of all Persons matching the query. The resulting Persons returned only have
 * non-object fields returned by using @JsonView.
 * 
 * @returnWrapped com.stoicflame.model.Person.PersonSummary[]
 */
@GET 
@Path("persons")
@JsonView(Person.Summary.class)
public Response get@BeanParam PersonsSearchRequest query) {
    return Response.status(Status.OK.entity(service.findAll(query)).build();
}
[ {
  "id": 123,
  "personName": "Ralph"
}, { 
  "id": 126,
  "personName": "Glen",
}, { 
  // ...
} ]

Lastly, in order for the docs to properly omit the "jobs", "supervisor" fields and use the @typehints described on the Person.PersonSummary.class when generating the example json in the api docs for both the endpoint, and the PersonSummary model, I have to configure:

<enunciate>
  <modules>
    <jackson collapse-type-hierarchy="true">
  </modules>
</enunciate>

If I don't configure this, then the docs show that PersonSummary extends PersonDetailed extends Person. While it's great to have that info, without collapsing the type hierarchy, the fields I want to mask in the generated docs by using @TypeHint and the fields I want to ignore by @JsonIgnoreProperties seem to be ignored/overwritten by the fact that those fields exist differently in the superclasses, and those superclass fields are taking precedence when generating the example json. Hard to say if that's a bug or a feature.

^^^ All of this works great. I can perfectly describe my API @JsonView response behavior in the generated docs + html.

However, now when I set collapse-type-hierarchy="true", it is affecting my enunciate generated java-json-client artifact (and probably other generated clients? but I don't use those). I make use of dynamic types with @JsonTypeInfo and @JsonSubTypes, which requires not flattening the type hierarchy to function properly.

So, now I'm at an impasse. I can either use collapse-type-hierarchy="false" and have the docs generate incorrectly (and it takes much longer to generate too when it's generating huuuuuge and often useless example json), or have collapse-type-hierarchy="true" and my java-json-client artifacts now don't support deserializing models where we use the @JsonTypeInfo feature. I could go into more detail on this if needed.

My attempts at working around this:
I was attempting having two separate maven plugin declarations - one for generating the docs pages using collapse-type-hierarchy="true" and another for the java-json-client with collapse-type-hierarchy="false", but it seems as though they share the build working directory and config file, and only do the steps in the <jackson> module once.
I was then going the route of trying to split the api generation into two separate maven modules. While I think that's possible, it's really hacky feeling because it involves generating and exporting the sources of my war module for use by the api docs modules, then since the docs generation is being done without the java-json-client, I also must manually include the artifact as a download. Not sure what other problems I'd run into.

Suggestions for a fix:

  • Somehow allow collapse-type-hierarchy setting to differ depending on which module you're using. I'm not familiar with how enunciate works internally but I suspect this isn't going to be easily possible.
  • Look into why @TypeHint and @JsonIgnoreProperties are ignored when displaying the example generated json on the docs pages when extending a class.
  • Support @JsonView (not holding my breath because that sounds like it could be a lot of work with not a lot of demand)

Any other ideas?

@stoicflame
Copy link
Owner

stoicflame commented Apr 7, 2022

Thanks for the report.

When you suggest supporting @JsonView annotation do you mean just copying it over from the API classes? Or is there more to it than that?

@stoicflame stoicflame self-assigned this Apr 7, 2022
@stoicflame stoicflame added this to the 2.15.0 milestone Apr 7, 2022
@matthew-pakulski
Copy link
Author

matthew-pakulski commented Apr 8, 2022

There's a little more to it.

There's 2 pieces to @JsonView.

  1. *@JsonView on the fields of API classes to describe different views a particular field may be a part of.
  2. @JsonView on the endpoint methods describing which view you want to use to filter out properties.

* Note a field may support multiple views which it is a member of, eg @JsonView({View1.class, View2.class}) but ideally Jackson would be in charge of handling how @JsonView behaves.

In addition to the API classes, each endpoint may optionally also specify @JsonView with a (single) class, which together can limit the fields serialized. If the endpoint doesn't have the annotation, @JsonView on the API class fields is ignored. Under the hood,

@GET 
@Path("persons")
@JsonView(Person.Summary.class) // <---
public Response get(@BeanParam PersonsSearchRequest query) {

is doing this:

    ObjectMapper mapper = new ObjectMapper();
    String result = mapper
      .writerWithView(Person.Summary.class)
      .writeValueAsString(item);

This also can be applied to requests (today I learned...)

@POST 
@Path("person/{id}")
public Response get(@PathParam("id") String id, @JsonView(Person.Create.class) Person person) {

is doing this:

    ObjectMapper mapper = new ObjectMapper();
    String result = mapper
      .readerWithView(Person.Create.class)
      .forType(Person.class)
      .readValue(json);

Baeldung explains it pretty well.
https://www.baeldung.com/jackson-json-view-annotation

So the other key is that each endpoint may specify the @JsonView. How should this be illustrated in the API docs? The example json would be corrected to show the correct json fields. However could this somehow be tied into the generated pages for the api models? Perhaps every observed instance of a model that has a @JsonView described in an endpoint method generates another variant of that model that is named some combination of the model and the @JsonView. Seems like it could be a lot of work though.

@stoicflame
Copy link
Owner

I've decided I'll have to take this on at the same time as #539 so I'm pulling this off the release plan for now.

@stoicflame stoicflame removed this from the 2.15.0 milestone Jan 13, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants