Skip to content

Commit

Permalink
PI-2268 Auto-generate AsyncAPI specs (#3897)
Browse files Browse the repository at this point in the history
* PI-2268 Auto-generate AsyncAPI specs

* Update schemas
  • Loading branch information
marcus-bcl authored Jun 13, 2024
1 parent ea0657a commit 7c43079
Show file tree
Hide file tree
Showing 133 changed files with 1,437 additions and 1,211 deletions.
42 changes: 32 additions & 10 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,48 +61,70 @@ jobs:
bundler-cache: true
working-directory: projects/${{ matrix.project }}/tech-docs

- name: Check if OpenAPI is configured
- name: Check if OpenAPI or AsyncAPI is configured
id: check_config
run: echo "api_path=$(yq '.api_path' 'projects/${{ matrix.project }}/tech-docs/config/tech-docs.yml')" | tee -a "$GITHUB_OUTPUT"
run: |
echo "has_rest_api=$(yq '. | has("api_path")' 'projects/${{ matrix.project }}/tech-docs/config/tech-docs.yml')" | tee -a "$GITHUB_OUTPUT"
echo "has_async_api=$(test -f 'projects/${{ matrix.project }}/tech-docs/source/asyncapi-reference.html.md.erb' && echo 'true' || echo 'false')" | tee -a "$GITHUB_OUTPUT"
- uses: actions/setup-java@v4
if: startsWith(steps.check_config.outputs.api_path, 'http')
if: steps.check_config.outputs.has_rest_api == 'true' || steps.check_config.outputs.has_async_api == 'true'
with:
java-version: '21'
distribution: 'temurin'

- uses: gradle/actions/setup-gradle@v3
if: startsWith(steps.check_config.outputs.api_path, 'http')
if: steps.check_config.outputs.has_rest_api || steps.check_config.outputs.has_async_api
with:
cache-read-only: true
- name: Host OpenAPI spec
if: startsWith(steps.check_config.outputs.api_path, 'http')

- name: Host API specs
if: steps.check_config.outputs.has_rest_api == 'true' || steps.check_config.outputs.has_async_api == 'true'
run: |
./gradlew '${{ matrix.project }}:bootRun' &
timeout 300 sh -c 'until curl -s localhost:8080/v3/api-docs.yaml; do sleep 5; done'
yq -i '.api_path = "http://localhost:8080/v3/api-docs.yaml"' 'projects/${{ matrix.project }}/tech-docs/config/tech-docs.yml'
env:
SPRING_PROFILES_ACTIVE: dev

- name: Set branch
- name: Update configured OpenAPI path
if: steps.check_config.outputs.has_rest_api == 'true'
run: yq -i '.api_path = "http://localhost:8080/v3/api-docs.yaml"' 'projects/${{ matrix.project }}/tech-docs/config/tech-docs.yml'

- name: Set branch in index
if: github.ref_name != 'main'
run: sed -i "s|$REPOSITORY/main|$REPOSITORY/$BRANCH|" index.html.md.erb
working-directory: projects/${{ matrix.project }}/tech-docs/source
env:
REPOSITORY: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}

- name: Set branch in asyncapi-reference
if: github.ref_name != 'main' && steps.check_config.outputs.has_async_api == 'true'
run: sed -i "s|/tech-docs/|/tech-docs-drafts/$BRANCH/|" asyncapi-reference.html.md.erb
working-directory: projects/${{ matrix.project }}/tech-docs/source
env:
REPOSITORY: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}

- name: Build
run: |
gem install middleman
bundle exec middleman build --verbose
working-directory: projects/${{ matrix.project }}/tech-docs

- name: Bundle API specs
if: startsWith(steps.check_config.outputs.api_path, 'http')
- name: Bundle OpenAPI specs
if: steps.check_config.outputs.has_rest_api == 'true'
run: |
curl -sf localhost:8080/v3/api-docs -o api-docs.json
curl -sf localhost:8080/v3/api-docs.yaml -o api-docs.yaml
working-directory: projects/${{ matrix.project }}/tech-docs/build

