Guides: How to create an API using ktor

预计阅读时间:12分钟

在本指南中,您将学习如何使用ktor创建API. 我们将创建一个简单的API来存储简单的文本片段(如小型的pastebin-like API).

为此,我们将使用RoutingStatusPagesAuthenticationJWT AuthenticationCORSContentNegotiationJackson功能.

尽管许多框架主张如何创建REST API,但大多数框架实际上并不是在谈论REST API,而是在谈论HTTP API. 就像许多其他框架一样,Ktor可以用来创建符合REST约束的系统. 但是,本教程不是在谈论REST,而是在谈论HTTP API,例如,使用HTTP动词的端点可能返回也可能不返回JSON,XML或任何其他格式. 如果您想了解有关RESTful系统的更多信息,可以开始阅读https://en.wikipedia.org/wiki/Representationalal_state_transfer .

目录:

Setting up the project

第一步是建立一个项目. 您可以按照《 快速入门》指南,或使用以下表单创建表单:

the pre-configured generator form

Simple routing

首先,我们将使用路由功能 . 此功能是Ktor核心的一部分,因此您无需包括任何其他工件.

使用routing { } DSL块时,将自动安装此功能.

让我们开始创建一个简单的GET路由,通过使用routing块中可用的get方法来响应OK

fun Application.module() {
    routing {
        get("/snippets") {
            call.respondText("OK")
        }
    }
}

Serving JSON content

HTTP API通常以JSON响应. 您可以为此使用Jackson内容协商功能:

fun Application.module() {
    install(ContentNegotiation) {
        jackson {
        }
    }
    routing {
        // ...
    }
}

要使用JSON响应请求,您必须使用任意对象调用call.respond方法.

routing {
    get("/snippets") {
        call.respond(mapOf("OK" to true))
    }
}

现在,浏览器或客户端应使用{"OK":true}响应http://127.0.0.1:8080/snippets

如果遇到诸如Response pipeline couldn't transform '...' to the OutgoingContent ,请检查是否已在Jackson上安装了ContentNegotiation功能.

您还可以将类型化的对象用作答复的一部分(但请确保您的类未在函数中定义,否则将无法工作). 因此,例如:

data class Snippet(val text: String)

val snippets = Collections.synchronizedList(mutableListOf(
    Snippet("hello"),
    Snippet("world")
))

fun Application.module() {
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT) // Pretty Prints the JSON
        }
    }
    routing {
        get("/snippets") {
            call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
        }
    }
}

会回复:

Handling other HTTP methods

HTTP API使用大多数HTTP方法/动词( HEADGETPOSTPUTPATCHDELETEOPTIONS )执行操作. 让我们创建一条添加新片段的途径. 为此,我们将需要读取POST请求的JSON正文. 为此,我们将使用call.receive<Type>()

data class PostSnippet(val snippet: PostSnippet.Text) {
    data class Text(val text: String)
}

// ...

routing {
    get("/snippets") {
        call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
    }
    post("/snippets") {
        val post = call.receive<PostSnippet>()
        snippets += Snippet(post.snippet.text)
        call.respond(mapOf("OK" to true))
    }
}

现在是时候实际尝试我们的后端了.

如果您具有IntelliJ IDEA Ultimate,则可以使用其内置的强大HTTP请求客户端,如果没有,还可以使用邮递员或curl:

IntelliJ IDEA Ultimate:

IntelliJ IDEA Ultimate,PhpStorm和JetBrains的其他IDE都包含一个非常好的基于编辑器的Rest Client .

首先,您必须创建一个HTTP请求文件( apihttp扩展名)

然后,您必须像这样输入方法,URL,标题和有效负载:

POST http://127.0.0.1:8080/snippets
Content-Type: application/json

{"snippet": {"text" : "mysnippet"}}

然后在URL中的播放装订线图标中,您可以执行呼叫并获取响应:

就是这样!

这使您可以定义包含几个HTTP请求定义的文件(纯文本或临时文本),允许包含标头,提供有效负载内联或使用文件,使用JSON文件中定义的环境变量,使用JavaScript处理响应以执行断言,或存储某些环境变量(例如身份验证凭据),以便其他请求可以使用它们. 它支持自动完成,模板和基于Content-Type的自动语言注入,包括JSON,XML等.

除了在编辑器中轻松测试您的后端外,它还通过包含一个带有端点的文件来帮助您记录API. 并允许您获取和本地存储响应并直观地进行比较.

CURL:

Bash:Response:
curl \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"snippet" : {"text" : "mysnippet"}}' \
  http://127.0.0.1:8080/snippets
{
  "OK" : true
}

让我们再次执行GET请求:

Nice!

Grouping routes together

现在我们有两条单独的路线共享路径(但没有方法),我们不想重复自己.

我们可以使用route(path) { }块将具有相同前缀的route(path) { }分组. 对于每个HTTP方法,都有一个重载,没有我们可以在路由叶节点上使用的route path参数:

