Ktor REST API
REST - Respresentational State Transfer
- The path (URL) is the route to a resource
- A resource is any kind of logical object in the business model
- HTTP is most often used as transport protocol
- GET - retrieve data
- PUT - create or update an object
- POST - submit data or update an object
- DELETE
- HEAD - get only the header (no body)
- OPTIONS - queries which methods are possible
- PATCH - updates a partial resource
- JSON is always the preferred data format, it's just so darn pretty
- API - Application program interface
- HTTPS encrypts communications between server and client
- Is often made secure with basic or OAUTH2 authenication
Ktor and Hypermedia
Hypermedia is an extension of the term hypertext, it is a medium of delivering information to a browser that could include graphics, audio, video, plaintext, and hyperlinks.
- HATEOAS - Hypermedia as the Engine of Application State
- Ktor does not have a standard/built-in feature that generates a HATEOAS response yet
Content Negotiation
- Register (map) content converters with content types
install(ContentNegotiation) {
register(TheContentType, TheContentTypeConverter()) {
//configure converter
}
}
- ContentNegotation feature is part of standard ktor library
- Example Converters:
- GSON converter - io.ktor:ktor-gson
- Jackson converter - io.ktor:ktor-jackson
- There is no standard XML converter, but you could define one by importing com.fasterxml.jackson.dataformat:jackson-dataformat-xml
- Use "Content-Type" header to find the correct receive converter
- Use the "Accept" header to find the matching send converter
Custom Converters
- To write a custom converter implement the interface ContentConverter
- Implement method convertForSend
- Implement method convertForReceive
interface ContentConverter {
suspend fun convertForSend(context: PipelineContent<Any, ApplicationCall>, contentType: ContentType, value: Any): Any?
suspend fun convertForReceive(context: PipelineContext<ApplicationReceiveRequest, ApplicationCall>): Any?
}
An example with the jackson XML library:
class XmlConverter : ContentConverter {
override suspend fun convertForSend(
context: PipelineContext<Any, ApplicationCall>,
contentType: ContentType,
value: Any
): Any? {
val xmlMapper = XmlMapper()
val xml = xmlMapper.writeValueAsString(value)
return TextContent(xml, contentType.withCharset(context.call.suitableCharset()))
}
override suspend fun convertForReceive(context: PipelineContext<ApplicationReceiveRequest, ApplicationCall>): Any? {
val request = context.subject
val channel = request.value as? ByteReadChannel ?: return null
val reader = channel.toInputStream().reader(context.call.request.contentCharset() ?: Charsets.UTF_8)
val type = request.typeInfo
val xmlMapper = XmlMapper()
val xml = reader
val result: Any? = xmlMapper.readValue(xml, type.javaClass)
return result
}
}
And then set that in the ContentNegotiator
// If nothing is specified in the header the request will use XML
install(ContentNegotiation) {
gson {
}
register(ContentType.Application.Xml, XmlConverter())
}
By setting up both json and xml we can set the perfered response type in the header:
curl -H "Accept: application/json" "http://localhost:8080/spaceship"
curl -H "Accept: application/xml" "http://localhost:8080/spaceship"
Serialization
- We can enable serialization features in both Gson and Jackson to configure the output. There are many serialization features, I will not list them all here, explore thhe class SerializationFeature for details.
- The main difference between GSON and Jackson is which serialization features are enabled by default. Ex:
- Jackson prints out null values by default.
- Jackson does not enable support for java.time.* by default, one must add the seperate dependency and register the module
install(ContentNegotiation) {
// If this line is active the request will use XML, unless the header specifies otherwise
//register(ContentType.Application.Xml, XmlConverter())
jackson {
registerModule(JavaTimeModule())
enable(SerializationFeature.INDENT_OUTPUT)
enable(SerializationFeature.WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED)
}
}
- Annotations can be used to determine how a single field should behave
- @JsonProperty - Set configuration to a single field
- @JsonFormat - To determine the datetime format of a single field
- @JsonInclude - Set if null values should be included
How Data Is Posted to the Route Endpoint
- The raw body data is collected from the request object
val channel: ByteReadChannel = call.receiveChannel()
val text: String = call.receiveText()
// receiveStream() is synchronous and blocks the thread
val inputStream: InputStream = call.receiveStream()
val multipart: MultiPartData = call.receiveMultipart()
- Form Parameters can be extracted with the function
call.recieveParameters
- Cookies sent in the header from the client can be accessed with
val cookies: RequestCookies = call.request.cookies
or can be accessed individually withval specificCookie: String? = request.cookies["specificCookie"]
- Multiparts are good for files uploads
val multipart = call.receiveMultipart()
multipart.forEachPart { ..}
Routes with Path Variables
- In a route, we can get the path variables with call.parameters.get(...)
- The Locations feature maps the variables from a class definition. As of right now it is still an experimental feature, it has been for a while
- Requires implementing "io.ktor:ktor-locations:$ktor_version" and then
install(Locations) {}
- The path variables are mapped by creating a class and annotation it with
@Location("/myLocation/{mypathvar}")
- Variables are in { } and it needs to match an argument in the primary constructor
- Then the route needs to be mapped using generics with the class we annotated with Location
get<MyLocation> {
call.respondText("${it.mypathvar}")
}
Nested Routes
- Nested Routes can be created with nested inner classes
@Location("/book/{category}")
data class Book(val category: String) {
@Location("/{author}")
data class Author(val book: Book, val author: String)
@Location("/list")
data class List(val book: Book)
}
Routes with Request Parameters
- Request or query parameters are most often used to describe paging and sorting when dealing with many rows of data
- It is the extra parameter after the "?" in a URL
https://localhost:8080/book/list?sortby=author&asc=1
- Multiple request parameters are separated with the "&" -sign
- Key and value are separated with the "=" sign
- We can use Locations to map request parameters to class fields. This is done by adding extra constructor arguments:
@Location("/listbooks") data class List(val sortBy: String, val asc: Int)
A call to the following might look like:http://localhost:8080/article/flowers/list?sortBy=author,releasedate&asc=1
- These arguments can be anything as long as they do not match path variables
- All request parameters are optional.
Working with Headers
Retrieve Headers from Request
- The request can be accessed on the call, when we are setting up the routes with call.request, using the following:
val headerValue: String? = call.request.headers.get("MyHeaderName")
- Mutiple values for a header key can be accessed with:
val multipleValues: List<String>? = request.headers.getAll("MyHeaderWithMultipleValues")
- Convienience functions can access standard headers on a request.
Setting Headers on a Response
- The response can be accessed on the call object when we are setting up the routes with call.response
call.response.header("HeaderName", "HeaderValue")
- HttpHeaders contain the most common HttpHeaders
call.response.header(HttpHeaders.SetCookie, "CookieValue")
- There is a DefaultHeaders feature available for installation
install(DefaultHeaders) {
header("SystemName", "BookStore")
}
Error Handling and Authentication
- Error handling can be done by importing StatusPages. The install function has three main configuration options:
- Exceptions: Create responses based on exception classes
- Satus: Create responses based on status code value
- statusFile: Use html file from classpath as response
install(StatusPages) {
exception<MyCustomException> { cause ->
call.respond(HttpStatusCode.InternalServerError, "Whoops a Cusom Error Occurred")
throw cause
}
}
- To prevent recursive stack calls when the same exception is thrown multiple times, each call is only caught by one handler
- It is also possible to redirect the client in the exception handler. That might look something like this:
install(StatusPages) {
exception<HttpRedirectException> { e ->
call.respondRedirect(e.location, permanent = e.permanent)
}
}
class HttpRedirectException(val location: String, val permanent: Boolean = false): RuntimeException()
- We can also return details from the HTTP repsonse:
install(StatusPages) {
status(HttpStatusCode.NotFound) {
call.respond(TextContent("${it.value} ${it.description}",
ContentType.Text.Plain.withCharsets(Charsets.UTF_8), it))
}
}
- And the StatusFile return:
install(StatusPages) {
statusFile(HttpStatusCode.NotFound, HttpStatusCode.Unauthorized, FilePattern = "my-custom-error#.html")
}
// The # above will be filled with the error code number
Authentication Concepts
- Authentication - proves the person or system is who they claim
- Authorization - the right to perform an action
- Principle - System or person to be authenticated
- Credentials - Username and password or API key that can be used to prove the identity of a principle
- Realm - Used to give more information in an unauthorized response
Supported Authentication Methods
-
Basic - Supply base64 encoded username and password in header
-
Form - Username and password sent as form data
-
HTTP Digest - MD5 encrypt username and password
-
JWT and JWK - JSON Web Tokens
-
LDAP within basic Authentication
-
OAuth 1a and 2.0
-
We can check credentials against values in database or against a constant in the validate function. If it's successful we return a UserIdPrinciple.
-
It is reccomended to create a table with usernames and hashed passwords
install(Authentication) {
basic("myAuth1") {
realm = " My Realm"
validate {
if (it.name == "mike" && it.password == "password")
UserIdPrincipal(it.name)
else null
}
}
basic("myAuth2") {
realm = "MyOtherRealm"
validate {
if(it.password == "${it.name}abc123")
UserIdPrincipal(it.name)
else
null
}
}
}
routing {
authenticate("myAuth1") {
get("/secret/weather") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name} it is secretly going to rain today")
}
}
authenticate("myAuth2") {
get("/secret/color") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name}, green is going to be popular tomorrow")
}
}
}
Routing Interceptors - Check Admin Rights
- An incoming request and outgoing response is called an ApplicationCall
- An ApplicationCall is passed through an ApplicationCallPipeline which consists of a number of interceptors, or it may not have any
- Interceptors are invoked one at a time
- An interceptor can choose to let the next interceptor continue with the ApplicationCall
- An interceptor can choose to finish the ApplicationCall and no more interceptors will receive the call
- The ApplicationCallPipeline consists of phases: 1. Setup 2. Monitoring 3. Features 4. Call 5. Fallback
- An interceptor registers to a specific phase
- Code can be run before and after a pipeline
// creating a new interceptor to be called after the call
val mike = PipelinePhase("Mike")
// This would not work if it was insertPhaseAfter, as the route would have already provided a response
insertPhaseBefore(ApplicationCallPipeline.Call, mike)
intercept(ApplicationCallPipeline.Setup) {
log.info("Setup phase")
}
intercept(ApplicationCallPipeline.Call) {
log.info("Call phase")
}
intercept(ApplicationCallPipeline.Features) {
log.info("Features phase")
}
intercept(ApplicationCallPipeline.Monitoring) {
log.info("Monitoring phase")
}
intercept(mike) {
log.info("Mike Phase${call.request.uri}")
if (call.request.uri.contains("mike")) {
log.info("The uri contains mike")
call.respondText("The Endpoint contains mike")
// finish means the remaining interceptors will not be called
finish()
}
}
routing {
get("/something/mike/something") {
call.respondText("Endpoint handled by route.")
}
}
No Comments