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
This commit is contained in:
dalbodeule committed 2026-10-29 23:11:21 +09:00
1 parent 5936622f64
commit 47461c505f
24 files changed
+894 -1303

No files matched your search

+44 -5
View File
@@ -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
# End of https://www.toptal.com/developers/gitignore/api/gradle,java,kotlin,macos,linux,windows,intellij
+9
View File
@@ -3,4 +3,13 @@
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="dnsapi" options="-parameters" />
<module name="dnsapi.aot" options="-parameters" />
<module name="dnsapi.aotTest" options="-parameters" />
<module name="dnsapi.test" options="-parameters" />
<module name="space.mori.dnsapi.main" options="-parameters" />
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:97ab7754-c9e9-4e6f-b775-574d260344a3
2:0:1b4583fc-5364-493b-be71-9d4c536f6902
.</data>
</component>
</project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.24" />
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.21" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SwiftToolchain">
<option name="toolchain" value="xcode-/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift" />
</component>
</project>
Generated
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>
+163 -885
View File
File diff suppressed because it is too large. Load diff
+59 -29
View File
@@ -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<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "21"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.named<Test>("test") {
useJUnitPlatform { excludeTags("openapi-generation") }
}
tasks.register<Test>("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<Exec>("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<BootBuildImage> {
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<BootBuildImage> {
"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<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
+1 -1
View File
@@ -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
+286 -317
View File
@@ -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": {
"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"
"DomainRequestDTO": {
"type": "object",
"description": "Request DTO for Domain",
"properties": {
"name": {
"type": "string",
"description": "Domain name(TLD)",
"example": "example.com"
}
}
},
"ApiResponseDTOListDomainResponseDTO": {
"required": [
"errors",
"messages",
"success"
],
"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": {
"oneOf": [
{
"$ref": "#/components/schemas/DeleteResponseWithId"
},
{
"type": "null"
}
]
}
}
},
"DeleteResponseWithId": {
"required": [
"id"
],
"type": "object",
"properties": {
"id": {
@@ -794,6 +762,7 @@
"securitySchemes": {
"api token": {
"type": "http",
"name": "api token",
"scheme": "bearer"
}
}
+99
View File
@@ -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 = "<!-- BEGIN GENERATED API -->"
END = "<!-- END GENERATED API -->"
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()
@@ -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,11 +21,27 @@ 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()
private fun execute(request: Request) {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) return
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 createZone(zoneName: String): Response {
try {
fun createZone(zoneName: String) {
val body = gson.toJson(
mapOf(
"name" to "$zoneName.",
@@ -40,21 +56,11 @@ class PowerDNSAPIClient() {
.post(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
} catch(ex: Exception) {
println(ex)
throw ex
}
execute(request)
}
@Throws(PowerDNSAPIException::class)
fun deleteZone(zoneName: String): Response {
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)
}
}
@@ -24,7 +24,7 @@ class RecordController(
content = [Content(schema = Schema(implementation = ApiResponseDTO::class))]),
])
fun allRecords(@PathVariable zone_id: String): ApiResponseDTO<List<RecordResponseDTO>> {
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}")
@@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.*
interface DomainRepository : JpaRepository<Domain?, Long?> {
interface DomainRepository : JpaRepository<Domain, Long> {
@Transactional
fun findByCfid(cfid: String): Optional<Domain>
@@ -5,13 +5,15 @@ import org.springframework.data.jpa.repository.JpaRepository
import java.util.*
interface RecordRepository : JpaRepository<Record?, Long?> {
interface RecordRepository : JpaRepository<Record, Long> {
@Transactional
fun findByCfid(cfid: String): Optional<Record>
@Transactional
fun findByDomainIdAndCfid(domainId: Long, cfid: String): Optional<Record>
fun findAllByDomainId(domainId: Long): List<Record>
@Transactional
fun deleteByCfid(cfid: String): Int
+1 -1
View File
@@ -15,7 +15,7 @@ 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<Domain> = mutableListOf(),
) {
override fun toString(): String = "User(id=$id, name='$name')"
@@ -35,4 +35,5 @@ class UserFilter(
}
fun getCurrentUser(): User =
SecurityContextHolder.getContext().authentication.principal as User
SecurityContextHolder.getContext().authentication?.principal as? User
?: throw IllegalStateException("Authenticated user is required")
@@ -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,11 +61,14 @@ 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
@@ -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<RecordResponseDTO>? {
fun getRecordsByDomain(domain_id: String): List<RecordResponseDTO> {
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)
@@ -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
@@ -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")
}
}
@@ -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")
}
}
@@ -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<Domain>(), service.getAllDomains())
}
private fun authenticate(user: User) {
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(user, null, emptyList())
}
}
@@ -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