routing {
    route("/snippets") {
        get {
            call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
        }
        post {
            val post = call.receive<PostSnippet>()
            snippets += Snippet(post.snippet.text)
            call.respond(mapOf("OK" to true))
        }
    }
}

Authentication

防止每个人都发布摘要是个好主意. 目前,我们将使用具有固定用户名和密码的http的基本身份验证来限制它. 为此,我们将使用身份验证功能.

fun Application.module() {
    install(Authentication) {
        basic {
            realm = "myrealm" 
            validate { if (it.name == "user" && it.password == "password") UserIdPrincipal("user") else null }
        }
    }
    // ...
}

安装和配置功能后,我们可以将一些路由分组在一起,以使用authenticate { }块进行身份authenticate { } .

在我们的例子中,我们将使get调用未经身份验证,并要求对后一个请求进行身份验证:

routing {
    route("/snippets") {
        get {
            call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
        }
        authenticate {
            post {
                val post = call.receive<PostSnippet>()
                snippets += Snippet(post.snippet.text)
                call.respond(mapOf("OK" to true))
            }        
        }
    }
}

JWT Authentication

而不是使用固定的身份验证,我们将使用JWT令牌.

我们将添加一个登录注册路由. 如果该路由不存在,它将注册一个用户,对于有效的登录或注册,它将返回一个JWT令牌. JWT令牌将保存用户名,发布后会将代码段链接到用户.

我们将需要安装和配置JWT(替换基本的auth):

open class SimpleJWT(val secret: String) {
    private val algorithm = Algorithm.HMAC256(secret)
    val verifier = JWT.require(algorithm).build()
    fun sign(name: String): String = JWT.create().withClaim("name", name).sign(algorithm)
}

fun Application.module() {
    val simpleJwt = SimpleJWT("my-super-secret-for-jwt")
    install(Authentication) {
        jwt {
            verifier(simpleJwt.verifier)
            validate {
                UserIdPrincipal(it.payload.getClaim("name").asString())
            }
        }
    }
    // ...
}

我们还将需要一个包含用户名和密码的数据源. 一个简单的选择是:

class User(val name: String, val password: String)

val users = Collections.synchronizedMap(
    listOf(User("test", "test"))
        .associateBy { it.name }
        .toMutableMap()
)
class LoginRegister(val user: String, val password: String)

有了这些,我们已经可以创建用于记录或注册用户的路由:

routing {
    post("/login-register") {
        val post = call.receive<LoginRegister>()
        val user = users.getOrPut(post.user) { User(post.user, post.password) }
        if (user.password != post.password) error("Invalid credentials")
        call.respond(mapOf("token" to simpleJwt.sign(user.name)))
    }
}

现在我们已经可以尝试为用户获取JWT令牌:

使用IntelliJ IDEA Ultimate的基于编辑器的HTTP客户端,您可以发出POST请求,并检查内容是否有效,并将令牌存储在环境变量中:

现在,您可以使用环境变量{{auth_token}}发出请求:

如果您想轻松测试除本地主机以外的其他端点,则可以创建一个http-client.env.json文件,并使用环境和变量来放置一个映射,如下所示:

之后,您可以开始使用用户定义的{{host}} env变量:

尝试运行请求时,您将能够选择要使用的环境:

Associating users to snippets

由于我们要发布具有经过身份验证的路由的代码段,因此我们可以访问包含用户名的生成的Principal . 因此,我们应该能够访问该用户并将其与代码段关联.

首先,我们需要将用户信息与代码段相关联:

data class Snippet(val user: String, val text: String)

val snippets = Collections.synchronizedList(mutableListOf(
    Snippet(user = "test", text = "hello"),
    Snippet(user = "test", text = "world")
))

现在,在插入新的代码片段时,我们可以使用主体信息(由身份验证功能在JWT身份验证时生成):

routing {
    // ...
    route("/snippets") {
        // ...
        authenticate {
            post {
                val post = call.receive<PostSnippet>()
                val principal = call.principal<UserIdPrincipal>() ?: error("No principal")
                snippets += Snippet(principal.name, post.snippet.text)
                call.respond(mapOf("OK" to true))
            }
        }
    }
}

让我们尝试一下:

Awesome!

StatusPages

现在让我们稍微完善一下. HTTP API应该使用HTTP状态代码来提供有关错误的语义信息. 现在,当引发异常时(例如,尝试从已经存在但密码错误的用户获取JWT令牌时),将返回500服务器错误. 我们可以做得更好,并且StatusPages功能将允许您通过捕获特定的异常并生成结果来做到这一点.

让我们创建一个新的异常类型:

class InvalidCredentialsException(message: String) : RuntimeException(message)

现在,让我们安装StatusPages功能,注册此异常类型并生成未经授权的页面:

