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 @@ [![Discord](https://img.shields.io/discord/630271953806295051)](https://discord.gg/EPnCfz7xx5) [![Build Status](https://teamcity.mori.space/app/rest/builds/buildType:PdnsApi_Build/statusIcon)](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