- name: Bundle AsyncAPI specs
if: steps.check_config.outputs.has_async_api == 'true'
run: |
curl -sf localhost:8080/docs/asyncapi -o asyncapi-docs.json
working-directory: projects/${{ matrix.project }}/tech-docs/build

- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.project }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class CommonSecurityConfigurer : SecurityConfigurer {
AntPathRequestMatcher("/info/**"),
AntPathRequestMatcher("/swagger-ui/**"),
AntPathRequestMatcher("/v3/api-docs.yaml"),
AntPathRequestMatcher("/v3/api-docs/**")
AntPathRequestMatcher("/v3/api-docs/**"),
AntPathRequestMatcher("/docs/**")
).permitAll().anyRequest().authenticated()
}
.csrf { it.disable() }
Expand Down
1 change: 1 addition & 0 deletions libs/messaging/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dependencies {
compileOnly("org.springframework.boot:spring-boot-starter-data-jpa")

api(libs.bundles.aws.messaging)
api(libs.asyncapi)

testImplementation(project(":libs:dev-tools"))
testImplementation(libs.bundles.mockito)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package uk.gov.justice.digital.hmpps.documentation

import org.openfolder.kotlinasyncapi.springweb.service.AsyncApiExtension
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class AsyncApiCommonConfig {
@Bean
fun asyncApiCommonInfo() = AsyncApiExtension.builder {
info {
title("Async API Reference")
version("1.0")
contact {
name("Probation Integration Team")
email("[email protected]")
url("https://mojdt.slack.com/archives/C02HQ4M2YQN") // #probation-integration-tech Slack channel
}
license {
name("MIT")
url("https://github.com/ministryofjustice/hmpps-probation-integration-services/blob/main/LICENSE")
}
}
externalDocs {
url("https://ministryofjustice.github.io/hmpps-probation-integration-services/tech-docs/")
}
defaultContentType("application/json")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package uk.gov.justice.digital.hmpps.documentation

import com.fasterxml.jackson.databind.ObjectMapper
import org.openfolder.kotlinasyncapi.model.AsyncApi
import org.openfolder.kotlinasyncapi.model.channel.*
import org.openfolder.kotlinasyncapi.springweb.service.AsyncApiSerializer
import org.springframework.context.annotation.Primary
import org.springframework.stereotype.Component

@Primary
@Component
class AsyncApiReferencingSerializer(val objectMapper: ObjectMapper) : AsyncApiSerializer {
override fun AsyncApi.serialize(): String = objectMapper.writeValueAsString(this.also { asyncApi ->
asyncApi.components?.channels?.values?.forEach { value ->
val channel = value as Channel
listOfNotNull(channel.publish, channel.subscribe).forEach { it.replaceMessagesWithReferences() }
}
})

private fun Operation.replaceMessagesWithReferences() {
val messages = (message as OneOfReferencableMessages?)?.oneOf
val messageNames = messages?.filterIsInstance<Message>()?.mapNotNull { it.name }
messageNames?.forEach { messages.replaceWithReference(it) }
}

private fun ReferencableMessagesList.replaceWithReference(name: String) {
reference { ref("https://raw.githubusercontent.com/ministryofjustice/hmpps-domain-events/main/spec/schemas/$name.yml") }
removeIf { it is Message && it.name == name }
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package uk.gov.justice.digital.hmpps.message

import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonAnyGetter
import com.fasterxml.jackson.annotation.JsonAnySetter
import com.fasterxml.jackson.annotation.JsonSetter
import com.fasterxml.jackson.annotation.Nulls
import org.openfolder.kotlinasyncapi.annotation.channel.Message
import java.time.ZonedDateTime

@Message
data class HmppsDomainEvent(
val eventType: String,
val version: Int,
val detailUrl: String? = null,
val occurredAt: ZonedDateTime = ZonedDateTime.now(),
val description: String? = null,
@JsonAlias("additionalInformation") private val nullableAdditionalInformation: AdditionalInformation? = AdditionalInformation(),
@JsonSetter(nulls = Nulls.SKIP)
val additionalInformation: Map<String, Any?> = emptyMap(),
val personReference: PersonReference = PersonReference()
) {
val additionalInformation = nullableAdditionalInformation ?: AdditionalInformation()
}
)

data class PersonReference(val identifiers: List<PersonIdentifier> = listOf()) {
fun findCrn() = get("CRN")
Expand All @@ -24,17 +24,3 @@ data class PersonReference(val identifiers: List<PersonIdentifier> = listOf()) {
}

data class PersonIdentifier(val type: String, val value: String)

data class AdditionalInformation(
@JsonAnyGetter @JsonAnySetter
private val info: MutableMap<String, Any?> = mutableMapOf()
) {
operator fun get(key: String): Any? = info[key]
operator fun set(key: String, value: Any) {
info[key] = value
}

fun containsKey(key: String): Boolean {
return info.containsKey(key)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,7 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.junit.jupiter.MockitoExtension
import uk.gov.justice.digital.hmpps.datetime.EuropeLondon
import uk.gov.justice.digital.hmpps.datetime.ZonedDateTimeDeserializer
import uk.gov.justice.digital.hmpps.message.AdditionalInformation
import uk.gov.justice.digital.hmpps.message.HmppsDomainEvent
import uk.gov.justice.digital.hmpps.message.MessageAttributes
import uk.gov.justice.digital.hmpps.message.Notification
import uk.gov.justice.digital.hmpps.message.PersonIdentifier
import uk.gov.justice.digital.hmpps.message.PersonReference
import uk.gov.justice.digital.hmpps.message.*
import java.time.ZonedDateTime

@ExtendWith(MockitoExtension::class)
Expand Down Expand Up @@ -76,7 +71,7 @@ class HmppsDomainEventConverterTest {
"http://detail/url",
ZonedDateTime.parse("2022-07-27T15:22:08.509+01:00"),
"A description for the event",
AdditionalInformation(mutableMapOf("specialId" to "6aafe304-861f-4479-8380-fec5f90f6d17")),
mapOf("specialId" to "6aafe304-861f-4479-8380-fec5f90f6d17"),
PersonReference(listOf(PersonIdentifier("CRN", "X123456")))
),
attributes = MessageAttributes("attribute.event.type")
Expand All @@ -88,7 +83,7 @@ class HmppsDomainEventConverterTest {
message,
equalTo(
"""
|{"Message":"{\"eventType\":\"message.event.type\",\"version\":1,\"detailUrl\":\"http://detail/url\",\"occurredAt\":\"2022-07-27T15:22:08.509+01:00\",\"description\":\"A description for the event\",\"personReference\":{\"identifiers\":[{\"type\":\"CRN\",\"value\":\"X123456\"}]},\"additionalInformation\":{\"specialId\":\"6aafe304-861f-4479-8380-fec5f90f6d17\"}}",
|{"Message":"{\"eventType\":\"message.event.type\",\"version\":1,\"detailUrl\":\"http://detail/url\",\"occurredAt\":\"2022-07-27T15:22:08.509+01:00\",\"description\":\"A description for the event\",\"additionalInformation\":{\"specialId\":\"6aafe304-861f-4479-8380-fec5f90f6d17\"},\"personReference\":{\"identifiers\":[{\"type\":\"CRN\",\"value\":\"X123456\"}]}}",
|"MessageAttributes":{"eventType":{"Type":"String","Value":"attribute.event.type"}},"MessageId":"${hmppsEvent.id}"}
""".trimMargin().replace("\\n".toRegex(), "")
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,10 @@ class HmppsDomainEventTest {
"test.event.type",
1,
"https//detail/url",
ZonedDateTime.now()
ZonedDateTime.now(),
additionalInformation = mapOf("specialId" to "SP12345")
)

hmppsEvent.additionalInformation["specialId"] = "SP12345"

assertThat(hmppsEvent.additionalInformation["specialId"], equalTo("SP12345"))
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package uk.gov.justice.digital.hmpps

import org.openfolder.kotlinasyncapi.springweb.EnableAsyncApi
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@EnableAsyncApi
@SpringBootApplication
class App

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package uk.gov.justice.digital.hmpps.config

import org.openfolder.kotlinasyncapi.springweb.service.AsyncApiExtension
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class AsyncApiConfig {
@Bean
fun asyncApiExtension(asyncApiCommonInfo: AsyncApiExtension) = AsyncApiExtension.builder(order = 1) {
info.title("approved-premises-and-delius")
info.description("Reflect progress of approved premises referrals in Delius")
servers {
server("dev") {
url("https://sqs.eu-west-2.amazonaws.com/754256621582/probation-integration-dev-approved-premises-and-delius-queue")
protocol("sqs")
}
server("preprod") {
url("https://sqs.eu-west-2.amazonaws.com/754256621582/probation-integration-preprod-approved-premises-and-delius-queue")
protocol("sqs")
}
server("prod") {
url("https://sqs.eu-west-2.amazonaws.com/754256621582/probation-integration-prod-approved-premises-and-delius-queue")
protocol("sqs")
}
}
externalDocs {
url("https://ministryofjustice.github.io/hmpps-probation-integration-services/tech-docs/projects/approved-premises-and-delius/")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package uk.gov.justice.digital.hmpps.messaging

import org.openfolder.kotlinasyncapi.annotation.Schema
import org.openfolder.kotlinasyncapi.annotation.channel.Channel
import org.openfolder.kotlinasyncapi.annotation.channel.Message
import org.openfolder.kotlinasyncapi.annotation.channel.Publish
import org.springframework.stereotype.Component
import uk.gov.justice.digital.hmpps.converter.NotificationConverter
import uk.gov.justice.digital.hmpps.exception.IgnorableMessageException
Expand All @@ -10,11 +14,26 @@ import uk.gov.justice.digital.hmpps.telemetry.TelemetryService
import java.net.URI

@Component
@Channel("approved-premises-and-delius-queue")
class Handler(
private val telemetryService: TelemetryService,
private val approvedPremisesService: ApprovedPremisesService,
override val converter: NotificationConverter<HmppsDomainEvent>
) : NotificationHandler<HmppsDomainEvent> {

@Publish(
messages = [
Message(name = "approved-premises/application-submitted"),
Message(name = "approved-premises/application-assessed"),
Message(name = "approved-premises/application-withdrawn"),
Message(name = "approved-premises/booking-made"),
Message(name = "approved-premises/booking-changed"),
Message(name = "approved-premises/booking-cancelled"),
Message(messageId = "approved-premises.person.not-arrived", payload = Schema(HmppsDomainEvent::class)),
Message(messageId = "approved-premises.person.arrived", payload = Schema(HmppsDomainEvent::class)),
Message(messageId = "approved-premises.person.departed", payload = Schema(HmppsDomainEvent::class)),
]
)
override fun handle(notification: Notification<HmppsDomainEvent>) {
val event = notification.message
try {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: AsyncAPI Reference
source_url: 'https://github.com/ministryofjustice/hmpps-probation-integration-services/blob/main/projects/approved-premises-and-delius/tech-docs/source/asyncapi-reference.html.md.erb'
weight: 30
---

# AsyncAPI Reference

<iframe src="https://studio.asyncapi.com/?url=https://ministryofjustice.github.io/hmpps-probation-integration-services/tech-docs/projects/approved-premises-and-delius/asyncapi-docs.json&readOnly" title="AsyncAPI Spec" width="100%" height="100%" frameborder=0 style="position: absolute; bottom: 0; background: white"></iframe>

<style>.app-pane__content { position: relative }</style>
<script>document.querySelector('main').parentElement.appendChild(document.querySelector('main > iframe'))</script>
Loading

0 comments on commit 7c43079

Please sign in to comment.