diff --git a/.gitignore b/.gitignore index 1b6985c..ca1ab9b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # Ignore Gradle build output directory build + +# schema file +/openapi/portone-v2-openapi.json diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..30042f2 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + kotlin("jvm") version "2.0.20" + kotlin("plugin.serialization") version "2.0.20" +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("com.squareup:kotlinpoet-jvm:1.18.1") +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/io/portone/openapi/GenerateSchemaCodeTask.kt b/buildSrc/src/main/kotlin/io/portone/openapi/GenerateSchemaCodeTask.kt new file mode 100644 index 0000000..dc8662a --- /dev/null +++ b/buildSrc/src/main/kotlin/io/portone/openapi/GenerateSchemaCodeTask.kt @@ -0,0 +1,32 @@ +package io.portone.openapi + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.nio.file.Files + +abstract class GenerateSchemaCodeTask : DefaultTask() { + @get:InputFile + abstract val inputFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generateSchemaCode() { + val document = Json.parseToJsonElement(Files.readString(inputFile.get().asFile.toPath())).jsonObject + SchemaGenerator(document, listOf( + "/payments", + "/payment-schedules", + "/identity-verifications", + "/billing-keys", + "/cash-receipts", + "/kakaopay", + ), outputDirectory.get().asFile.toPath()).generateFiles() + } +} diff --git a/buildSrc/src/main/kotlin/io/portone/openapi/SchemaGenerator.kt b/buildSrc/src/main/kotlin/io/portone/openapi/SchemaGenerator.kt new file mode 100644 index 0000000..ce398cf --- /dev/null +++ b/buildSrc/src/main/kotlin/io/portone/openapi/SchemaGenerator.kt @@ -0,0 +1,363 @@ +package io.portone.openapi + +import kotlinx.serialization.json.* +import java.nio.file.Files +import java.nio.file.Path + +interface Documented { + val title: String? + val description: String? +} + +sealed interface Spec : Documented { + val name: String + val asType: String + val refs: Set + + val asOptional: Spec + fun withTitle(title: String?): Spec + fun withDescription(description: String?): Spec +} + +data class MinimalSpec( + override val name: String, + override val asType: String, + override val refs: Set, + override val title: String?, + override val description: String?, +) : Spec { + override val asOptional: Spec + get() = copy(asType = if (asType.endsWith("?")) asType else "$asType?") + override fun withTitle(title: String?): Spec = + copy(title = title) + override fun withDescription(description: String?): Spec = + copy(description = description) +} + +data class EnumSpec( + override val name: String, + override val asType: String, + override val refs: Set, + val values: List, + override val title: String?, + override val description: String?, +) : Spec { + override val asOptional: Spec + get() = copy(asType = if (asType.endsWith("?")) asType else "$asType?") + override fun withTitle(title: String?): Spec = + copy(title = title) + override fun withDescription(description: String?): Spec = + copy(description = description) +} + +data class ObjectSpec( + override val name: String, + override val asType: String, + override val refs: Set, + val properties: MutableList = mutableListOf(), + val discriminator: Discriminator? = null, + val parents: MutableList = mutableListOf(), + override val title: String?, + override val description: String?, +) : Spec { + override val asOptional: Spec + get() = copy(asType = if (asType.endsWith("?")) asType else "$asType?") + override fun withTitle(title: String?): Spec = + copy(title = title) + override fun withDescription(description: String?): Spec = + copy(description = description) +} + +data class Discriminator( + val propertyName: String, + val mapping: List>, +) + +data class Operation( + val name: String, + val path: String, + val method: String, + val pathParams: List, + val queryParams: List, + val body: String?, + val success: String?, + val returns: String?, + val error: String, + override val title: String?, + override val description: String?, +) : Documented + +private fun parseRef(ref: String): String = + ref.substringAfterLast('/') + +class SchemaGenerator(val document: JsonObject, val includePrefixes: List, val targetDirectory: Path) { + val resolvedNames: MutableSet = mutableSetOf() + val operations: MutableList = mutableListOf() + val schemas: MutableMap = mutableMapOf() + + val documentSchemas: JsonObject? + get() = document.getObject("components")?.getObject("schemas") + + fun visitDocument() { + val paths = document.getObject("paths") ?: return + + for ((path, pathItem) in paths.entries) { + if (includePrefixes.none { path.startsWith(it) } || pathItem !is JsonObject || pathItem.isEmpty()) continue + + for ((method, schema) in pathItem.entries) { + if (schema !is JsonObject) continue + val operationId = schema.getString("operationId") ?: continue + val title = schema.getString("x-portone-title") ?: continue + val description = schema.getString("x-portone-description") ?: continue + + val portoneError = schema.getObject("x-portone-error") ?: continue + val errorRef = portoneError.getString("\$ref") ?: continue + val error = parseRef(errorRef) + exportType(error) + + val requestBody = schema["requestBody"]?.jsonObject + val body = schema.getObject("requestBody") + ?.getObject("content") + ?.getObject("application/json") + ?.getObject("schema")?.jsonObject + ?.getString("\$ref")?.let { bodyRef -> + parseRef(bodyRef).also { exportType(it) } + } + + val parametersSchema = schema["parameters"]?.jsonArray + val pathParams = mutableListOf() + val queryParams = mutableListOf() + if (parametersSchema != null) { + for (parameter in parametersSchema) { + parameter as JsonObject + if (parameter.containsKey("\$ref")) continue + val name = parameter.getString("name") ?: continue + if (name == "requestBody") continue + val where = parameter.getString("in") ?: continue + val parameterSchema = parameter.getObject("schema") ?: continue + val parameterDescription = parameter.getString("description") + val spec = visitSchema(parameterSchema, name).withDescription(parameterDescription) + if (where == "path") { + pathParams.add(spec) + } else if (where == "query") { + queryParams.add(spec) + } + } + } + + val response = schema.getObject("responses") + val success = response?.getObject("200")?.getObject("content") + ?.getObject("application/json")?.getObject("schema")?.getString("\$ref")?.let { successRef -> + parseRef(successRef).also { exportType(it) } + } + val returns = response?.getObject("200")?.getString("description") + + operations.add(Operation( + name = operationId, + path = path, + method = method, + pathParams = pathParams, + queryParams = queryParams, + body = body, + success = success, + returns = returns, + error = error, + title = title, + description = description, + )) + } + } + } + + fun visitSchema(schema: JsonObject, name: String): Spec { + val title = schema.getString("title") + val description = schema.getString("description") + + val ref = schema.getString("\$ref") + if (ref != null) { + val asType = parseRef(ref) + exportType(asType) + return MinimalSpec( + name = name, + asType = asType, + refs = setOf(asType), + title = title, + description = description, + ) + } + + val discriminator = schema.getObject("discriminator") + if (discriminator != null) { + val propertyName = discriminator.getString("propertyName") + val mapping = discriminator.getObject("mapping") + if (propertyName != null && mapping != null) { + val refs = mutableSetOf() + val map = mutableListOf>() + for ((key, ref) in mapping.entries) { + val name = parseRef(ref.string) + exportType(name) + refs.add(name) + map.add(Pair(key, name)) + (schemas[name] as? ObjectSpec)?.properties?.removeAll { + it.name == propertyName + } + } + + return ObjectSpec( + name = name, + asType = name, + refs = refs, + discriminator = Discriminator( + propertyName = propertyName, + mapping = map, + ), + title = title, + description = description, + ) + } + } + + val type = schema.getString("type")!! + return when (type) { + "boolean" -> MinimalSpec( + name = name, + asType = "Boolean", + title = title, + description = description, + refs = emptySet(), + ) + "integer" -> MinimalSpec( + name = name, + asType = when (schema.getString("format")) { + "int32" -> "Int" + "int64" -> "Long" + null -> "BigInteger" + else -> throw RuntimeException() + }, + title = title, + description = description, + refs = emptySet(), + ) + "number" -> MinimalSpec( + name = name, + asType = when (schema.getString("format")) { + "float" -> "Float" + "double" -> "Double" + else -> throw RuntimeException() + }, + title = title, + description = description, + refs = emptySet(), + ) + "string" -> { + val enum = schema.getArray("enum") + if (enum != null) { + EnumSpec( + name = name, + asType = name, + refs = emptySet(), + values = enum.map { it.string }, + title = title, + description = description, + ) + } else { + MinimalSpec( + name = name, + asType = "String", + refs = emptySet(), + title = title, + description = description, + ) + } + } + "array" -> { + val items = schema.getObject("items")!! + val spec = visitSchema(items, name) + MinimalSpec( + name = name, + asType = "List<${spec.asType}>", + refs = spec.refs, + title = title, + description = description, + ) + } + "object" -> { + val rawProperties = schema.getObject("properties") + if (rawProperties != null) { + val required = schema.getStringArray("required")?.toSet() ?: emptySet() + + val refs = mutableSetOf() + val properties = mutableListOf() + for ((key, value) in rawProperties.entries) { + if (value is JsonObject) { + var spec = visitSchema(value, key) + refs.addAll(spec.refs) + if (!required.contains(key)) { + spec = spec.asOptional + } + properties.add(spec) + } + } + + ObjectSpec( + name = name, + asType = name, + title = title, + description = description, + properties = properties, + refs = refs, + ) + } else { + MinimalSpec( + name = name, + asType = "Any", + title = title, + description = description, + refs = emptySet(), + ) + } + } + + else -> throw RuntimeException("unknown type $type") + } + } + + fun exportType(name: String) { + if (resolvedNames.contains(name)) return + resolvedNames.add(name) + val schema = documentSchemas?.getObject(name) ?: return + val spec = visitSchema(schema, name) + schemas[name] = MinimalSpec( + name = name, + asType = name, + refs = emptySet(), + title = spec.title, + description = spec.description, + ) + } + + fun generateFiles() { + visitDocument() + + val sampleFile = targetDirectory.resolve("sample.kt") + Files.writeString(sampleFile, """package io.portone.sdk.server + +public object PortOneClient {}""") + } +} + +val JsonElement.string: String + get() { + val primitive = jsonPrimitive + if (!primitive.isString) throw IllegalArgumentException("Element is not a string") + return primitive.content + } + +fun JsonObject.getObject(name: String): JsonObject? = get(name)?.jsonObject + +fun JsonObject.getArray(name: String): JsonArray? = get(name)?.jsonArray + +fun JsonObject.getStringArray(name: String): List? = getArray(name)?.map { it.string } + +fun JsonObject.getString(name: String): String? = get(name)?.string + diff --git a/common/.editorconfig b/common/.editorconfig new file mode 100644 index 0000000..af61523 --- /dev/null +++ b/common/.editorconfig @@ -0,0 +1,2 @@ +[build/generated/**/*] +ktlint = disabled diff --git a/common/api/common.api b/common/api/common.api index b77a14d..06f7ac0 100644 --- a/common/api/common.api +++ b/common/api/common.api @@ -1,3 +1,7 @@ +public final class io/portone/sdk/server/PortOneClient { + public static final field INSTANCE Lio/portone/sdk/server/PortOneClient; +} + public final class io/portone/sdk/server/webhook/WebhookVerificationException : java/lang/Exception { public static final field Companion Lio/portone/sdk/server/webhook/WebhookVerificationException$Companion; } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index ac7356e..1501f93 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,4 +1,5 @@ import com.vanniktech.maven.publish.SonatypeHost +import io.portone.openapi.GenerateSchemaCodeTask import org.jetbrains.dokka.gradle.DokkaTask import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.jvm.JvmTargetValidationMode @@ -27,6 +28,24 @@ version = "0.0.1-SNAPSHOT" } +val generateSchemaCode = + tasks.register("generateSchemaCode") { + inputFile = file("../openapi/portone-v2-openapi.json") + outputDirectory.set(layout.buildDirectory.dir("generated/sources/schemaCode/kotlin/main")) + } + +sourceSets { + main { + kotlin { + srcDir(generateSchemaCode) + } + } +} + +tasks.compileKotlin { + dependsOn(generateSchemaCode) +} + tasks.withType().configureEach { jvmTargetValidationMode = JvmTargetValidationMode.ERROR } @@ -56,11 +75,11 @@ tasks.compileJava { ) } -repositories { - mavenCentral() +dependencies { } -dependencies { +repositories { + mavenCentral() } testing { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e644113..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a441313..9355b41 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-8.10-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf1..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30d..9d21a21 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ##########################################################################