Search code examples
swiftvaporvapor-fluent

Vapor 4: how to map an eagerly loaded parent relation into a different format?


I am struggling a bit with how to return a model that contains a parent relationship, while mapping that eagerly loaded model into a different form.

Let's consider the following 2 models: Course and User.

final class Course: Model, Content {
  static let schema = "courses"

  @ID(key: .id)
  var id: UUID?

  @Field(key: "name")
  var name: String

  @Parent(key: "teacher_id")
  var teacher: User

  init() { }
}

final class User: Model, Content {
  static let schema = "users"

  @ID(key: .id)
  var id: UUID?

  @OptionalField(key: "avatar")
  var avatar: String?

  @Field(key: "name")
  var name: String

  @Field(key: "private")
  var somePrivateField: String

  init() { }
}

I have a route like this, which returns an array of courses:

func list(req: Request) throws -> EventLoopFuture<[Course]> {
  return Course
    .query(on: req.db)
    .all()
}

The resulting JSON looks something like this:

[
  {
    "id": 1,
    "name": "Course 1",
    "teacher": {
      "id": 1
    }
]

What I want instead is that the teacher object is returned, which is easy enough by adding .with(\.$teacher) to the query. Vapor 4 does make this very easy!

[
  {
    "id": 1,
    "name": "Course 1",
    "teacher": {
      "id": 1,
      "name": "User 1",
      "avatar": "https://www.example.com/avatar.jpg",
      "somePrivateField": "super secret internal info"
    }
]

And there's my problem: the entire User object is returned, with literally all fields, even ones I don't want to make public.

What is the easiest way to transform the teacher info a different version of the User model, like PublicUser? Does that mean I have to make a DTO for the Course, map my array from [Course] to [PublicCourse], copy all properties, keep them in sync when the Course model changes, etc?

That seems like a lot of boilerplate with lots of room for mistakes in the future. Would love to hear if there are better options.


Solution

  • You can do this by first encoding the original model and then decoding it into a structure with fewer fields. So, for an instance of Course stored in course to convert to PublicCourse you would do:

    struct PublicCourse: Decodable {
        //...
        let teacher: PublicUser
        //...
    }
    
    let course:Course = // result of Course.query including `with(\.$teacher)`
    let encoder = JSONEncoder()
    let decoder = JSONDecoder()
    let data = try encoder.encode(course)
    let publicCourse = try decoder.decode(PublicCourse.self, from: data)
    

    Notice the PublicUser field in the structure. If this is the cut-down version, you can generate your minimal JSON in one go.