Fail to create children instance in SQLite database

I am (desperately) trying to build a simple database example with a one-to-many relationship. But at the end, when I try to create a children entity I receive the following error, which I am unable to understand.

[ WARNING ] Value was not of type 'User' at path 'user'. Could not decode property. Underlying error: valueNotFound(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [SomeCodingKey(stringValue: "user", intValue: nil)], debugDescription: "Cannot get keyed decoding container -- found null value instead", underlyingError: nil)). [method: POST, request-id: A8F743CD-84B5-4540-99A2-5CB8EA25707C, url: /users/6FB9253B-56BE-4F7C-ABC6-2CB72C22B476/todos, userAgent: [curl/8.7.1]]

Creating a user with:

curl -X POST http://127.0.0.1:8080/users -H "Content-Type: application/json" -d '{"name":"Bob","email":"bob@example.com"}'

returns as expected :

{"email":"bob@example.com","id":"6FB9253B-56BE-4F7C-ABC6-2CB72C22B476","name":"Bob"}**%**

but, trying to add a task with:

curl -X POST http://127.0.0.1:8080/users/6FB9253B-56BE-4F7C-ABC6-2CB72C22B476/todos -H "Content-Type: application/json" -d '{"title":"Write Code","user_id":"6FB9253B-56BE-4F7C-ABC6-2CB72C22B476"}'

Produces the above mentionned message. It must be a simple mistake somewhere but I am unable to find out where. Would appreciate some help.

Here is my User model :

final class User: Model, @unchecked Sendable, Content {
    static let schema = "users"
    
    @ID(key: .id)
    var id: UUID?
    
    @Field(key:"name")
    var name: String
    
    @Field(key:"email")
    var email: String
    
    @Children(for: \.$user)
    var todos: [Todo]
    
    init(){}
    
    init(id: UUID? = nil, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
}

My todo Model:

final class Todo: Model, @unchecked Sendable, Content {
    static let schema = "todos"
    
    @ID(key: .id)
    var id: UUID?

    @Field(key: "title")
    var title: String
    
    @Parent(key: "user_id")
    var user: User

    init() { }

    init(id: UUID? = nil, title: String, userID: User.IDValue) {
        self.id = id
        self.title = title
        self.$user.id = userID
    }
}

The migrations

struct CreateUser: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("users")
            .id()
            .field("name", .string)
            .field("email", .string)
            .create()
    }
    
    func revert(on database: any Database) async throws {
        try await database.schema("users").delete()
    }
}

struct CreateTodo: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("todos")
            .id()
            .field("title", .string)
            .field("user_id", .uuid, .references("users", "id"))
            .create()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("todos").delete()
    }
}

and the user controller

struct UserController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let users = routes.grouped("users")
        users.post(use: self.create)
        users.post(":userID","todos", use: self.createTodo)
        users.get(":userID","todos", use: self.getTodos)
    }
    
    @Sendable
    func create(req: Request) async throws -> User {
        let user = try req.content.decode(User.self)
        try await user.save(on: req.db)
        return user
    }
    
    @Sendable
    func createTodo(req: Request) async throws -> Todo {
        let todo = try req.content.decode(Todo.self)
        try await todo.save(on :req.db)
        return todo
    }
    
    @Sendable
    func getTodos(req: Request) async throws -> [Todo] {
        let userID = try req.parameters.require("userID", as: UUID.self)
        guard let user = try await User.find(userID, on: req.db) else {
            throw Abort(.notFound, reason: "User not found")
        }
        return try await user.$todos.query(on: req.db).all()
    }
}

This

let todo = try req.content.decode(Todo.self)

is trying to decode a User (from the Todo's

@Parent(key: "user_id")
var user: User

) but you're passing in a user_id instead. The decoder is ignoring that user_id and is missing a decodable user: User dictionary. The typical way to solve this is by having a DTO for the Todo which you read from the request and create a Fluent model from, instead of trying to decode the model directly. Something like

struct TodoDTO {
  let title: String
}

then decode this, take the user ID from the path parameter (like you're doing in getTodos(req:), and create the Todo using those. You can read more about this at Vapor: Fluent → Relations

ok @ptoffy I got it.

I was not yet at the Vapor: Fluent -> Relations section. But I realize my curl request was not correctly formulated.

So it works:

> curl -X POST http://127.0.0.1:8080/users/6FB9253B-56BE-4F7C-ABC6-2CB72C22B476/todos -H "Content-Type: application/json" -d '{"title":"Write Code","user":{"id":"6FB9253B-56BE-4F7C-ABC6-2CB72C22B476"}}'

Thanks a lot !

And finally with the DTO :

struct TodoDTO: Content {
    var id: UUID?
    var title: String?
    var user: User.IDValue?
    
    func toModel() -> Todo {
        let model = Todo()
        
        model.id = self.id
        if let title = self.title {
            model.title = title
        }
        if let user = self.user {
            model.$user.id = user
        }
        return model
    }
}

The Todo model with toDTO() func:

final class Todo: Model, @unchecked Sendable, Content {
    static let schema = "todos"
    
    @ID(key: .id)
    var id: UUID?

    @Field(key: "title")
    var title: String
    
    @Parent(key: "user_id")
    var user: User

    init() { }

    init(id: UUID? = nil, title: String, userID: User.IDValue) {
        self.id = id
        self.title = title
        self.$user.id = userID
    }
    
    func toDTO() -> TodoDTO {
        .init(
            id: self.id,
            title: self.$title.value,
            user: self.$user.id
        )
    }
}

and the updated controller:

 struct UserController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let users = routes.grouped("users")
        users.post(use: self.create)
        users.post(":userID","todos", use: self.createTodo)
        users.get(":userID","todos", use: self.getTodos)
    }
    
    @Sendable
    func create(req: Request) async throws -> User {
        let user = try req.content.decode(User.self)
        try await user.save(on: req.db)
        return user
    }
    
    @Sendable
    func createTodo(req: Request) async throws -> TodoDTO {
        let todo = try req.content.decode(TodoDTO.self).toModel()
        try await todo.save(on :req.db)
        return todo.toDTO()
    }
    
    @Sendable
    func getTodos(req: Request) async throws -> [TodoDTO] {
        let userID = try req.parameters.require("userID", as: UUID.self)
        guard let user = try await User.find(userID, on: req.db) else {
            throw Abort(.notFound, reason: "User not found")
        }
        return try await user.$todos.query(on: req.db).all().map { $0.toDTO() }
    }
}

and now it works with:
curl -X POST http://127.0.0.1:8080/users/6FB9253B-56BE-4F7C-ABC6-2CB72C22B476/todos -H "Content-Type: application/json" -d '{"title":"Write Code","user":"6FB9253B-56BE-4F7C-ABC6-2CB72C22B476"}'