- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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-catalogproject.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 thebook-catalogfolder, 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
// Taskcomment.
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.
-
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
ContentNegotiationplugin inApplication.module().
Content negotiation
Route handlers work with Kotlin objects. HTTP carries bytes. The
ContentNegotiationplugin 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-Typeheader of a request to pick a converter for the body. It reads theAcceptheader 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 answered406 Not Acceptablein the previous step.Plugins are added with
install(Plugin) { configuration }. ForContentNegotiation, the configuration block is where you register converters. Callingjson(format)registers a JSON converter that uses aJsonformat from thekotlinx.serializationlibrary.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 |ignoreUnknownKeysis 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.ktfun 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()andconfigureRouting(): 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
catalogJsonformat and installingContentNegotiationwith it.In the next step, you will model API requests and responses with serializable Kotlin data classes.
-
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
ContentNegotiationcan only convert a class it has a serializer for. The@Serializableannotation tells the Kotlin serialization compiler plugin to generate a serializer at build time.JSON {"title": "...", "author": "..."} ↕ serializer Kotlin CreateBookRequest(title = "...", ...)A model class without
@Serializablecan 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 anid, assigned by the server.CreateBookRequest(request): noidoravailable, 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 aBookinto 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.
Bookis for the responses,CreateBookRequestandUpdateBookRequestare the request bodies, andUpdateBookRequesthas optional fields so a client can leave values out. Together withErrorResponse, 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.
-
Challenge
Implementing REST routes
In this step, you implement REST routes using Ktor for the
/booksendpoints, 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 ofApplication.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:
- Read input: turn the request into Kotlin values with
call.parameters["id"]orcall.receive<T>(). - Use the repository: ask the in-memory list for the result, for example
books.findById(id). - 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 notnull, 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
/bookswill continue to return404 Not Founduntil 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 inRoute.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.
- Read input: turn the request into Kotlin values with
-
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 returnsnull. - Request body: bad input looks like a blank title, an ISBN of
"12345", or a year of3000.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 notnulland throws an exception when it is. The Elvis operator?:lets you handle thenullcase instead. Combined withreturn@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, ordelete.
The handlers use
!!on the id, so a request such as/books/abccrashes with a500error. Next, you completetoBookId()so it returns a positive id, ornullfor 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 withvalidate(), and replaced every forced unwrap with an early return that sends anErrorResponse.In the next step, you will return appropriate HTTP status codes so every response tells the caller what happened.
- Route parameter: bad input looks like
-
Challenge
Returning appropriate HTTP status codes
In this step, you return appropriate HTTP status codes by passing an explicit
HttpStatusCodeto everycall.respondin 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 sends200 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
HttpStatusCodeto everycall.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.
-
Challenge
Separating routing from repository logic
In this step, you separate routing logic from repository logic by making the routes depend on the
BookRepositoryinterface. You also installStatusPagesto handle exceptions in one place.
Depend on an Interface, Not a Class
Today,
bookRoutes()takes anInMemoryBookRepository, 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()acceptsInMemoryBookRepository|bookRoutes()acceptsBookRepository| | 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
400and404. Other failures, such as malformed JSON, can cause exceptions while a request is being processed. TheStatusPagesplugin handles these exceptions in one place:BadRequestException: Ktor cannot convert the request, for example because the JSON is malformed. The response is400 Bad Requestwith anErrorResponse.Throwable: an unexpected exception occurs. The response is500 Internal Server Errorwith anErrorResponsethat hides internal details.
StatusPagesselects the most specific registered exception handler, so aBadRequestExceptionuses its dedicated handler.Review
repository/BookRepository.ktinterface BookRepository { }: the contract the routes need, with the five operationsfindAll,findById,create,update, anddelete.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 theBookRepositorycontract.--- 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
InMemoryBookRepositoryimplement theBookRepositoryinterface, and passing it intobookRoutes(). 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.
-
Challenge
Testing endpoints with JUnit and testApplication
In this step, you test Ktor endpoints with JUnit and
testApplicationby completing a small test suite that runs the real application in memory.testApplicationtestApplication { }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 callsapplication { module() }to load the same plugins and routes thatmain()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
ContentNegotiationplugin with a JSON converter.
Review
src/test/kotlin/com/globomantics/catalog/BookRoutesTest.ktjsonClient(): creates the test client, with no plugins installed yet.- The seeded catalog test asserts
200 OKand 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 idtests 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
ContentNegotiationin 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
testApplicationby 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.
-
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
ContentNegotiationwith oneJsonformat for the API’s JSON requests and responses. - Model API requests and responses. You marked data classes with
@Serializableand used separate types for what a client sends and what the server returns, with= nulldefaults 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, and404for the outcomes handled by your routes and configured a500response for unexpected exceptions. - Separate routing logic from repository logic. You made the routes depend on the
BookRepositoryinterface. You also handled exceptions in one place withStatusPages. - Test Ktor endpoints. You used
testApplicationto 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=trueby readingcall.request.queryParametersin the list handler and filtering the result. - Validate updates. Write a
validate()extension forUpdateBookRequestthat rejects a blanktitleorauthorwhen the property is notnull. - Test the update endpoint. Add tests for
PUT /books/{id}, using the existing fetch tests as models. - Add a second resource. Build
/authorswith 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
BookRepositorywith a library such as Exposed, then select the new implementation inRouting.kt. Because the routes depend on the interface, the route handlers can remain unchanged. - Secure the API. Use Ktor’s
Authenticationplugin to protect the create, update, and delete endpoints, for example with JWT. - Validate with plugins. Compare your handwritten validation with Ktor’s
RequestValidationplugin and useStatusPagesto 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.
- Configure a Ktor application to send and receive JSON. You installed
About the author
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.