Web services
Using Kotlin and Ktor to build web services.
Why services?
Historically, many software applications were originally designed to be standalone, and much of the software that we use is still standalone. However, it can be useful to sometimes split processing across multiple systems. Here’s an incomplete list of the reasons why you might want to do this:
- Resource sharing. We often need to share resources across users. For example, storing our customer data in a shared databases that everyone in the company can access.
- Reliability. We might want to increase the reliability of our software by redundant copies running on different systems. This allows for fault tolerance - fail-over in case the first system fails. This is common with important resources like a web server.
- Performance. It can be more cost-effective to have one highly capable machine running the bulk of our processing, while cheaper/smaller systems can be used to access that shared machine. It can also be cheaper to spread computation across multiple systems, where tasks can be run in parallel. Distributed architectures provide flexibility to align the processing capabilities with the task to be performed.
- Scalability. Finally, if designed correctly, distributing our work across multiple systems can allow us to grow our system to meet high demand. Amazon for example, needs to ensure that their systems remain responsive, even in times of heavy load (e.g. holiday season).
- Openness. There is more flexibility, since we can mix systems from different vendors.
Earlier, we discussed a distributed application as a set of components, spread across more than one machine, and communicating with one another over a network. Each component has some capabilities that it provides to the other components, and many of them coordinate work to accomplish a specific task.
We have already described a number of different distributed architectures. These enforce the idea that distributed systems can take many different forms, each with its own advantages and disadvantages. When we’re considering building a service, we’re really focusing on a particular kind of distributed system, where our application is leveraging remote resources.
Web architecture
A web server and web browser are a great example of a client-server architecture.
A web server is effectively a service running on a server, listening for requests at a particular port over a network, and serving web documents (HTML, JSON, XML, images). The payload delivered to a web browser is the content, which the browser interprets and displays. When the user interacts with a web page, the web browser reacts by making requests for additional information to the web server.
Over time, both browser and web server have become more sophisticated, allowing servers to host additional content, run additional programs as needed, and work as part of a larger ecosystem that can distribute client requests across other systems.
We’ll discuss this in more detail, and examine how we can leverage web technologies to build more generalized web services.
The major underlying protocol used to deliver web content is the Hypertext Transfer Protocol (HTTP). This is an application layer protocol that supports serving documents, and processing links to related documents, from a remote service.
HTTP functions as a request–response protocol:
- A web browser is a typical client, which the user is accessing. A web server would be a typical server.
- The user requests content through the browser, which results in an HTTP request message being sent to the server.
- The server, which provides resources such as HTML files and other content or performs other functions on behalf of the client, returns a response message to the client. The response contains completion status information about the request and may also contain requested content in its message body.
HTTP defines methods to indicate the desired action to be performed on the identified resource.
What this resource represents, whether pre-existing data or data generated dynamically, depends on the implementation of the server. Often, the resource corresponds to a file or the output of an executable residing on the server. Method names are case-sensitive.
-
GET: The GET method requests that the target resource transfers a representation of its state. GET requests should only retrieve data and should have no other effect.
-
HEAD: The HEAD method requests that the target resource transfers a representation of its state, like for a GET request, but without the representation data enclosed in the response body. Uses include looking whether a page is available through the status code, and quickly finding out the size of a file (
Content-Length
). -
POST: The POST method requests that the target resource processes the representation enclosed in the request according to the semantics of the target resource. For example, it is used for posting a message to an Internet forum, or completing an online shopping transaction.
-
PUT: The PUT method requests that the target resource creates or updates its state with the state defined by the representation enclosed in the request. A distinction to POST is that the client specifies the target location on the server.
-
DELETE: The DELETE method requests that the target resource deletes its state.
What is REST?
Representational State Transfer (REST) is a software architectural style that defines a set of constraints for how the architecture of an Internet-scale system, such as the Web, should behave.
- REST was created by Roy Fielding in his doctoral dissertation in 2000.
- It has been widely adopted and is considered the standard for managing stateless interfaces for service-based systems.
- The term “Restful Services” is commonly used to describe services built using standard web technologies that adheres to these design principles.
REST Principles
- Client-Server. By splitting responsibility into a client and service, we decouple our interface and allow for greater flexibility in how our service is deployed.
- Layered System. The client has no awareness of how the service is provided, and we may have multiple layers of responsibility on the server. i.e. we may have multiple servers behind the scenes.
- Cacheable. With stateless servers, the client has the ability to cache responses under certain circumstances which can improve performance.
- Code On-Demand. Clients can download code at runtime to extend their functionality (a property of the client architecture).
- Stateless. The service does not retain state i.e. it’s idempotent. Every request that is sent is handled independently of previous requests. That does not mean that we cannot store data in a backing database, it just means that we have consistency in our processing.
- Uniform Interface. Our interface is consistent and well-documented. Using the guidelines below, we can be assured of consistent behaviour.
API Endpoints
For your service, you define one or more HTTP endpoints (URLs). Think of an endpoint as a function - you interact with it to make a request to the server. Examples:
To use a service, you format a request using one of these request types and send that request to an endpoint.
- GET: Use the GET method to READ data. GET requests are safe and idempotent.
- POST: Use a POST request to STORE data i.e. create a new record in the database, or underlying data model. Use the request payload to include the information that you want to store. You have a choice of content types (e.g. multipart/form-data or x-www-form-urlencoded or raw application/json, text/plain…)
- PUT: A PUT request should be used to UPDATE existing data.
- DELETE: Use a DELETE request to delete existing data.
API Design
Here’s some guidelines on using REST to create a web service [Cindrić 2021].
- Use JSON for requests and responses
- It’s easier to use, read and write, and it’s faster than XML. Every meaningful programming language and toolkit already supports it.
- e.g. make a POST request with a JSON data structure as the payload.
- Use meaningful structures for your endpoint
- Use nouns instead of verbs and use plural instead of singular form. e.g.
- GET /customers should return a list of customers
- GET /customers/1 should return data for customer ID=1.
- Be Consistent
- If you define a JSON structure for a record, you should always use that structure: avoid doing things like omitting empty fields (instead, return them as named empty arrays).
Ktor for Web Services
One challenge in using web services is that we are stuck with the inherent limits of that architecture. They are useful in situations where a REQUEST/RESPONSE model makes sense i.e. where the client can initiate all connections and query the server for information. We’ll focus on building that style of server first.
Building a scalable, high-performance server that can handle security, and other concerns is a significant undertaking. Luckily, there are a number of frameworks available that simplify the process. We’ll consider Ktor, a Kotlin-first framework. It’s designed as a multi-platform framework, meaning that you can deploy it anywhere. It’s lightweight, flexible and leverages other Kotlin features like coroutines.
Creating a Server
You can visit start.ktor.io to create and download a Ktor Kotlin project. IntelliJ Ultimate also ships with a Ktor plugin, and supports generating a new Ktor project from the project wizard.
You should choose these settings:
- Build system: Gradle Kotlin (to be consistent with the rest of the course).
- Ktor version: The latest version (2.3.3 at this time).
- Engine: This is your web server which will host your application. Netty is lightweight and fine for testing.
You will next be prompted for plugins – extensions which define the capabilities of your web service. Ktor includes a number of types of plugins:
- Security: Authentication and authorization, integrated with common services (e.g. LDAP, OAuth).
- Routing: How to handle web requests: serve static content, or route to an application.
- HTTP: Options for handling HTTP requests.
- Serialization: How data will be shared to and from your web service (e.g. JSON).
- Databases: Are you leveraging any underlying database engine (e.g. Exposed, Postgres).
You should always add at-least Routing and Serialization so that you can handle incoming requests (ajd serialize to/from JSON). Other modules can be added as needed.
Project Structure
Your starting project will consist of:
Application.kt
with your basic code.Plugins/Routing.kt
andPlugins/Serialization.kt
which contain code for handling specific requests.
Here’s the Application.kt
code:
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureSerialization()
configureRouting()
}
Gradle
> Run
will launch the server! You can see details in the Run log: this server runs on the localhost
machine, and listens for HTTP traffic on port 8080:
> Task :run
2023-08-26 21:29:20.044 [main] INFO ktor.application - Autoreload is disabled because the development mode is off.
2023-08-26 21:29:20.223 [main] INFO ktor.application - Application started in 0.21 seconds.
2023-08-26 21:29:20.321 [DefaultDispatcher-worker-1] INFO ktor.application - Responding at http://0.0.0.0:8080
Launch a web browser against this address to test it.
Handling Requests
The server typically needs to listen for HTTP requests on specific end-points that you have defined. Your client will send HTTP requests (GET, POST, PUT, DEL) to those end-points, possibly with data included in the requests. Your server needs to accept the requests and process it accordingly.
For example, UW has an Open API that you can use to query course information:
- The base URL is: https://openapi.data.uwaterloo.ca/
- Subject information is at this end-point: https://openapi.data.uwaterloo.ca/v3/subjects
- You can make a GET request using a standard HTTP GET method to return subject information!
- Note that you need to include other information in the header e.g. authentication token.
Here’s an example of a server that just returns data from a GET request. It includes two source code files: the main application, and the Routing.kt
file to handle the specific routing request.
// Application.kt
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureRouting()
}
// Routing.kt
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
Creating a Client
You can add Ktor support to any client application by adding the appropriate dependencies.
implementation("io.ktor:ktor-client-core:2.3.8") // or recent version
Full details are in the Ktor client documentation.
Making Requests
The client application needs to generate correctly formatted requests, and send them over the network.
You can use Ktor to add network capabilities to any Kotlin application.
Here’s an example of application code to make a GET requests. In this example, the query()
function makes a GET request to fetch the contents of the Ktor website, and return the status code.
class Main : Application() {
override fun start(stage: Stage) {
var result: String = "empty"
runBlocking {
launch {
result = query()
}
}
stage.scene = Scene(StackPane(Label(result)), 250.0, 150.0)
stage.isResizable = false
stage.title = "Ktor-client-server"
stage.show()
}
}
suspend fun query(): String {
val client = HttpClient(CIO)
val response: HttpResponse = client.get("https://ktor.io/")
client.close()
return("${SysInfo.hostname}: ${response.status}")
}
Testing the API
It’s helpful to have a program that can generate HTTP requests for testing (vs. relying completely on the code that you write, or testing in a browser). Postman is one popular API client.
Ktor for Websockets
Web services are fantastic if you have a situation that suits a REQUEST/RESPONSE model, where the client is querying the server for data. However, this interaction model doesn’t work for every scenario.
For example, imagine that you are building a chat client where one or more clients can connect to a central server; when a person types a message, you want that message to be received by all of the clients promptly. Using a REST service, the server has no ability to initiate a connection to the client, or send a message, so the client would need to poll periodically to check for messages.
For this situation, we need a way to open a persistant connection between the client and server, so that either process can send messages to the other. In our chat client, for example, we would like
- the client to register with the server
- the client can then send messages to the server at any time
- the server can receive messages, and send them to other clients (i.e. it initiated the connection).
Websockets
are the web browser technology that supports this style of persistant connection. Note that there are other ways to address this problem (including manually managing socket connections), but this is a standard way for web clients to handle this problem, which are useful for all applications. Ktor also provides extensive websocket support.
If you want websocket support in your project, you will need to modify the dependencies to include the appropriate libraries. If this is a new server project, you can specify
websockets
in the list of plugins that you include in your project. Websockets need to be enabled on both client and server.
See Creating a WebSocket chat for a lengthy explanation of how websockets work.
Hosting Services
Unlike applications, which are hosted by users on their own systems, services are typically deployed on servers. These can be physical systems, VMs or containers running in the cloud, or any combination of these targets.
Web services, the type that we’ve been considering, need to be deployed to a web server. When we were building Ktor projects, it quietly launched a web server in the background to support us testing our application. To deploy in a production environment though, we would need to install our application in an environment where a web server is already installed.
Ktor can produce a JAR file which you can install and host in a locally installed web server.
However, it’s more common to use a cloud service to host your database and application.
For details, see Cloud Hosting.