From 47461c505f3ef002add2c895b15684db4a200273 Mon Sep 17 00:00:00 2001
From: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:11:21 +0900
Subject: [PATCH] feat: Add script to refresh README from OpenAPI document
refactor: Improve error handling in PowerDNSAPIClient and streamline response processing
fix: Update RecordController to directly return records without unnecessary mapping
refactor: Update DomainRepository and RecordRepository to remove nullable types for IDs
fix: Change User domains fetch strategy to lazy loading for performance improvement
fix: Enhance user authentication handling in UserFilter to ensure authenticated user presence
refactor: Improve DomainService to enforce ownership checks before domain deletion
refactor: Update RecordService to streamline record retrieval and ensure proper domain association
test: Add OpenAPI generation test to validate API documentation endpoint
test: Implement PowerDNSAPIClient tests to verify HTTP method usage and error handling
test: Create DomainService tests to validate domain ownership and deletion logic
test: Add application-test.properties for in-memory database configuration during tests
---
.gitignore | 49 +-
.idea/compiler.xml | 9 +
.idea/db-forest-config.xml | 10 +
.idea/kotlinc.xml | 3 +-
.idea/noctule.xml | 6 +
.idea/vcs.xml | 2 +-
README.md | 1048 +++--------------
build.gradle.kts | 90 +-
gradle/wrapper/gradle-wrapper.properties | 2 +-
openapi.json | 613 +++++-----
scripts/update_readme.py | 99 ++
.../space/mori/dnsapi/PowerDNSAPIClient.kt | 104 +-
.../dnsapi/controller/RecordController.kt | 4 +-
.../space/mori/dnsapi/db/DomainRepository.kt | 4 +-
.../space/mori/dnsapi/db/RecordRepository.kt | 6 +-
src/main/kotlin/space/mori/dnsapi/db/User.kt | 4 +-
.../space/mori/dnsapi/filter/UserFilter.kt | 3 +-
.../mori/dnsapi/service/DomainService.kt | 8 +-
.../mori/dnsapi/service/RecordService.kt | 23 +-
.../mori/dnsapi/DnsapiApplicationTests.kt | 2 +
.../mori/dnsapi/OpenApiGenerationTest.kt | 36 +
.../mori/dnsapi/PowerDNSAPIClientTest.kt | 52 +
.../mori/dnsapi/service/DomainServiceTest.kt | 64 +
.../resources/application-test.properties | 8 +
24 files changed, 920 insertions(+), 1329 deletions(-)
create mode 100644 .idea/db-forest-config.xml
create mode 100644 .idea/noctule.xml
create mode 100644 scripts/update_readme.py
create mode 100644 src/test/kotlin/space/mori/dnsapi/OpenApiGenerationTest.kt
create mode 100644 src/test/kotlin/space/mori/dnsapi/PowerDNSAPIClientTest.kt
create mode 100644 src/test/kotlin/space/mori/dnsapi/service/DomainServiceTest.kt
create mode 100644 src/test/resources/application-test.properties
diff --git a/.gitignore b/.gitignore
index ab667b4..c01ec47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
-# Created by https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,intellij
-# Edit at https://www.toptal.com/developers/gitignore?templates=gradle,java,kotlin,macos,intellij
+# Created by https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,linux,windows,intellij
+# Edit at https://www.toptal.com/developers/gitignore?templates=gradle,java,kotlin,macos,linux,windows,intellij
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
@@ -153,6 +153,21 @@ replay_pid*
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
### macOS ###
# General
.DS_Store
@@ -186,6 +201,32 @@ Temporary Items
# iCloud generated files
*.icloud
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
### Gradle ###
.gradle
**/build/
@@ -213,6 +254,4 @@ gradle-app.setting
# Java heap dump
*.hprof
-.env
-
-# End of https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,intellij
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,linux,windows,intellij
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b86273d..fd86868 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -3,4 +3,13 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml
new file mode 100644
index 0000000..cba11ab
--- /dev/null
+++ b/.idea/db-forest-config.xml
@@ -0,0 +1,10 @@
+
+
+
+ .
+ ----------------------------------------
+ 1:0:97ab7754-c9e9-4e6f-b775-574d260344a3
+ 2:0:1b4583fc-5364-493b-be71-9d4c536f6902
+ .
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 148fdd2..473f106 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,7 @@
-
+
+
\ No newline at end of file
diff --git a/.idea/noctule.xml b/.idea/noctule.xml
new file mode 100644
index 0000000..70eaf90
--- /dev/null
+++ b/.idea/noctule.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/README.md b/README.md
index a4b8cca..e9c6d47 100644
--- a/README.md
+++ b/README.md
@@ -2,897 +2,175 @@
[](https://discord.gg/EPnCfz7xx5) [](https://teamcity.mori.space/project/PdnsApi)
-> Version v1.0.0
+A Kotlin and Spring Boot API that exposes Cloudflare-style zone and DNS record endpoints backed by PowerDNS and MariaDB. API requests use `Authorization: Bearer `.
-## env Table
-| name | value |
-| --- |-----------------------------------------------------------------------|
-| DB_HOST | database host (mysql/mariadb) |
-| DB_PORT | database port (3306) |
-| DB_NAME | database name |
-| DB_USER | database connect user |
-| DB_PASSWORD | database connect password |
-| PDNS_API_KEY | PowerDNS Auth API Key |
-| PDNS_API_URL | PowerDNS HTTP API URL (contain host, port ex: http://pdns:8081) |
-| PDNS_NS | Nameserver addresses with comma splitted. (ns1.sh0rt.kr,ns2.sh0rt.kr) |
+## Build and run
-## Path Table
+Use JDK 25. The Gradle wrapper selects the Java 25 toolchain for compilation.
-| Method | Path | Description |
-| --- | --- | --- |
-| GET | [/zones](#getzones) | Get all domains |
-| POST | [/zones](#postzones) | Create domain |
-| GET | [/zones/{zone_id}/dns_records](#getzoneszone_iddns_records) | Get all records |
-| POST | [/zones/{zone_id}/dns_records](#postzoneszone_iddns_records) | Add Record by ID |
-| GET | [/zones/{zone_id}/dns_records/{dns_record_id}](#getzoneszone_iddns_recordsdns_record_id) | Get Record by ID |
-| DELETE | [/zones/{zone_id}/dns_records/{dns_record_id}](#deletezoneszone_iddns_recordsdns_record_id) | Remove Record by ID |
-| PUT | [/zones/{zone_id}/dns_records/{dns_record_id}](#putzoneszone_iddns_recordsdns_record_id) | Update Record by ID |
-| GET | [/zones/{cfid}](#getzonescfid) | Get domain |
-| DELETE | [/zones/{domain_id}](#deletezonesdomain_id) | Delete domain |
-
-## Reference Table
-
-| Name | Path | Description |
-| --- | --- | --- |
-| DomainRequestDTO | [#/components/schemas/DomainRequestDTO](#componentsschemasdomainrequestdto) | Request DTO for Domain |
-| ApiResponseDTO | [#/components/schemas/ApiResponseDTO](#componentsschemasapiresponsedto) | |
-| ErrorOrMessage | [#/components/schemas/ErrorOrMessage](#componentsschemaserrorormessage) | |
-| ApiResponseDTODomainResponseDTO | [#/components/schemas/ApiResponseDTODomainResponseDTO](#componentsschemasapiresponsedtodomainresponsedto) | |
-| DomainResponseDTO | [#/components/schemas/DomainResponseDTO](#componentsschemasdomainresponsedto) | Response DTO for Domain |
-| RecordRequestDTO | [#/components/schemas/RecordRequestDTO](#componentsschemasrecordrequestdto) | Request DTO for Record |
-| ApiResponseDTORecordResponseDTO | [#/components/schemas/ApiResponseDTORecordResponseDTO](#componentsschemasapiresponsedtorecordresponsedto) | |
-| RecordResponseDTO | [#/components/schemas/RecordResponseDTO](#componentsschemasrecordresponsedto) | Response DTO for Record |
-| ApiResponseDTOListDomainResponseDTO | [#/components/schemas/ApiResponseDTOListDomainResponseDTO](#componentsschemasapiresponsedtolistdomainresponsedto) | |
-| ApiResponseDTOListRecordResponseDTO | [#/components/schemas/ApiResponseDTOListRecordResponseDTO](#componentsschemasapiresponsedtolistrecordresponsedto) | |
-| ApiResponseDTODeleteResponseWithId | [#/components/schemas/ApiResponseDTODeleteResponseWithId](#componentsschemasapiresponsedtodeleteresponsewithid) | |
-| DeleteResponseWithId | [#/components/schemas/DeleteResponseWithId](#componentsschemasdeleteresponsewithid) | |
-| api token | [#/components/securitySchemes/api token](#componentssecurityschemesapi-token) | |
-
-## Path Details
-
-***
-
-### [GET]/zones
-
-- Summary
-Get all domains
-
-#### Responses
-
-- 200 Returns all domains
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Domain
- result: {
- // Domain CFID
- id: string
- // Domain name(TLD)
- name: string
- }[]
-}
+```sh
+./gradlew test bootJar
+./gradlew bootRun
```
-- 404 Returns not found
+`bootRun` activates the `dev` profile. Supply the environment variables below before starting the app. Tests use an in-memory H2 database and do not require MariaDB or PowerDNS.
-`application/json`
+| Variable | Description |
+| --- | --- |
+| `DB_HOST` | MariaDB host |
+| `DB_PORT` | MariaDB port, usually `3306` |
+| `DB_NAME` | Database name |
+| `DB_USER` | Database user |
+| `DB_PASSWORD` | Database password |
+| `PDNS_API_KEY` | PowerDNS HTTP API key |
+| `PDNS_API_URL` | PowerDNS HTTP API base URL, for example `http://pdns:8081` |
+| `PDNS_NS` | Comma-separated nameservers, for example `ns1.example.com,ns2.example.com` |
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
+## Update API documentation
+
+```sh
+./gradlew updateDocs
```
-***
-
-### [POST]/zones
-
-- Summary
-Create domain
-
-#### RequestBody
-
-- application/json
-
-```ts
-// Request DTO for Domain
-{
- // Domain name(TLD)
- name: string
-}
-```
-
-#### Responses
-
-- 200 Created domain
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Domain
- result: {
- // Domain CFID
- id: string
- // Domain name(TLD)
- name: string
- }
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [GET]/zones/{zone_id}/dns_records
-
-- Summary
-Get all records
-
-#### Responses
-
-- 200 Return All Records
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Record
- result: {
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
- }[]
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [POST]/zones/{zone_id}/dns_records
-
-- Summary
-Add Record by ID
-
-#### RequestBody
-
-- application/json
-
-```ts
-// Request DTO for Record
-{
- // Record type
- type: string
- // Host name
- name: string
- // Record data
- content: string
- // TTL (Time to Live)
- ttl: integer
- // Priority
- priority?: integer
- // Proxied: cloudflare api compatibility
- proxied: boolean
- // comment
- comment: string
-}
-```
-
-#### Responses
-
-- 200 Return Record
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Record
- result: {
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
- }
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [GET]/zones/{zone_id}/dns_records/{dns_record_id}
-
-- Summary
-Get Record by ID
-
-#### Responses
-
-- 200 Return Record
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Record
- result: {
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
- }
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [DELETE]/zones/{zone_id}/dns_records/{dns_record_id}
-
-- Summary
-Remove Record by ID
-
-#### Responses
-
-- 200 Return Record
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- id: string
- }
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [PUT]/zones/{zone_id}/dns_records/{dns_record_id}
-
-- Summary
-Update Record by ID
-
-#### RequestBody
-
-- application/json
-
-```ts
-// Request DTO for Record
-{
- // Record type
- type: string
- // Host name
- name: string
- // Record data
- content: string
- // TTL (Time to Live)
- ttl: integer
- // Priority
- priority?: integer
- // Proxied: cloudflare api compatibility
- proxied: boolean
- // comment
- comment: string
-}
-```
-
-#### Responses
-
-- 200 Return Record
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Record
- result: {
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
- }
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [GET]/zones/{cfid}
-
-- Summary
-Get domain
-
-#### Responses
-
-- 200 Returns domain
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Domain
- result: {
- // Domain CFID
- id: string
- // Domain name(TLD)
- name: string
- }
-}
-```
-
-- 404 Returns not found
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-***
-
-### [DELETE]/zones/{domain_id}
-
-- Summary
-Delete domain
-
-#### Responses
-
-- 200 Deleted domain
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- id: string
- }
-}
-```
-
-- 400 Bad request
-
-`application/json`
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-## References
-
-### #/components/schemas/DomainRequestDTO
-
-```ts
-// Request DTO for Domain
-{
- // Domain name(TLD)
- name: string
-}
-```
-
-### #/components/schemas/ApiResponseDTO
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- }
-}
-```
-
-### #/components/schemas/ErrorOrMessage
-
-```ts
-{
- code: integer
- message: string
-}
-```
-
-### #/components/schemas/ApiResponseDTODomainResponseDTO
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Domain
- result: {
- // Domain CFID
- id: string
- // Domain name(TLD)
- name: string
- }
-}
-```
-
-### #/components/schemas/DomainResponseDTO
-
-```ts
-// Response DTO for Domain
-{
- // Domain CFID
- id: string
- // Domain name(TLD)
- name: string
-}
-```
-
-### #/components/schemas/RecordRequestDTO
-
-```ts
-// Request DTO for Record
-{
- // Record type
- type: string
- // Host name
- name: string
- // Record data
- content: string
- // TTL (Time to Live)
- ttl: integer
- // Priority
- priority?: integer
- // Proxied: cloudflare api compatibility
- proxied: boolean
- // comment
- comment: string
-}
-```
-
-### #/components/schemas/ApiResponseDTORecordResponseDTO
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Record
- result: {
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
- }
-}
-```
-
-### #/components/schemas/RecordResponseDTO
-
-```ts
-// Response DTO for Record
-{
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
-}
-```
-
-### #/components/schemas/ApiResponseDTOListDomainResponseDTO
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Domain
- result: {
- // Domain CFID
- id: string
- // Domain name(TLD)
- name: string
- }[]
-}
-```
-
-### #/components/schemas/ApiResponseDTOListRecordResponseDTO
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- // Response DTO for Record
- result: {
- // Record ID
- id: string
- // Record type
- type: string
- // Record name
- name: string
- // Record content
- content: string
- // Zone(TLD) ID
- zoneId: string
- // Zone name(TLD)
- zoneName: string
- // Record creation time
- createdOn: string
- // Record modification time
- modifiedOn: string
- // Record priority
- priority?: integer
- // is proxyable: must false
- proxiable: boolean
- // is proxied: must false
- proxied: boolean
- // Record TTL
- ttl: integer
- // Record is locked: must false
- locked: boolean
- // Record comments
- comment?: string
- }[]
-}
-```
-
-### #/components/schemas/ApiResponseDTODeleteResponseWithId
-
-```ts
-{
- success: boolean
- errors: {
- code: integer
- message: string
- }[]
- messages:#/components/schemas/ErrorOrMessage[]
- result: {
- id: string
- }
-}
-```
-
-### #/components/schemas/DeleteResponseWithId
-
-```ts
-{
- id: string
-}
-```
-
-### #/components/securitySchemes/api token
-
-```ts
-{
- "type": "http",
- "scheme": "bearer"
-}
-```
+This task starts the app in a test context, writes the current OpenAPI 3.1 specification to [openapi.json](openapi.json), and refreshes the generated API section below. `generateOpenApi` and `updateReadme` are also available separately. The documentation task uses H2 and does not contact PowerDNS.
+
+## API reference
+
+
+Generated from [openapi.json](openapi.json). Do not edit this section by hand.
+
+### Endpoints
+
+| Method | Path | Summary | Request body | Success response |
+| --- | --- | --- | --- | --- |
+| `GET` | `/zones` | Get all domains | — | 200: [ApiResponseDTOListDomainResponseDTO](#apiresponsedtolistdomainresponsedto) |
+| `POST` | `/zones` | Create domain | [DomainRequestDTO](#domainrequestdto) | 200: [ApiResponseDTODomainResponseDTO](#apiresponsedtodomainresponsedto) |
+| `GET` | `/zones/{cfid}` | Get domain | — | 200: [ApiResponseDTODomainResponseDTO](#apiresponsedtodomainresponsedto) |
+| `DELETE` | `/zones/{domain_id}` | Delete domain | — | 200: [ApiResponseDTODeleteResponseWithId](#apiresponsedtodeleteresponsewithid) |
+| `GET` | `/zones/{zone_id}/dns_records` | Get all records | — | 200: [ApiResponseDTOListRecordResponseDTO](#apiresponsedtolistrecordresponsedto) |
+| `POST` | `/zones/{zone_id}/dns_records` | Add Record by ID | [RecordRequestDTO](#recordrequestdto) | 200: [ApiResponseDTORecordResponseDTO](#apiresponsedtorecordresponsedto) |
+| `GET` | `/zones/{zone_id}/dns_records/{dns_record_id}` | Get Record by ID | — | 200: [ApiResponseDTORecordResponseDTO](#apiresponsedtorecordresponsedto) |
+| `PUT` | `/zones/{zone_id}/dns_records/{dns_record_id}` | Update Record by ID | [RecordRequestDTO](#recordrequestdto) | 200: [ApiResponseDTORecordResponseDTO](#apiresponsedtorecordresponsedto) |
+| `DELETE` | `/zones/{zone_id}/dns_records/{dns_record_id}` | Remove Record by ID | — | 200: [ApiResponseDTODeleteResponseWithId](#apiresponsedtodeleteresponsewithid) |
+
+### Schemas
+
+#### ApiResponseDTO
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `success` | boolean | No | — |
+| `errors` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `messages` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `result` | — | No | — |
+
+#### ApiResponseDTODeleteResponseWithId
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `success` | boolean | No | — |
+| `errors` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `messages` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `result` | [DeleteResponseWithId](#deleteresponsewithid) or null | No | — |
+
+#### ApiResponseDTODomainResponseDTO
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `success` | boolean | No | — |
+| `errors` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `messages` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `result` | [DomainResponseDTO](#domainresponsedto) or null | No | — |
+
+#### ApiResponseDTOListDomainResponseDTO
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `success` | boolean | No | — |
+| `errors` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `messages` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `result` | array of [DomainResponseDTO](#domainresponsedto) or null | No | — |
+
+#### ApiResponseDTOListRecordResponseDTO
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `success` | boolean | No | — |
+| `errors` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `messages` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `result` | array of [RecordResponseDTO](#recordresponsedto) or null | No | — |
+
+#### ApiResponseDTORecordResponseDTO
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `success` | boolean | No | — |
+| `errors` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `messages` | array of [ErrorOrMessage](#errorormessage) | No | — |
+| `result` | [RecordResponseDTO](#recordresponsedto) or null | No | — |
+
+#### DeleteResponseWithId
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `id` | string | No | — |
+
+#### DomainRequestDTO
+
+Request DTO for Domain
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `name` | string | No | Domain name(TLD) |
+
+#### DomainResponseDTO
+
+Response DTO for Domain
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `id` | string | No | Domain CFID |
+| `name` | string | No | Domain name(TLD) |
+
+#### ErrorOrMessage
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `code` | integer | No | — |
+| `message` | string | No | — |
+
+#### RecordRequestDTO
+
+Request DTO for Record
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `type` | string | No | Record type |
+| `name` | string | No | Host name |
+| `value` | string | No | Record data |
+| `ttl` | integer | No | TTL (Time to Live) |
+| `priority` | integer or null | No | Priority |
+| `proxied` | boolean | No | Proxied: cloudflare api compatibility |
+| `comment` | string | No | comment |
+
+#### RecordResponseDTO
+
+Response DTO for Record
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `id` | string | No | Record ID |
+| `type` | string | No | Record type |
+| `name` | string | No | Record name |
+| `content` | string | No | Record content |
+| `zoneId` | string | No | Zone(TLD) ID |
+| `zoneName` | string | No | Zone name(TLD) |
+| `createdOn` | string | No | Record creation time |
+| `modifiedOn` | string | No | Record modification time |
+| `priority` | integer or null | No | Record priority |
+| `proxiable` | boolean | No | is proxyable: must false |
+| `proxied` | boolean | No | is proxied: must false |
+| `ttl` | integer | No | Record TTL |
+| `locked` | boolean | No | Record is locked: must false |
+| `comment` | string or null | No | Record comments |
+
diff --git a/build.gradle.kts b/build.gradle.kts
index fe03fe7..61dbbc3 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,26 +1,34 @@
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
plugins {
id("java")
- id("org.springframework.boot") version "3.3.0"
- id("io.spring.dependency-management") version "1.1.5"
- id("org.hibernate.orm") version "6.5.2.Final"
- id("org.graalvm.buildtools.native") version "0.10.2"
- kotlin("jvm") version "1.9.24"
- kotlin("plugin.spring") version "1.9.24"
- kotlin("plugin.jpa") version "1.9.24"
+ id("org.springframework.boot") version "4.1.2"
+ id("io.spring.dependency-management") version "1.1.7"
+ id("org.hibernate.orm") version "7.1.4.Final"
+ id("org.graalvm.buildtools.native") version "1.1.17"
+ kotlin("jvm") version "2.4.21"
+ kotlin("plugin.spring") version "2.4.21"
+ kotlin("plugin.jpa") version "2.4.21"
}
val datetimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm")
+val jvmVersion = 25
group = "space.mori"
-version = "0.0.1"
+version = "1.0.0"
java {
- sourceCompatibility = JavaVersion.VERSION_21
+ toolchain.languageVersion.set(JavaLanguageVersion.of(jvmVersion))
+}
+
+kotlin {
+ compilerOptions {
+ freeCompilerArgs.add("-Xjsr305=strict")
+ jvmTarget.set(JvmTarget.fromTarget(jvmVersion.toString()))
+ }
}
repositories {
@@ -31,40 +39,64 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
- implementation("org.springframework.boot:spring-boot-starter-web")
- implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
+ implementation("org.springframework.boot:spring-boot-starter-webmvc")
+ implementation("tools.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
- implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.2.0")
// https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-bindings
- implementation("org.springframework.cloud:spring-cloud-bindings:2.0.3")
+ implementation("org.springframework.cloud:spring-cloud-bindings:2.0.4")
- implementation("com.google.code.gson:gson:2.11.0")
- implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
+ implementation("com.google.code.gson:gson:2.15.0")
+ implementation("io.github.cdimascio:dotenv-kotlin:6.5.1")
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:okhttp:5.5.1")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
+ testImplementation("com.squareup.okhttp3:mockwebserver3:5.5.1")
+ testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
-tasks.withType {
- kotlinOptions {
- freeCompilerArgs += "-Xjsr305=strict"
- jvmTarget = "21"
- }
-}
-
tasks.withType {
useJUnitPlatform()
}
+tasks.named("test") {
+ useJUnitPlatform { excludeTags("openapi-generation") }
+}
+
+tasks.register("generateOpenApi") {
+ group = "documentation"
+ description = "Generate the root openapi.json from the running Spring application"
+ dependsOn(tasks.testClasses)
+ testClassesDirs = sourceSets.test.get().output.classesDirs
+ classpath = sourceSets.test.get().runtimeClasspath
+ useJUnitPlatform { includeTags("openapi-generation") }
+ val openApiFile = layout.projectDirectory.file("openapi.json")
+ outputs.file(openApiFile)
+ systemProperty("openapi.output", openApiFile.asFile.absolutePath)
+}
+
+tasks.register("updateReadme") {
+ group = "documentation"
+ description = "Update README.md from the generated OpenAPI document"
+ dependsOn("generateOpenApi")
+ commandLine("python3", "scripts/update_readme.py")
+}
+
+tasks.register("updateDocs") {
+ group = "documentation"
+ description = "Refresh openapi.json and README.md"
+ dependsOn("updateReadme")
+}
+
tasks.withType {
- val dockerId = System.getenv("DOCKER_USERNAME") ?: "git.mori.space/dalbodeule"
+ val dockerId = System.getenv("DOCKER_USERNAME") ?: "dalbodeule"
val dockerPw = System.getenv("DOCKER_PASSWORD") ?: ""
docker {
@@ -83,14 +115,12 @@ tasks.withType {
"BP_NATIVE_IMAGE" to "true",
"BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to "-H:+UnlockExperimentalVMOptions",
"BP_JVM_TYPE" to "JDK",
- "BP_JVM_VERSION" to "21",
+ "BP_JVM_VERSION" to jvmVersion.toString(),
)
}
hibernate {
- enhancement {
- enableAssociationManagement.set(true)
- }
+ enhancement {}
}
tasks.named("bootRun") {
@@ -101,4 +131,4 @@ graalvmNative {
binaries.all {
buildArgs.add("--initialize-at-build-time=org.slf4j.helpers")
}
-}
\ No newline at end of file
+}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index a441313..2a66ec2 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/openapi.json b/openapi.json
index e8a5198..664d402 100644
--- a/openapi.json
+++ b/openapi.json
@@ -1,5 +1,5 @@
{
- "openapi": "3.0.1",
+ "openapi": "3.1.0",
"info": {
"title": "Cloudflare compatible PowerDNS API Server",
"version": "v1.0.0"
@@ -16,6 +16,159 @@
}
],
"paths": {
+ "/zones/{zone_id}/dns_records/{dns_record_id}": {
+ "get": {
+ "tags": [
+ "record"
+ ],
+ "summary": "Get Record by ID",
+ "operationId": "getRecordByCfid",
+ "parameters": [
+ {
+ "name": "zone_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "dns_record_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Return Record",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseDTO"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "record"
+ ],
+ "summary": "Update Record by ID",
+ "operationId": "updateRecord",
+ "parameters": [
+ {
+ "name": "zone_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "dns_record_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RecordRequestDTO"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Return Record",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseDTO"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "record"
+ ],
+ "summary": "Remove Record by ID",
+ "operationId": "deleteRecord",
+ "parameters": [
+ {
+ "name": "zone_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "dns_record_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Return Record",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseDTODeleteResponseWithId"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseDTO"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/zones": {
"get": {
"tags": [
@@ -176,159 +329,6 @@
}
}
},
- "/zones/{zone_id}/dns_records/{dns_record_id}": {
- "get": {
- "tags": [
- "record"
- ],
- "summary": "Get Record by ID",
- "operationId": "getRecordByCfid",
- "parameters": [
- {
- "name": "zone_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "dns_record_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Return Record",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiResponseDTO"
- }
- }
- }
- }
- }
- },
- "delete": {
- "tags": [
- "record"
- ],
- "summary": "Remove Record by ID",
- "operationId": "deleteRecord",
- "parameters": [
- {
- "name": "zone_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "dns_record_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Return Record",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiResponseDTODeleteResponseWithId"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiResponseDTO"
- }
- }
- }
- }
- }
- },
- "put": {
- "tags": [
- "record"
- ],
- "summary": "Update Record by ID",
- "operationId": "updateRecord",
- "parameters": [
- {
- "name": "zone_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "dns_record_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/RecordRequestDTO"
- }
- }
- },
- "required": true
- },
- "responses": {
- "200": {
- "description": "Return Record",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiResponseDTORecordResponseDTO"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ApiResponseDTO"
- }
- }
- }
- }
- }
- }
- },
"/zones/{cfid}": {
"get": {
"tags": [
@@ -414,122 +414,9 @@
},
"components": {
"schemas": {
- "DomainRequestDTO": {
- "required": [
- "name"
- ],
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "description": "Domain name(TLD)",
- "example": "example.com"
- }
- },
- "description": "Request DTO for Domain"
- },
- "ApiResponseDTO": {
- "required": [
- "errors",
- "messages",
- "success"
- ],
- "type": "object",
- "properties": {
- "success": {
- "type": "boolean"
- },
- "errors": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ErrorOrMessage"
- }
- },
- "messages": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ErrorOrMessage"
- }
- },
- "result": {
- "type": "object"
- }
- }
- },
- "ErrorOrMessage": {
- "required": [
- "code",
- "message"
- ],
- "type": "object",
- "properties": {
- "code": {
- "type": "integer",
- "format": "int32"
- },
- "message": {
- "type": "string"
- }
- }
- },
- "ApiResponseDTODomainResponseDTO": {
- "required": [
- "errors",
- "messages",
- "success"
- ],
- "type": "object",
- "properties": {
- "success": {
- "type": "boolean"
- },
- "errors": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ErrorOrMessage"
- }
- },
- "messages": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ErrorOrMessage"
- }
- },
- "result": {
- "$ref": "#/components/schemas/DomainResponseDTO"
- }
- }
- },
- "DomainResponseDTO": {
- "required": [
- "id",
- "name"
- ],
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "description": "Domain CFID",
- "example": "123e4567e89b12d3a456426655440000"
- },
- "name": {
- "type": "string",
- "description": "Domain name(TLD)",
- "example": "example.com"
- }
- },
- "description": "Response DTO for Domain"
- },
"RecordRequestDTO": {
- "required": [
- "comment",
- "content",
- "name",
- "proxied",
- "ttl",
- "type"
- ],
"type": "object",
+ "description": "Request DTO for Record",
"properties": {
"type": {
"type": "string",
@@ -541,21 +428,24 @@
"description": "Host name",
"example": "www"
},
- "content": {
+ "value": {
"type": "string",
"description": "Record data",
"example": "192.0.2.1"
},
"ttl": {
"type": "integer",
- "description": "TTL (Time to Live)",
"format": "int32",
+ "description": "TTL (Time to Live)",
"example": 3600
},
"priority": {
- "type": "integer",
- "description": "Priority",
+ "type": [
+ "integer",
+ "null"
+ ],
"format": "int32",
+ "description": "Priority",
"example": 0
},
"proxied": {
@@ -567,15 +457,42 @@
"type": "string",
"description": "comment"
}
- },
- "description": "Request DTO for Record"
+ }
+ },
+ "ApiResponseDTO": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "errors": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ErrorOrMessage"
+ }
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ErrorOrMessage"
+ }
+ },
+ "result": {}
+ }
+ },
+ "ErrorOrMessage": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
},
"ApiResponseDTORecordResponseDTO": {
- "required": [
- "errors",
- "messages",
- "success"
- ],
"type": "object",
"properties": {
"success": {
@@ -594,26 +511,20 @@
}
},
"result": {
- "$ref": "#/components/schemas/RecordResponseDTO"
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/RecordResponseDTO"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
}
},
"RecordResponseDTO": {
- "required": [
- "content",
- "createdOn",
- "id",
- "locked",
- "modifiedOn",
- "name",
- "proxiable",
- "proxied",
- "ttl",
- "type",
- "zoneId",
- "zoneName"
- ],
"type": "object",
+ "description": "Response DTO for Record",
"properties": {
"id": {
"type": "string",
@@ -656,9 +567,12 @@
"example": "2014-01-01T05:20:00.12345Z"
},
"priority": {
- "type": "integer",
- "description": "Record priority",
+ "type": [
+ "integer",
+ "null"
+ ],
"format": "int32",
+ "description": "Record priority",
"example": 0
},
"proxiable": {
@@ -673,8 +587,8 @@
},
"ttl": {
"type": "integer",
- "description": "Record TTL",
"format": "int32",
+ "description": "Record TTL",
"example": 300
},
"locked": {
@@ -683,18 +597,26 @@
"example": false
},
"comment": {
- "type": "string",
+ "type": [
+ "string",
+ "null"
+ ],
"description": "Record comments"
}
- },
- "description": "Response DTO for Record"
+ }
},
- "ApiResponseDTOListDomainResponseDTO": {
- "required": [
- "errors",
- "messages",
- "success"
- ],
+ "DomainRequestDTO": {
+ "type": "object",
+ "description": "Request DTO for Domain",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Domain name(TLD)",
+ "example": "example.com"
+ }
+ }
+ },
+ "ApiResponseDTODomainResponseDTO": {
"type": "object",
"properties": {
"success": {
@@ -713,7 +635,56 @@
}
},
"result": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/DomainResponseDTO"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ },
+ "DomainResponseDTO": {
+ "type": "object",
+ "description": "Response DTO for Domain",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Domain CFID",
+ "example": "123e4567e89b12d3a456426655440000"
+ },
+ "name": {
+ "type": "string",
+ "description": "Domain name(TLD)",
+ "example": "example.com"
+ }
+ }
+ },
+ "ApiResponseDTOListDomainResponseDTO": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "errors": {
"type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ErrorOrMessage"
+ }
+ },
+ "messages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ErrorOrMessage"
+ }
+ },
+ "result": {
+ "type": [
+ "array",
+ "null"
+ ],
"items": {
"$ref": "#/components/schemas/DomainResponseDTO"
}
@@ -721,11 +692,6 @@
}
},
"ApiResponseDTOListRecordResponseDTO": {
- "required": [
- "errors",
- "messages",
- "success"
- ],
"type": "object",
"properties": {
"success": {
@@ -744,7 +710,10 @@
}
},
"result": {
- "type": "array",
+ "type": [
+ "array",
+ "null"
+ ],
"items": {
"$ref": "#/components/schemas/RecordResponseDTO"
}
@@ -752,11 +721,6 @@
}
},
"ApiResponseDTODeleteResponseWithId": {
- "required": [
- "errors",
- "messages",
- "success"
- ],
"type": "object",
"properties": {
"success": {
@@ -775,14 +739,18 @@
}
},
"result": {
- "$ref": "#/components/schemas/DeleteResponseWithId"
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/DeleteResponseWithId"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
}
},
"DeleteResponseWithId": {
- "required": [
- "id"
- ],
"type": "object",
"properties": {
"id": {
@@ -794,8 +762,9 @@
"securitySchemes": {
"api token": {
"type": "http",
+ "name": "api token",
"scheme": "bearer"
}
}
}
-}
\ No newline at end of file
+}
diff --git a/scripts/update_readme.py b/scripts/update_readme.py
new file mode 100644
index 0000000..8f4c593
--- /dev/null
+++ b/scripts/update_readme.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Refresh the generated README section from the root OpenAPI document."""
+
+import json
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+START = ""
+END = ""
+METHODS = ("get", "post", "put", "patch", "delete")
+
+
+def schema_type(schema):
+ if not schema:
+ return "—"
+ if "$ref" in schema:
+ name = schema["$ref"].rsplit("/", 1)[-1]
+ return f"[{name}](#{name.lower()})"
+ if "oneOf" in schema:
+ return " or ".join(schema_type(part) for part in schema["oneOf"])
+ kind = schema.get("type", "object")
+ if isinstance(kind, list):
+ return " or ".join(
+ f"array of {schema_type(schema.get('items'))}" if part == "array" else part
+ for part in kind
+ )
+ if kind == "array":
+ return f"array of {schema_type(schema.get('items'))}"
+ return kind
+
+
+def media_schema(content):
+ return schema_type(content.get("application/json", {}).get("schema"))
+
+
+def cell(value):
+ return str(value or "—").replace("|", "\\|").replace("\n", " ")
+
+
+def generate(spec):
+ lines = [
+ "Generated from [openapi.json](openapi.json). Do not edit this section by hand.",
+ "",
+ "### Endpoints",
+ "",
+ "| Method | Path | Summary | Request body | Success response |",
+ "| --- | --- | --- | --- | --- |",
+ ]
+
+ for path, operations in sorted(spec["paths"].items()):
+ for method in METHODS:
+ operation = operations.get(method)
+ if not operation:
+ continue
+ request = media_schema(operation.get("requestBody", {}).get("content", {}))
+ responses = operation.get("responses", {})
+ success_code = next((code for code in sorted(responses) if code.startswith("2")), None)
+ response = responses.get(success_code, {})
+ result = media_schema(response.get("content", {}))
+ lines.append(
+ f"| `{method.upper()}` | `{path}` | {cell(operation.get('summary'))} | "
+ f"{cell(request)} | {cell(success_code)}: {cell(result)} |"
+ )
+
+ lines.extend(["", "### Schemas", ""])
+ for name, schema in sorted(spec.get("components", {}).get("schemas", {}).items()):
+ lines.extend([f"#### {name}", ""])
+ if schema.get("description"):
+ lines.extend([schema["description"], ""])
+ properties = schema.get("properties", {})
+ if not properties:
+ lines.extend([f"Type: {schema_type(schema)}", ""])
+ continue
+ required = set(schema.get("required", []))
+ lines.extend(["| Field | Type | Required | Description |", "| --- | --- | --- | --- |"])
+ for field, definition in properties.items():
+ lines.append(
+ f"| `{field}` | {cell(schema_type(definition))} | "
+ f"{'Yes' if field in required else 'No'} | {cell(definition.get('description'))} |"
+ )
+ lines.append("")
+
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def main():
+ spec = json.loads((ROOT / "openapi.json").read_text(encoding="utf-8"))
+ readme_path = ROOT / "README.md"
+ readme = readme_path.read_text(encoding="utf-8")
+ if readme.count(START) != 1 or readme.count(END) != 1:
+ raise ValueError("README.md must contain one pair of generated API markers")
+ before, remainder = readme.split(START, 1)
+ _, after = remainder.split(END, 1)
+ readme_path.write_text(before + START + "\n" + generate(spec) + END + after, encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/main/kotlin/space/mori/dnsapi/PowerDNSAPIClient.kt b/src/main/kotlin/space/mori/dnsapi/PowerDNSAPIClient.kt
index c2c84fe..9f1c51b 100644
--- a/src/main/kotlin/space/mori/dnsapi/PowerDNSAPIClient.kt
+++ b/src/main/kotlin/space/mori/dnsapi/PowerDNSAPIClient.kt
@@ -6,10 +6,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
-import okhttp3.Response
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.lang.reflect.Type
+import java.util.concurrent.TimeUnit
@Service
@@ -21,40 +21,46 @@ class PowerDNSAPIClient() {
private lateinit var apiKey: String
private val gson = Gson()
- private val client = OkHttpClient()
+ private val client = OkHttpClient.Builder()
+ .callTimeout(30, TimeUnit.SECONDS)
+ .build()
- @Throws(PowerDNSAPIException::class)
- fun createZone(zoneName: String): Response {
- try {
- val body = gson.toJson(
- mapOf(
- "name" to "$zoneName.",
- "kind" to "Primary"
- )
- ).toRequestBody()
- val request = Request.Builder()
- .url("$apiUrl/api/v1/servers/localhost/zones")
- .addHeader("X-API-Key", apiKey)
- .addHeader("Accept", "application/json")
- .addHeader("Content-Type", "application/json")
- .post(body)
- .build()
+ private fun execute(request: Request) {
+ client.newCall(request).execute().use { response ->
+ if (response.isSuccessful) return
- val response = client.newCall(request).execute()
- if (!response.isSuccessful) {
- val str = response.body?.string()
- val error = gson.fromJson(str, PowerDNSAPIError::class.java)
- throw PowerDNSAPIException(error)
- }
- return response
- } catch(ex: Exception) {
- println(ex)
- throw ex
+ val error = runCatching {
+ gson.fromJson(response.peekBody(64 * 1024L).string(), PowerDNSAPIError::class.java)
+ }.getOrNull()
+ throw PowerDNSAPIException(
+ if (error?.error.isNullOrBlank())
+ PowerDNSAPIError("PowerDNS API returned HTTP ${response.code}", null)
+ else error
+ )
}
}
@Throws(PowerDNSAPIException::class)
- fun deleteZone(zoneName: String): Response {
+ fun createZone(zoneName: String) {
+ val body = gson.toJson(
+ mapOf(
+ "name" to "$zoneName.",
+ "kind" to "Primary"
+ )
+ ).toRequestBody()
+ val request = Request.Builder()
+ .url("$apiUrl/api/v1/servers/localhost/zones")
+ .addHeader("X-API-Key", apiKey)
+ .addHeader("Accept", "application/json")
+ .addHeader("Content-Type", "application/json")
+ .post(body)
+ .build()
+
+ execute(request)
+ }
+
+ @Throws(PowerDNSAPIException::class)
+ fun deleteZone(zoneName: String) {
val request = Request.Builder()
.url("$apiUrl/api/v1/servers/localhost/zones/$zoneName.")
.addHeader("X-API-Key", apiKey)
@@ -63,17 +69,11 @@ class PowerDNSAPIClient() {
.delete()
.build()
- val response = client.newCall(request).execute()
- if(!response.isSuccessful) {
- val str = response.body?.string()
- val error = gson.fromJson(str, PowerDNSAPIError::class.java)
- throw PowerDNSAPIException(error)
- }
- return response
+ execute(request)
}
@Throws(PowerDNSAPIException::class)
- fun createRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0): Response {
+ fun createRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0) {
val rrset = mapOf(
"name" to recordName,
"type" to recordType,
@@ -95,17 +95,11 @@ class PowerDNSAPIClient() {
.patch(body)
.build()
- val response = client.newCall(request).execute()
- if(!response.isSuccessful) {
- val str = response.body?.string()
- val error = gson.fromJson(str, PowerDNSAPIError::class.java)
- throw PowerDNSAPIException(error)
- }
- return response
+ execute(request)
}
@Throws(PowerDNSAPIException::class)
- fun updateRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0): Response {
+ fun updateRecord(zoneName: String, recordName: String, recordType: String, recordContent: String, ttl: Int = 300, priority: Int = 0) {
val rrset = mapOf(
"name" to recordName,
"type" to recordType,
@@ -127,17 +121,11 @@ class PowerDNSAPIClient() {
.patch(body)
.build()
- val response = client.newCall(request).execute()
- if(!response.isSuccessful) {
- val str = response.body?.string()
- val error = gson.fromJson(str, PowerDNSAPIError::class.java)
- throw PowerDNSAPIException(error)
- }
- return response
+ execute(request)
}
@Throws(PowerDNSAPIException::class)
- fun deleteRecord(zoneName: String, recordName: String, recordType: String): Response {
+ fun deleteRecord(zoneName: String, recordName: String, recordType: String) {
val rrset = mapOf(
"name" to recordName,
"type" to recordType,
@@ -153,13 +141,7 @@ class PowerDNSAPIClient() {
.patch(body)
.build()
- val response = client.newCall(request).execute()
- if(!response.isSuccessful) {
- val str = response.body?.string()
- val error = gson.fromJson(str, PowerDNSAPIError::class.java)
- throw PowerDNSAPIException(error)
- }
- return response
+ execute(request)
}
}
@@ -192,4 +174,4 @@ class PowerDNSAPIException(private val error: PowerDNSAPIError): RuntimeExceptio
get() = error.errors
override fun toString(): String = "PowerDNSAPIException(${error.error} ${errors?.joinToString(", ") ?: ""})"
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/space/mori/dnsapi/controller/RecordController.kt b/src/main/kotlin/space/mori/dnsapi/controller/RecordController.kt
index cc24316..bb57e59 100644
--- a/src/main/kotlin/space/mori/dnsapi/controller/RecordController.kt
+++ b/src/main/kotlin/space/mori/dnsapi/controller/RecordController.kt
@@ -24,7 +24,7 @@ class RecordController(
content = [Content(schema = Schema(implementation = ApiResponseDTO::class))]),
])
fun allRecords(@PathVariable zone_id: String): ApiResponseDTO> {
- return ApiResponseDTO(result = recordService.getRecordsByDomain(zone_id)?.map{ it } ?: listOf())
+ return ApiResponseDTO(result = recordService.getRecordsByDomain(zone_id))
}
@GetMapping("{zone_id}/dns_records/{dns_record_id}")
@@ -71,4 +71,4 @@ class RecordController(
fun updateRecord(@PathVariable zone_id: String, @PathVariable dns_record_id: String, @RequestBody record: RecordRequestDTO): ApiResponseDTO {
return ApiResponseDTO(result = recordService.updateRecord(zone_id, dns_record_id, record))
}
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/space/mori/dnsapi/db/DomainRepository.kt b/src/main/kotlin/space/mori/dnsapi/db/DomainRepository.kt
index 5f53360..3346c2d 100644
--- a/src/main/kotlin/space/mori/dnsapi/db/DomainRepository.kt
+++ b/src/main/kotlin/space/mori/dnsapi/db/DomainRepository.kt
@@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.*
-interface DomainRepository : JpaRepository {
+interface DomainRepository : JpaRepository {
@Transactional
fun findByCfid(cfid: String): Optional
@@ -14,4 +14,4 @@ interface DomainRepository : JpaRepository {
@Transactional
fun deleteByCfid(cfid: String): Int
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/space/mori/dnsapi/db/RecordRepository.kt b/src/main/kotlin/space/mori/dnsapi/db/RecordRepository.kt
index 012c705..57486d8 100644
--- a/src/main/kotlin/space/mori/dnsapi/db/RecordRepository.kt
+++ b/src/main/kotlin/space/mori/dnsapi/db/RecordRepository.kt
@@ -5,16 +5,18 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.*
-interface RecordRepository : JpaRepository {
+interface RecordRepository : JpaRepository {
@Transactional
fun findByCfid(cfid: String): Optional
@Transactional
fun findByDomainIdAndCfid(domainId: Long, cfid: String): Optional
+ fun findAllByDomainId(domainId: Long): List
+
@Transactional
fun deleteByCfid(cfid: String): Int
@Transactional
fun deleteByDomainIdAndCfid(domain_id: Long, cfid: String): Int
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/space/mori/dnsapi/db/User.kt b/src/main/kotlin/space/mori/dnsapi/db/User.kt
index 8cf8209..73d30f7 100644
--- a/src/main/kotlin/space/mori/dnsapi/db/User.kt
+++ b/src/main/kotlin/space/mori/dnsapi/db/User.kt
@@ -15,8 +15,8 @@ data class User(
@Column(nullable = false, unique = false, length = 20)
val name: String,
- @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
+ @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
val domains: List = mutableListOf(),
) {
override fun toString(): String = "User(id=$id, name='$name')"
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/space/mori/dnsapi/filter/UserFilter.kt b/src/main/kotlin/space/mori/dnsapi/filter/UserFilter.kt
index dd3b1ab..4c892aa 100644
--- a/src/main/kotlin/space/mori/dnsapi/filter/UserFilter.kt
+++ b/src/main/kotlin/space/mori/dnsapi/filter/UserFilter.kt
@@ -35,4 +35,5 @@ class UserFilter(
}
fun getCurrentUser(): User =
- SecurityContextHolder.getContext().authentication.principal as User
\ No newline at end of file
+ SecurityContextHolder.getContext().authentication?.principal as? User
+ ?: throw IllegalStateException("Authenticated user is required")
diff --git a/src/main/kotlin/space/mori/dnsapi/service/DomainService.kt b/src/main/kotlin/space/mori/dnsapi/service/DomainService.kt
index c2d663c..16c1075 100644
--- a/src/main/kotlin/space/mori/dnsapi/service/DomainService.kt
+++ b/src/main/kotlin/space/mori/dnsapi/service/DomainService.kt
@@ -20,7 +20,6 @@ class DomainService(
try {
val user = getCurrentUser()
val domain = domainRepository.findAllByUser(user)
- if (domain.isEmpty()) throw RuntimeException("Unauthorized")
return domain
} catch(ex: Error) {
@@ -62,14 +61,17 @@ class DomainService(
val domain = domainRepository.findByCfid(domain_id).orElseThrow {
throw RuntimeException("Domain with CFID $domain_id not found")
}
+ val user = getCurrentUser()
+ if (domain.user.id != user.id)
+ throw RuntimeException("Unauthorized to delete domain in API: $domain_id")
powerDNSApiClient.deleteZone(domain.name)
val count = domainRepository.deleteByCfid(domain_id)
- if (count > 0) throw RuntimeException("Domain with CFID $domain_id not found")
+ if (count == 0) throw RuntimeException("Domain with CFID $domain_id not found")
return domain_id
} catch(ex: Error) {
throw ex
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/space/mori/dnsapi/service/RecordService.kt b/src/main/kotlin/space/mori/dnsapi/service/RecordService.kt
index 3519797..9712048 100644
--- a/src/main/kotlin/space/mori/dnsapi/service/RecordService.kt
+++ b/src/main/kotlin/space/mori/dnsapi/service/RecordService.kt
@@ -64,7 +64,7 @@ class RecordService(
proxied = false,
ttl = record.ttl,
locked = false,
- zoneId = record.domain.cfid,
+ zoneId = domain.cfid,
zoneName = domain.name,
createdOn = record.createdOn.getISOFormat(),
modifiedOn = record.modifiedOn.getISOFormat(),
@@ -82,8 +82,9 @@ class RecordService(
if(domain.user.id != user.id)
throw RuntimeException("Unauthorized to get record in API: $domain_id")
- val record = domain.records.find { it.cfid == record_id }
- if(record == null) throw RuntimeException("Failed to find record in API: $record_id")
+ val record = recordRepository.findByDomainIdAndCfid(domain.id!!, record_id).orElseThrow {
+ RuntimeException("Failed to find record in API: $record_id")
+ }
return RecordResponseDTO(
id = record.cfid,
@@ -91,15 +92,15 @@ class RecordService(
name = record.name,
content = record.content,
ttl = record.ttl,
- zoneId = record.domain.cfid,
- zoneName = record.domain.name,
+ zoneId = domain.cfid,
+ zoneName = domain.name,
createdOn = record.createdOn.getISOFormat(),
modifiedOn = record.modifiedOn.getISOFormat(),
comment = record.comment,
)
}
- fun getRecordsByDomain(domain_id: String): List? {
+ fun getRecordsByDomain(domain_id: String): List {
val domain = domainRepository.findByCfid(domain_id).orElseThrow {
RuntimeException("Failed to find domain in API: $domain_id")
}
@@ -108,13 +109,13 @@ class RecordService(
if(domain.user.id != user.id)
throw RuntimeException("Unauthorized to create record in API: $domain_id")
- return domain?.records?.map { RecordResponseDTO(
+ return recordRepository.findAllByDomainId(domain.id!!).map { RecordResponseDTO(
id = it.cfid,
type = it.type,
name = it.name,
content = it.content,
- zoneId = it.domain.cfid,
- zoneName = it.domain.name,
+ zoneId = domain.cfid,
+ zoneName = domain.name,
priority = it.prio,
ttl = it.ttl,
createdOn = it.createdOn.getISOFormat(),
@@ -153,7 +154,7 @@ class RecordService(
else -> record.ttl
}
- powerDNSApiClient.updateRecord(domain!!.name, updatedRecord.name, updatedRecord.type, updatedRecord.value)
+ powerDNSApiClient.updateRecord(domain.name, updatedRecord.name, updatedRecord.type, updatedRecord.value, record.ttl, record.prio)
// 저장
val savedRecord = recordRepository.save(record)
@@ -192,4 +193,4 @@ class RecordService(
return record_id
}
-}
\ No newline at end of file
+}
diff --git a/src/test/kotlin/space/mori/dnsapi/DnsapiApplicationTests.kt b/src/test/kotlin/space/mori/dnsapi/DnsapiApplicationTests.kt
index 29b85f8..283e17b 100644
--- a/src/test/kotlin/space/mori/dnsapi/DnsapiApplicationTests.kt
+++ b/src/test/kotlin/space/mori/dnsapi/DnsapiApplicationTests.kt
@@ -2,8 +2,10 @@ package space.mori.dnsapi
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
+import org.springframework.test.context.ActiveProfiles
@SpringBootTest
+@ActiveProfiles("test")
class DnsapiApplicationTests {
@Test
diff --git a/src/test/kotlin/space/mori/dnsapi/OpenApiGenerationTest.kt b/src/test/kotlin/space/mori/dnsapi/OpenApiGenerationTest.kt
new file mode 100644
index 0000000..ba1f899
--- /dev/null
+++ b/src/test/kotlin/space/mori/dnsapi/OpenApiGenerationTest.kt
@@ -0,0 +1,36 @@
+package space.mori.dnsapi
+
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonParser
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.context.SpringBootTest
+import org.springframework.test.context.ActiveProfiles
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
+import org.springframework.test.web.servlet.setup.MockMvcBuilders
+import org.springframework.web.context.WebApplicationContext
+import java.nio.file.Files
+import java.nio.file.Path
+
+@Tag("openapi-generation")
+@SpringBootTest(properties = ["springdoc.api-docs.enabled=true"])
+@ActiveProfiles("test")
+class OpenApiGenerationTest(@Autowired private val context: WebApplicationContext) {
+ @Test
+ fun generateOpenApi() {
+ val response = MockMvcBuilders.webAppContextSetup(context).build()
+ .perform(get("/api-docs").with { request ->
+ request.serverName = "localhost"
+ request.serverPort = 8080
+ request
+ })
+ .andReturn().response
+ check(response.status == 200) { "OpenAPI endpoint returned HTTP ${response.status}: ${response.contentAsString}" }
+
+ val output = Path.of(System.getProperty("openapi.output"))
+ val json = GsonBuilder().setPrettyPrinting().create()
+ .toJson(JsonParser.parseString(response.contentAsString))
+ Files.writeString(output, json + "\n")
+ }
+}
diff --git a/src/test/kotlin/space/mori/dnsapi/PowerDNSAPIClientTest.kt b/src/test/kotlin/space/mori/dnsapi/PowerDNSAPIClientTest.kt
new file mode 100644
index 0000000..7a56161
--- /dev/null
+++ b/src/test/kotlin/space/mori/dnsapi/PowerDNSAPIClientTest.kt
@@ -0,0 +1,52 @@
+package space.mori.dnsapi
+
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import org.springframework.test.util.ReflectionTestUtils
+
+class PowerDNSAPIClientTest {
+ @Test
+ fun successfulCallsUseTheExpectedHttpMethods() {
+ MockWebServer().use { server ->
+ server.start()
+ repeat(5) { server.enqueue(MockResponse.Builder().body("ok").build()) }
+ val client = clientFor(server)
+
+ client.createZone("example.com")
+ client.deleteZone("example.com")
+ client.createRecord("example.com", "www.example.com.", "A", "192.0.2.1")
+ client.updateRecord("example.com", "www.example.com.", "A", "192.0.2.2")
+ client.deleteRecord("example.com", "www.example.com.", "A")
+
+ assertEquals("POST", server.takeRequest().method)
+ assertEquals("DELETE", server.takeRequest().method)
+ repeat(3) { assertEquals("PATCH", server.takeRequest().method) }
+ }
+ }
+
+ @Test
+ fun errorResponsesKeepThePowerDnsMessageAndHandleInvalidJson() {
+ MockWebServer().use { server ->
+ server.start()
+ server.enqueue(MockResponse.Builder().code(400)
+ .body("{\"error\":\"invalid zone\",\"errors\":[\"bad name\"]}").build())
+ server.enqueue(MockResponse.Builder().code(500).body("not json").build())
+ val client = clientFor(server)
+
+ val error = assertThrows(PowerDNSAPIException::class.java) { client.deleteZone("example.com") }
+ assertEquals("invalid zone", error.message)
+ assertEquals(listOf("bad name"), error.errors)
+
+ val fallback = assertThrows(PowerDNSAPIException::class.java) { client.deleteZone("example.com") }
+ assertEquals("PowerDNS API returned HTTP 500", fallback.message)
+ }
+ }
+
+ private fun clientFor(server: MockWebServer): PowerDNSAPIClient = PowerDNSAPIClient().also {
+ ReflectionTestUtils.setField(it, "apiUrl", server.url("/").toString().removeSuffix("/"))
+ ReflectionTestUtils.setField(it, "apiKey", "test-key")
+ }
+}
diff --git a/src/test/kotlin/space/mori/dnsapi/service/DomainServiceTest.kt b/src/test/kotlin/space/mori/dnsapi/service/DomainServiceTest.kt
new file mode 100644
index 0000000..cc13ff8
--- /dev/null
+++ b/src/test/kotlin/space/mori/dnsapi/service/DomainServiceTest.kt
@@ -0,0 +1,64 @@
+package space.mori.dnsapi.service
+
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import org.mockito.Mockito
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.core.context.SecurityContextHolder
+import space.mori.dnsapi.PowerDNSAPIClient
+import space.mori.dnsapi.db.Domain
+import space.mori.dnsapi.db.DomainRepository
+import space.mori.dnsapi.db.User
+import java.util.Optional
+
+class DomainServiceTest {
+ private val repository = Mockito.mock(DomainRepository::class.java)
+ private val powerDns = Mockito.mock(PowerDNSAPIClient::class.java)
+ private val service = DomainService(repository, powerDns)
+
+ @AfterEach
+ fun clearSecurityContext() {
+ SecurityContextHolder.clearContext()
+ }
+
+ @Test
+ fun rejectsDeletingAnotherUsersDomainBeforeCallingPowerDns() {
+ val owner = User(id = 1, apiKey = "owner", name = "owner")
+ val otherUser = User(id = 2, apiKey = "other", name = "other")
+ val domain = Domain(name = "example.com", user = owner)
+ Mockito.`when`(repository.findByCfid(domain.cfid)).thenReturn(Optional.of(domain))
+ authenticate(otherUser)
+
+ assertThrows(RuntimeException::class.java) { service.deleteDomain(domain.cfid) }
+ Mockito.verifyNoInteractions(powerDns)
+ Mockito.verify(repository, Mockito.never()).deleteByCfid(domain.cfid)
+ }
+
+ @Test
+ fun deletingAnOwnedDomainSucceedsWhenOneRowWasRemoved() {
+ val owner = User(id = 1, apiKey = "owner", name = "owner")
+ val domain = Domain(name = "example.com", user = owner)
+ Mockito.`when`(repository.findByCfid(domain.cfid)).thenReturn(Optional.of(domain))
+ Mockito.`when`(repository.deleteByCfid(domain.cfid)).thenReturn(1)
+ authenticate(owner)
+
+ assertEquals(domain.cfid, service.deleteDomain(domain.cfid))
+ Mockito.verify(powerDns).deleteZone(domain.name)
+ }
+
+ @Test
+ fun aUserWithoutDomainsGetsAnEmptyList() {
+ val user = User(id = 1, apiKey = "owner", name = "owner")
+ Mockito.`when`(repository.findAllByUser(user)).thenReturn(emptyList())
+ authenticate(user)
+
+ assertEquals(emptyList(), service.getAllDomains())
+ }
+
+ private fun authenticate(user: User) {
+ SecurityContextHolder.getContext().authentication =
+ UsernamePasswordAuthenticationToken(user, null, emptyList())
+ }
+}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
new file mode 100644
index 0000000..ae0ac27
--- /dev/null
+++ b/src/test/resources/application-test.properties
@@ -0,0 +1,8 @@
+spring.datasource.url=jdbc:h2:mem:dnsapi;DB_CLOSE_DELAY=-1
+spring.datasource.driver-class-name=org.h2.Driver
+spring.datasource.username=sa
+spring.datasource.password=
+spring.jpa.hibernate.ddl-auto=create-drop
+pdns.api.key=test
+pdns.api.url=http://localhost
+pdns.ns=ns.example