fun Application.module() {
    install(StatusPages) {
        exception<InvalidCredentialsException> { exception ->
            call.respond(HttpStatusCode.Unauthorized, mapOf("OK" to false, "error" to (exception.message ?: "")))
        }
    }
    // ...
}

We should also update our login-register page to throw this exception:

routing {
    post("/login-register") {
        val post = call.receive<LoginRegister>()
        val user = users.getOrPut(post.user) { User(post.user, post.password) }
        if (user.password != post.password) throw InvalidCredentialsException("Invalid credentials")
        call.respond(mapOf("token" to simpleJwt.sign(user.name)))
    }
}

让我们尝试一下:

情况越来越好!

CORS

现在,假设我们需要可以从另一个域通过JavaScript访问此API. 我们将需要配置CORS. Ktor具有配置此功能的功能:

fun Application.module() {
    install(CORS) {
        method(HttpMethod.Options)
        method(HttpMethod.Get)
        method(HttpMethod.Post)
        method(HttpMethod.Put)
        method(HttpMethod.Delete)
        method(HttpMethod.Patch)
        header(HttpHeaders.Authorization)
        allowCredentials = true
        anyHost()
    }
    // ...
}

现在可以从任何主机访问我们的API了:)

Full Source

application.kt
package com.example

import com.auth0.jwt.*
import com.auth0.jwt.algorithms.*
import com.fasterxml.jackson.databind.*
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.jackson.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import java.util.*

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
    val simpleJwt = SimpleJWT("my-super-secret-for-jwt")
    install(CORS) {
        method(HttpMethod.Options)
        method(HttpMethod.Get)
        method(HttpMethod.Post)
        method(HttpMethod.Put)
        method(HttpMethod.Delete)
        method(HttpMethod.Patch)
        header(HttpHeaders.Authorization)
        allowCredentials = true
        anyHost()
    }
    install(StatusPages) {
        exception<InvalidCredentialsException> { exception ->
            call.respond(HttpStatusCode.Unauthorized, mapOf("OK" to false, "error" to (exception.message ?: "")))
        }
    }
    install(Authentication) {
        jwt {
            verifier(simpleJwt.verifier)
            validate {
                UserIdPrincipal(it.payload.getClaim("name").asString())
            }
        }
    }
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT) // Pretty Prints the JSON
        }
    }
    routing {
        post("/login-register") {
            val post = call.receive<LoginRegister>()
            val user = users.getOrPut(post.user) { User(post.user, post.password) }
            if (user.password != post.password) throw InvalidCredentialsException("Invalid credentials")
            call.respond(mapOf("token" to simpleJwt.sign(user.name)))
        }
        route("/snippets") {
            get {
                call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
            }
            authenticate {
                post {
                    val post = call.receive<PostSnippet>()
                    val principal = call.principal<UserIdPrincipal>() ?: error("No principal")
                    snippets += Snippet(principal.name, post.snippet.text)
                    call.respond(mapOf("OK" to true))
                }
            }
        }
    }
}

data class PostSnippet(val snippet: PostSnippet.Text) {
    data class Text(val text: String)
}

data class Snippet(val user: String, val text: String)

val snippets = Collections.synchronizedList(mutableListOf(
    Snippet(user = "test", text = "hello"),
    Snippet(user = "test", text = "world")
))

open class SimpleJWT(val secret: String) {
    private val algorithm = Algorithm.HMAC256(secret)
    val verifier = JWT.require(algorithm).build()
    fun sign(name: String): String = JWT.create().withClaim("name", name).sign(algorithm)
}

class User(val name: String, val password: String)

val users = Collections.synchronizedMap(
    listOf(User("test", "test"))
        .associateBy { it.name }
        .toMutableMap()
)

class InvalidCredentialsException(message: String) : RuntimeException(message)

class LoginRegister(val user: String, val password: String)
my-api.http

# Get all the snippets
GET {{host}}/snippets

###

# Register a new user
POST {{host}}/login-register
Content-Type: application/json

{"user" : "test", "password" : "test"}

> {%
client.assert(typeof response.body.token !== "undefined", "No token returned");
client.global.set("auth_token", response.body.token);
%}

###

# Put a new snippet (requires registering)
POST {{host}}/snippets
Content-Type: application/json
Authorization: Bearer {{auth_token}}

{"snippet" : {"text": "hello-world-jwt"}}

###

# Try a bad login-register
POST http://127.0.0.1:8080/login-register
Content-Type: application/json

{"user" : "test", "password" : "invalid-password"}

###

http-client.env.json
{
  "localhost": {
    "host": "http://127.0.0.1:8080"
  },
  "prod": {
    "host": "https://my.domain.com"
  }
}

Exercises

在按照本指南进行练习之后,您可以尝试执行以下练习:

Exercise 1

向每个代码段添加唯一的ID,并向/snippets添加DELETE http动词,以使经过身份验证的用户删除其代码段.

Exercise 2

将用户和代码段存储在数据库中.

by  ICOPY.SITE