Skip to content

Commit

Permalink
feat: Add Ktor integration (#72)
Browse files Browse the repository at this point in the history
* docs: Update Spring support

* pre-release: 3.0.4 (#70)

* docs: Link Spring Boot example

* Add Ktor integration

* Fix content type

* Fix test

* Fix test

* Add Script extension

* Add Script extension

* Refactor

* Add plugin integration test

* Add docs for ktor integration
  • Loading branch information
lorenzsimon authored Aug 27, 2024
1 parent f1c1d00 commit ecbc202
Show file tree
Hide file tree
Showing 59 changed files with 1,157 additions and 255 deletions.
122 changes: 92 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@
[![Qodana](https://github.com/OpenFolder/kotlin-asyncapi/actions/workflows/qodana.yml/badge.svg?branch=master)](https://openfolder.github.io/kotlin-asyncapi/qodana/report)
[![Maven Central status](https://img.shields.io/maven-central/v/org.openfolder/kotlin-asyncapi-parent.svg)](https://search.maven.org/#search%7Cga%7C1%7Corg.openfolder%20kotlin-asyncapi)

> [!NOTE]
> Spring Framework 6 / Spring Boot 3 is supported since `6.0.14` / `3.1.6`
* [About](#about)
* [Prerequisites](#prerequisites)
* [Module Roadmap](#module-roadmap)
* [Usage](#usage)
* [Kotlin DSL](#kotlin-dsl-usage)
* [Spring Web](#spring-web-usage)
* [Ktor](#ktor-usage)
* [Annotation](#annotation-usage)
* [Kotlin Script](#kotlin-script-usage)
* [Examples](#examples)
* [Configuration](#configuration)
* [Spring Web](#spring-web-configuration)
* [Ktor](#ktor-configuration)
* [Maven Plugin](#maven-plugin-configuration)
* [License](#license)

Expand All @@ -23,26 +27,6 @@ The Kotlin AsyncAPI project aims to provide convenience tools for generating and
[Kotlin DSL](https://kotlinlang.org/docs/type-safe-builders.html) for building the specification in a typesafe way.
The modules around that core build a framework for documenting asynchronous microservice APIs.

## Prerequisites
The framework generally supports any JVM project. Compatibility has been tested, but is not limited to the following versions:

| Identifier | Version |
|-----------------|-----------------------------|
| **JRE** | `8`, `11`, `17` |
| **Kotlin** | `1.6.21`, `1.7.0`, `1.7.10` |
| **Spring Boot** | `2.6.0`-`2.7.6` |
| **Maven** | `3.8.4`, `3.8.6` |

## Module Roadmap
| Module | Description | State |
|-------------------------|--------------------------------------------------------------------------------|--------------------|
| **core** | Kotlin DSL for building AsyncAPI specifications | :white_check_mark: |
| **spring‑web** | Spring Boot autoconfiguration for serving the generated document | :white_check_mark: |
| **script** | Kotlin scripting support for configuration as code | :white_check_mark: |
| **maven‑plugin** | Maven plugin for evaluating AsyncAPI scripts and packaging generated resources | :white_check_mark: |
| **annotation** | Technology agnostic annotations for meta-configuration | :white_check_mark: |
| **template** | Template engine for reusing similar AsyncAPI components | :x: |

## Usage
### <a name="kotlin-dsl-usage"></a>Kotlin DSL
The `AsyncApi` class represents the root of the specification. It provides a static entry function `asyncApi` to the
Expand Down Expand Up @@ -187,6 +171,66 @@ data class ChatMessage(
</dependency>
```

### <a name="ktor-usage"></a>Ktor
To serve your AsyncAPI specification via Ktor:
- add the `kotlin-asyncapi-ktor` dependency
- install the `AsyncApiPlugin` in you application
- document your API with `AsyncApiExtension` and/or Kotlin scripting (see [Kotlin script usage](#kotlin-script-usage))
- add annotations to auto-generate components (see [annotation usage](#annotation-usage))

You can register multiple extensions to extend and override AsyncAPI components. Extensions with a higher order override extensions with a lower order. Please note that you can only extend top-level components for now (`info`, `channels`, `servers`...). Subcomponents will always be overwritten.

**Example** (simplified version of [Gitter example](https://github.com/asyncapi/spec/blob/22c6f2c7a61846338bfbd43d81024cb12cf4ed5f/examples/gitter-streaming.yml))
```kotlin
fun main() {
embeddedServer(Netty, port = 8000) {
install(AsyncApiPlugin) {
extension = AsyncApiExtension.builder(order = 10) {
info {
title("Gitter Streaming API")
version("1.0.0")
}
servers {
// ...
}
// ...
}
}
}.start(wait = true)
}

@Channel(
value = "/rooms/{roomId}",
parameters = [
Parameter(
value = "roomId",
schema = Schema(
type = "string",
examples = ["53307860c3599d1de448e19d"]
)
)
]
)
class RoomsChannel {

@Subscribe(message = Message(ChatMessage::class))
fun publish(/*...*/) { /*...*/ }
}

@Message
data class ChatMessage(
val id: String,
val text: String
)
```
```xml
<dependency>
<groupId>org.openfolder</groupId>
<artifactId>kotlin-asyncapi-ktor</artifactId>
<version>${kotlin-asyncapi.version}</version>
</dependency>
```

### <a name="annotation-usage"></a>Annotation
The `kotlin-asyncapi-annotation` module defines technology-agnostic annotations that can be used to document event-driven microservice APIs.

Expand All @@ -207,6 +251,10 @@ You have two options to use Kotlin scripting in your project:
- [Plugin] let the Maven plugin evaluate the script during build time (recommended)
- [Embedded] let your Spring Boot application evaluate the script at runtime

### <a name="examples"></a>Examples
- [Spring Boot Application](kotlin-asyncapi-examples/kotlin-asyncapi-spring-boot-example)
- [Ktor Application](kotlin-asyncapi-examples/kotlin-asyncapi-ktor-example)

#### Maven Plugin
The Maven plugin evaluates your `asyncapi.kts` script, generates a valid AsyncAPI JSON file and adds it to the project resources. The `kotlin-asyncapi-spring-web` module picks the generated resource up and converts it to an `AsyncApiExtension`.

Expand Down Expand Up @@ -276,14 +324,28 @@ In order to enable embedded scripting, you need to make some additional configur
### <a name="spring-web-configuration"></a>Spring Web
You can configure the Spring Web integration in the application properties:

| Property | Description | Default |
|---------------------------------|---------------------------------------------------------------|----------------------------------------------|
| `asyncapi.enabled` | Enables the autoconfiguration | `true` |
| `asyncapi.path` | The resource path for serving the generated AsyncAPI document | `/docs/asyncapi` |
| `asyncapi.annotation.enabled` | Enables the annotation scanning and processing | `true` |
| `asyncapi.script.enabled` | Enables the Kotlin script support | `true` |
| `asyncapi.script.resource-path` | Path to the generated script resource file | `classpath:asyncapi/generated/asyncapi.json` |
| `asyncapi.script.source-path` | Path to the AsyncAPI Kotlin script file | `classpath:build.asyncapi.kts` |
| Property | Description | Default |
|---------------------------------|---------------------------------------------------------------|------------------------------------|
| `asyncapi.enabled` | Enables the autoconfiguration | `true` |
| `asyncapi.path` | The resource path for serving the generated AsyncAPI document | `/docs/asyncapi` |
| `asyncapi.annotation.enabled` | Enables the annotation scanning and processing | `true` |
| `asyncapi.script.enabled` | Enables the Kotlin script support | `true` |
| `asyncapi.script.resource-path` | Path to the generated script resource file | `asyncapi/generated/asyncapi.json` |
| `asyncapi.script.source-path` | Path to the AsyncAPI Kotlin script file | `build.asyncapi.kts` |

### <a name="ktor-configuration"></a>Ktor
You can configure the Ktor integration in the plugin configuration:

| Property | Description | Default |
|-------------------|---------------------------------------------------------------|------------------------------------|
| `path` | The resource path for serving the generated AsyncAPI document | `/docs/asyncapi` |
| `baseClass` | The base class to filter code scanning packages | `null` |
| `scanAnnotations` | Enables class path scanning for annotations | `true` |
| `extension` | AsyncApiExtension hook | `AsyncApiExtension.empty()` |
| `extensions` | For registering multiple AsyncApiExtension hooks | `emptyList()` |
| `resourcePath` | Path to the generated script resource file | `asyncapi/generated/asyncapi.json` |
| `sourcePath` | Path to the AsyncAPI Kotlin script file | `build.asyncapi.kts` |


### <a name="maven-plugin-configuration"></a>Maven Plugin
You can configure the plugin in the plugin configuration:
Expand Down
62 changes: 62 additions & 0 deletions kotlin-asyncapi-context/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.openfolder</groupId>
<artifactId>kotlin-asyncapi-parent</artifactId>
<version>3.0.4-SNAPSHOT</version>
</parent>

<artifactId>kotlin-asyncapi-context</artifactId>
<packaging>jar</packaging>

<name>Kotlin AsyncAPI Context</name>
<description>Context module for framework integrations</description>

<dependencies>
<dependency>
<groupId>org.openfolder</groupId>
<artifactId>kotlin-asyncapi-core</artifactId>
</dependency>
<dependency>
<groupId>org.openfolder</groupId>
<artifactId>kotlin-asyncapi-script</artifactId>
</dependency>
<dependency>
<groupId>org.openfolder</groupId>
<artifactId>kotlin-asyncapi-annotation</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-core-jakarta</artifactId>
</dependency>
<dependency>
<groupId>io.github.classgraph</groupId>
<artifactId>classgraph</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-scripting-jvm-host</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.skyscreamer</groupId>
<artifactId>jsonassert</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.openfolder.kotlinasyncapi.context

import org.openfolder.kotlinasyncapi.model.AsyncApi

interface AsyncApiContextProvider {

val asyncApi: AsyncApi?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.openfolder.kotlinasyncapi.context

import org.openfolder.kotlinasyncapi.model.AsyncApi

class PackageInfoProvider(
private val applicationPackage: Package?
) : AsyncApiContextProvider {

override val asyncApi: AsyncApi? by lazy {
AsyncApi().apply {
info {
title(applicationPackage?.implementationTitle ?: "AsyncAPI Definition")
version(applicationPackage?.implementationVersion ?: "SNAPSHOT")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.openfolder.kotlinasyncapi.context

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.ObjectMapper
import org.openfolder.kotlinasyncapi.model.AsyncApi

class ResourceProvider(path: String) : AsyncApiContextProvider {

private val objectMapper = ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL)

override val asyncApi: AsyncApi? by lazy {
resource?.let { objectMapper.readValue(it, AsyncApi::class.java) }
}

val resource: String? = javaClass.classLoader.getResource(path)?.readText()
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package org.openfolder.kotlinasyncapi.springweb.context
package org.openfolder.kotlinasyncapi.context.annotation

import org.openfolder.kotlinasyncapi.annotation.AsyncApiAnnotation
import org.openfolder.kotlinasyncapi.annotation.Schema
import org.openfolder.kotlinasyncapi.annotation.channel.Channel
import org.openfolder.kotlinasyncapi.annotation.channel.Message
import org.openfolder.kotlinasyncapi.context.AsyncApiContextProvider
import org.openfolder.kotlinasyncapi.context.annotation.processor.AnnotationProcessor
import org.openfolder.kotlinasyncapi.model.AsyncApi
import org.openfolder.kotlinasyncapi.model.ReferencableCorrelationIDsMap
import org.openfolder.kotlinasyncapi.model.ReferencableSchemasMap
Expand All @@ -20,17 +22,12 @@ import org.openfolder.kotlinasyncapi.model.component.ReferencableSecuritySchemas
import org.openfolder.kotlinasyncapi.model.server.ReferencableServerBindingsMap
import org.openfolder.kotlinasyncapi.model.server.ReferencableServerVariablesMap
import org.openfolder.kotlinasyncapi.model.server.ReferencableServersMap
import org.openfolder.kotlinasyncapi.springweb.EnableAsyncApi
import org.openfolder.kotlinasyncapi.springweb.context.annotation.AnnotationScanner
import org.openfolder.kotlinasyncapi.springweb.context.annotation.processor.AnnotationProcessor
import org.springframework.context.ApplicationContext
import org.springframework.stereotype.Component
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation

@Component
internal class AnnotationProvider(
private val context: ApplicationContext,
class AnnotationProvider(
private val applicationPackage: Package? = null,
private val classLoader: ClassLoader? = null,
private val scanner: AnnotationScanner,
private val messageProcessor: AnnotationProcessor<Message, KClass<*>>,
private val schemaProcessor: AnnotationProcessor<Schema, KClass<*>>,
Expand Down Expand Up @@ -59,14 +56,14 @@ internal class AnnotationProvider(
}

private fun bind(components: Components) {
val scanPackage = context.getBeansWithAnnotation(EnableAsyncApi::class.java).values
.firstOrNull()
?.let { it::class.java.`package`.name }
?.takeIf { it.isNotEmpty() }

val annotatedClasses = scanPackage?.let {
scanner.scan(scanPackage = it, annotation = AsyncApiAnnotation::class)
} ?: emptyList()
val packageName = applicationPackage?.name
val annotatedClasses = packageName.let {
scanner.scan(
scanPackage = it,
classLoader = classLoader,
annotation = AsyncApiAnnotation::class
)
}

annotatedClasses
.flatMap { clazz ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.openfolder.kotlinasyncapi.context.annotation

import io.github.classgraph.ClassGraph
import kotlin.reflect.KClass

interface AnnotationScanner {
fun scan(classLoader: ClassLoader? = null, scanPackage: String? = null, annotation: KClass<out Annotation>): List<KClass<*>>
}

class DefaultAnnotationScanner : AnnotationScanner {
override fun scan(classLoader: ClassLoader?, scanPackage: String?, annotation: KClass<out Annotation>): List<KClass<*>> {
val packageClasses = ClassGraph()
.enableAllInfo()
.apply {
if (classLoader != null) {
addClassLoader(classLoader)
}
if (scanPackage != null) {
acceptPackages(scanPackage)
}
}
.scan()

return packageClasses.getClassesWithAnnotation(annotation.java).standardClasses.map {
it.loadClass().kotlin
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.openfolder.kotlinasyncapi.context.annotation.processor

import org.openfolder.kotlinasyncapi.model.component.Components

interface AnnotationProcessor<T, U> {
fun process(annotation: T, context: U): Components
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
package org.openfolder.kotlinasyncapi.springweb.context.annotation.processor
package org.openfolder.kotlinasyncapi.context.annotation.processor

import org.openfolder.kotlinasyncapi.annotation.channel.Channel
import org.openfolder.kotlinasyncapi.annotation.channel.Publish
import org.openfolder.kotlinasyncapi.annotation.channel.Subscribe
import org.openfolder.kotlinasyncapi.model.component.Components
import org.springframework.stereotype.Component
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.functions
import kotlin.reflect.full.hasAnnotation

@Component
internal class ChannelProcessor : AnnotationProcessor<Channel, KClass<*>> {
class ChannelProcessor : AnnotationProcessor<Channel, KClass<*>> {
override fun process(annotation: Channel, context: KClass<*>): Components {
return Components().apply {
channels {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.openfolder.kotlinasyncapi.springweb.context.annotation.processor
package org.openfolder.kotlinasyncapi.context.annotation.processor

import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.swagger.v3.core.converter.ModelConverters
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.openfolder.kotlinasyncapi.springweb.context.annotation.processor
package org.openfolder.kotlinasyncapi.context.annotation.processor

import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
Expand Down
Loading

0 comments on commit ecbc202

Please sign in to comment.