Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Build and Test a REST API with Ktor

Getting a Ktor route to return data is the easy part. Real clients send malformed IDs and blank fields, and they request records that do not exist. An API that responds to all of these requests with 200 OK or crashes with a 500 Internal Server Error forces callers to guess what actually happened. Routes that manage storage directly also become hard to change and even harder to test. In this Guided Code Lab, you will build the Globomantics Book Catalog, a REST API written in Kotlin with Ktor. You will configure JSON support, model requests and responses with serializable data classes, implement full CRUD routes, validate route parameters and request bodies, and return appropriate HTTP status codes. You will then move storage behind a repository and verify the API’s behavior with JUnit tests using `testApplication`. By the end of this lab, you will have a repeatable pattern for building Ktor APIs that are predictable for callers and easy to test.

Lab platform
Lab Info
Level
Intermediate
Last updated
Oct 28, 2026
Duration
59m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Introduction

    Welcome! In this lab, you will build the REST API behind the Globomantics book catalog, then write the tests that confirm every endpoint behaves as expected.


    Scenario

    Globomantics is a growing company with an engineering library of hundreds of books. Until now, the library has been tracked in a shared spreadsheet. Books go missing, nobody knows which copies are checked out, and every team keeps its own outdated copy of the list.

    The company has decided to replace the spreadsheet with a Book Catalog API: a single service that every team can rely on. Three teams are waiting for it:

    | Team | What they are building | What they expect from the API | |---|---|---| | Mobile | An app employees use to browse and check out books | Quick answers and a clear message when a book does not exist | | Web | A dashboard librarians use to add, edit, and remove books | Helpful feedback when they enter something wrong | | Platform | Scripts that import books in bulk | Predictable responses they can automate against |

    You are the developer building that API. A teammate has set up the project and its basic structure. The server starts, but it does not do anything useful yet. Your job is to bring it to life and make it trustworthy.


    What you will build

    By the end of the lab, the Book Catalog API lets a client:

    • List every book in the catalog
    • Look up a single book
    • Add a new book
    • Update a book, for example to mark it as checked out
    • Remove a book

    Those five capabilities become five endpoints:

    | Method | Path | Purpose | |---|---|---| | GET | /books | List every book | | GET | /books/{id} | Look up a single book | | POST | /books | Add a new book | | PUT | /books/{id} | Update a book | | DELETE | /books/{id} | Remove a book |

    It also behaves the way a production service should. It speaks JSON, it refuses bad input with a clear explanation, it answers with status codes that tell callers exactly what happened, and it comes with tests that catch mistakes before anyone else does.


    What you will learn

    When you complete the lab, you will be able to:

    • Configure a Ktor application to send and receive JSON
    • Model API requests and responses with serializable Kotlin data classes
    • Separate routing logic from repository logic
    • Implement REST routes using Ktor
    • Validate route parameters and request bodies
    • Return appropriate HTTP status codes
    • Test Ktor endpoints with JUnit and testApplication

    Review the current book-catalog project.

    The project is organized so that each concern has its own place, and the paths below are relative to src/main/kotlin/com/globomantics/catalog/ inside the book-catalog folder, except for the tests.

    • Application.kt: starts the server and switches on each feature of the API, including JSON handling.
    • plugins/: settings that apply to the whole API, such as routing and error handling.
    • model/: the data the API sends and receives.
    • repository/: where the books are stored, starting with a temporary list of three sample books.
    • routes/: the endpoints clients call.
    • validation/: the rules for good input.
    • src/test/: the tests that check the API.

    You will visit each of these as the lab progresses.

    info>Every place where you will write code is marked with a // Task comment.


    You have seen how the project is organized. Next, you start the server and see where the work begins. The server is up. Next, you send it its first request. The root endpoint answered, but not with JSON. Next, you check whether the catalog itself is reachable. In this step, you ran the server to see where the work begins. In the next step, you will configure the Ktor application to send and receive JSON.

  2. Challenge

    Configuring JSON support

    In this step, you configure the Ktor application to send and receive JSON by choosing a JSON format and installing the ContentNegotiation plugin in Application.module().


    Content negotiation

    Route handlers work with Kotlin objects. HTTP carries bytes. The ContentNegotiation plugin converts between the two in both directions:

    call.receive<T>()   request body (JSON)  ---->  Kotlin object of type T
    call.respond(obj)   Kotlin object        ---->  response body (JSON)
    

    The plugin reads the Content-Type header of a request to pick a converter for the body. It reads the Accept header to pick a format for the response. Without a registered converter, Ktor has no way to turn a Kotlin object into a response body. That is why the service information endpoint answered 406 Not Acceptable in the previous step.

    Plugins are added with install(Plugin) { configuration }. For ContentNegotiation, the configuration block is where you register converters. Calling json(format) registers a JSON converter that uses a Json format from the kotlinx.serialization library.

    Choosing a JSON format

    A Json { } builder creates a format. Each option you set changes how every endpoint reads or writes JSON:

    | Option | Default | When set to true | |---|---|---| | prettyPrint | false | Responses are indented across several lines, which is easy to read in a terminal | | ignoreUnknownKeys | false | Request properties that have no matching Kotlin property are skipped instead of rejected |

    ignoreUnknownKeys is a design decision. With it enabled, a client can send an extra field, such as one added in a newer app version, and the request still succeeds. Without it, the extra field causes the request to fail.


    Review Application.kt

    • fun Application.module(): the function the server runs at startup. Everything the application needs is installed or registered here.
    • val catalogJson = Json { }: the catalog's JSON format, created with every option at its default.
    • configureStatusPages() and configureRouting(): calls to other configuration functions you will work with later. --- The catalog's JSON format is still empty, so responses come out on one line and any unknown field in a request fails. Next, you configure it to format responses for reading and to skip fields it does not recognize. The format exists, but nothing uses it yet. Next, you connect it to the application. JSON support is in place. Next, you run the server and see the difference.

    In this step, you configured the Ktor application to send and receive JSON by choosing the options of the catalogJson format and installing ContentNegotiation with it.

    In the next step, you will model API requests and responses with serializable Kotlin data classes.

  3. Challenge

    Modeling requests and responses

    In this step, you model API requests and responses with serializable Kotlin data classes, deciding which types cross the wire and which fields a client may leave out.

    Serializable data classes

    ContentNegotiation can only convert a class it has a serializer for. The @Serializable annotation tells the Kotlin serialization compiler plugin to generate a serializer at build time.

    JSON    {"title": "...", "author": "..."}
               ↕  serializer
    Kotlin  CreateBookRequest(title = "...", ...)
    

    A model class without @Serializable can still compile, but serialization fails at runtime when a route tries to send or receive it. Each request and response model in this lab therefore needs the annotation.


    Separate types for separate jobs

    The data a client sends is not the same as the data the server returns. The catalog uses one type per job:

    • Book (response): always has an id, assigned by the server.
    • CreateBookRequest (request): no id or available, because the server sets both.
    • UpdateBookRequest (request): every field optional, so a caller sends only what changed.
    • ErrorResponse (response): one shape for every failure, a list of messages.

    --- Review the current implementation in model/Models.kt, none of the models has @Serializable, so Ktor cannot turn a Book into JSON or read a request body.

    Next, you add the annotation to the three book models. The three book models can now travel as JSON. Next, you make update requests flexible enough for partial changes. Update requests now accept partial changes. Next, you confirm that the models compile. In this step, you modeled API requests and responses with serializable Kotlin data classes. Book is for the responses, CreateBookRequest and UpdateBookRequest are the request bodies, and UpdateBookRequest has optional fields so a client can leave values out. Together with ErrorResponse, every type that crosses the wire now has a serializer.

    In the next step, you will implement REST routes using Ktor that send and receive these models.

  4. Challenge

    Implementing REST routes

    In this step, you implement REST routes using Ktor for the /books endpoints, backed by a temporary in-memory list of books, and register them with the application.


    Grouping endpoints in a route extension function

    Ktor routes follow a verb, pattern, and handler structure: a function named after the HTTP method, an optional path pattern, and a lambda that handles the call.

    // prefix shared by every nested route
    route("/books") {
        // verb + pattern: GET /books/{id}
        get("/{id}") {
            // handler: read from call,
            // respond through call
        }
    }
    

    Defining every book endpoint inside fun Route.bookRoutes(...) keeps them in one place and out of Application.module(). The application only needs one line to register the whole group, and the routes can grow without cluttering startup code.


    What a handler does

    Most handlers in this step follow the same sequence:

    1. Read input: turn the request into Kotlin values with call.parameters["id"] or call.receive<T>().
    2. Use the repository: ask the in-memory list for the result, for example books.findById(id).
    3. Respond: turn the result into a JSON response with call.respond(value).

    For now, the handlers you complete assume that input is valid. You use !! to assert that a value is not null, which keeps the code short but causes an exception when the assumption is wrong. You will see what that costs at the end of the step.



    You will start with the list handler, which returns the three seeded books and has no input to read. The book routes are not registered yet, so /books will continue to return 404 Not Found until you register them later in this step. Listing works for the whole catalog. Next, you fetch a single book by its id. Reading is covered. Next, you let clients add books. Clients can now create books. Next, you complete the last handler you write in this step: removing a book. All handlers are in place. Next, you register them so the server can use them. The routes are registered. Next, you call every endpoint and see how the API behaves. In this step, you implemented REST routes in Ktor for listing, fetching, creating, and deleting books. You grouped them in Route.bookRoutes() and registered them with the application. You also saw that an unknown id causes an exception in the fetch handler.

    In the next step, you will validate route parameters and request bodies so invalid input receives a helpful error response.

  5. Challenge

    Validating route parameters and request bodies

    In this step, you validate route parameters and request bodies, replacing the forced unwraps in your handlers with early returns that send an ErrorResponse.

    Two kinds of input

    Every handler trusts input that a client controls:

    • Route parameter: bad input looks like /books/abc, /books/-3, or /books/999. toBookId() turns the parameter into a valid id or returns null.
    • Request body: bad input looks like a blank title, an ISBN of "12345", or a year of 3000. validate() returns a list of problems.

    Collect every problem, not just the first

    A check that stops at the first problem forces a client to fix one field, resend the request, and discover the next. validate() adds one message per problem to a list.

    For a request with a blank title and an ISBN of "12345", the list contains:

    [
      "title must not be blank",
      "isbn must match the format 000-0000000000"
    ]
    

    An empty list means the request passed the validation rules.


    Early returns instead of forced unwraps

    !! asserts that a value is not null and throws an exception when it is. The Elvis operator ?: lets you handle the null case instead. Combined with return@get, it leaves the handler as soon as something is wrong:

    val value = somethingThatMayBeNull()
        ?: return@get call.respond(/* an error body */)
    

    The label after return@ names the handler you are leaving: get, post, put, or delete.



    The handlers use !! on the id, so a request such as /books/abc crashes with a 500 error. Next, you complete toBookId() so it returns a positive id, or null for anything else.

    --- Ids are covered. Next, you finish the rules for a new book's body. Both helpers are ready. Next, you use them in the handlers, starting with fetching a book. Fetching is protected. Next, you validate the body of a create request. Creates are validated. Next, you protect the delete handler. Every handler now rejects bad input. Next, you see what callers receive.

    In this step, you validated route parameters with toBookId() and request bodies with validate(), and replaced every forced unwrap with an early return that sends an ErrorResponse.

    In the next step, you will return appropriate HTTP status codes so every response tells the caller what happened.

  6. Challenge

    Returning appropriate HTTP status codes

    In this step, you return appropriate HTTP status codes by passing an explicit HttpStatusCode to every call.respond in the book routes.

    Status codes are part of the contract

    A client should be able to decide what to do from the status line alone, without parsing the body:

    • 200 OK: the request succeeded and the body has the result. Used for reading or updating a book.
    • 201 Created: a new resource now exists. Used for creating a book.
    • 204 No Content: the request succeeded and there is nothing to return. Used for deleting a book.
    • 400 Bad Request: the client sent something invalid. Used for a malformed id or a book that fails validation.
    • 404 Not Found: the id is well formed, but no book has it. Used for an unknown id.

    call.respond(value) always sends 200 OK. To choose the status, pass it first: call.respond(HttpStatusCode.Created, value). A response with no body takes only the status: call.respond(HttpStatusCode.NoContent).


    Every response still goes out as 200 OK, even errors and newly created books. Next, you start with the list handler and send its status code explicitly. The list response is explicit. Next, you give each fetch outcome its own status. Fetching is done. Next, you choose the statuses for creating a book. Creates are done. Next, you finish with the delete handler. Every response now carries a status. Next, you verify them from the terminal.

    In this step, you returned appropriate HTTP status codes by passing an explicit HttpStatusCode to every call.respond, so each response tells the caller exactly what happened.

    In the next step, you will separate routing logic from repository logic by moving storage behind an interface.

  7. Challenge

    Separating routing from repository logic

    In this step, you separate routing logic from repository logic by making the routes depend on the BookRepository interface. You also install StatusPages to handle exceptions in one place.


    Depend on an Interface, Not a Class

    Today, bookRoutes() takes an InMemoryBookRepository, a specific class backed by a list. The routes only need five operations, but they are tied to one way of storing books.

    | Before | After | |---|---| | bookRoutes() accepts InMemoryBookRepository | bookRoutes() accepts BookRepository | | The parameter requires the concrete class | The parameter accepts any implementation of the interface |


    When routes depend on an interface:

    • Replaceable storage: a database-backed repository can replace the in-memory repository without changing the route handlers.
    • Testable routes: tests can pass the routes a repository with known data.
    • Clear responsibilities: routes describe HTTP behavior, and the repository handles storage.

    Centralized error handling with StatusPages

    Your handlers already respond to expected problems with 400 and 404. Other failures, such as malformed JSON, can cause exceptions while a request is being processed. The StatusPages plugin handles these exceptions in one place:

    • BadRequestException: Ktor cannot convert the request, for example because the JSON is malformed. The response is 400 Bad Request with an ErrorResponse.
    • Throwable: an unexpected exception occurs. The response is 500 Internal Server Error with an ErrorResponse that hides internal details.

    StatusPages selects the most specific registered exception handler, so a BadRequestException uses its dedicated handler.

    Review repository/BookRepository.kt

    • interface BookRepository { }: the contract the routes need, with the five operations findAll, findById, create, update, and delete.
    • InMemoryBookRepository.kt, in the same folder: the class the routes use today. It contains the five functions, but does not declare that it implements the interface.

    The routes depend directly on InMemoryBookRepository, which ties them to the concrete class. Next, you make the in-memory repository fulfill the BookRepository contract.

    --- The list now implements the interface. Next, you make the routes depend on the interface instead of the class. The routes accept any repository. Next, you update the one place that creates it. Storage is separated from routing. Next, you handle exceptions in one place. The refactor and error handling are complete. Next, you confirm the API still behaves the same.

    In this step, you separated routing logic from repository logic by making InMemoryBookRepository implement the BookRepository interface, and passing it into bookRoutes(). You also installed StatusPages to turn exceptions into JSON error responses in one place.

    In the next step, you will test Ktor endpoints with JUnit and testApplication.

  8. Challenge

    Testing endpoints with JUnit and testApplication

    In this step, you test Ktor endpoints with JUnit and testApplication by completing a small test suite that runs the real application in memory.

    testApplication

    testApplication { } runs an application in memory for one test. Its client sends requests directly into the Ktor pipeline without opening a network port. In this project, each test calls application { module() } to load the same plugins and routes that main() uses.

    Every test follows the same pattern:

    | Part | Code | |---|---| | Arrange | application { module() } | | Act | val response = jsonClient().get("/books") | | Assert | assertEquals(HttpStatusCode.OK, response.status) |

    To send or read data classes as JSON, the test client also needs the client ContentNegotiation plugin with a JSON converter.


    Review src/test/kotlin/com/globomantics/catalog/BookRoutesTest.kt

    • jsonClient(): creates the test client, with no plugins installed yet.
    • The seeded catalog test asserts 200 OK and three books, but never loads the application.
    • The create, delete, and invalid book tests send their requests, with no assertions yet.
    • The two GET books by id tests are complete. Use them as models.

    The test client cannot send or read JSON yet, so the create and validation tests fail before they reach an assertion. Next, you install ContentNegotiation in the test client.

    --- The client speaks JSON. Next, you make the first test load the real application. The list test is complete. Next, you assert the result of creating a book. Creates are checked. Next, you assert the result of deleting a book. Deletes are checked. Next, you verify that invalid input is rejected. Every test has its assertions. Next, you run the whole suite. In this step, you tested Ktor endpoints with JUnit and testApplication by giving the test client JSON support, loading the application module, and asserting status codes and response bodies. Your tests cover listing, fetching, creating, and deleting books, rejecting invalid create requests, and requesting a book that does not exist.

    In the next step, you will review the patterns from this lab and explore ways to extend the Book Catalog API.

  9. Challenge

    Conclusion and next steps

    Well done! The Globomantics Book Catalog API is complete. Starting from a partly built Ktor project, you added JSON support, input validation, meaningful status codes, a clean separation between routes and storage, and automated tests.


    Conclusion

    What you learned

    • Configure a Ktor application to send and receive JSON. You installed ContentNegotiation with one Json format for the API’s JSON requests and responses.
    • Model API requests and responses. You marked data classes with @Serializable and used separate types for what a client sends and what the server returns, with = null defaults for optional fields.
    • Implement REST routes. You grouped the endpoints in a route extension function, read path parameters and request bodies, and registered the routes with the application.
    • Validate route parameters and request bodies. You converted raw ids into positive integers or null, collected validation errors in create requests, and replaced forced unwraps with early returns.
    • Return appropriate HTTP status codes. You used 200, 201, 204, 400, and 404 for the outcomes handled by your routes and configured a 500 response for unexpected exceptions.
    • Separate routing logic from repository logic. You made the routes depend on the BookRepository interface. You also handled exceptions in one place with StatusPages.
    • Test Ktor endpoints. You used testApplication to run the application module in memory and asserted on status codes and response bodies.

    Next steps

    Extend the book catalog

    Each challenge reuses a pattern from the lab:

    • Filter the list. Support /books?available=true by reading call.request.queryParameters in the list handler and filtering the result.
    • Validate updates. Write a validate() extension for UpdateBookRequest that rejects a blank title or author when the property is not null.
    • Test the update endpoint. Add tests for PUT /books/{id}, using the existing fetch tests as models.
    • Add a second resource. Build /authors with its own models, repository, routes, validation, and tests, following the same layout.

    Keep learning

    These topics build directly on what you practiced:

    • Persist data in a database. Implement BookRepository with a library such as Exposed, then select the new implementation in Routing.kt. Because the routes depend on the interface, the route handlers can remain unchanged.
    • Secure the API. Use Ktor’s Authentication plugin to protect the create, update, and delete endpoints, for example with JWT.
    • Validate with plugins. Compare your handwritten validation with Ktor’s RequestValidation plugin and use StatusPages to handle validation failures.
    • Document the API. Publish an OpenAPI description so other teams can discover and call the endpoints.
    • Call other services. Use the Ktor client, which you also used in your tests, to call external APIs from your service.
    • Package and deploy. Build a runnable JAR or a container image and run the service outside your development machine.

    Congratulations on completing the lab! You now have practice building, validating, and testing REST APIs with Ktor.

About the author

Amar Sonwani is a software architect with more than twelve years of experience. He has worked extensively in the financial industry and has expertise in building scalable applications.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight