From 62d23e05896dcf92f4fcedbffb5a5614eca2b075 Mon Sep 17 00:00:00 2001 From: Juha Komulainen Date: Wed, 21 Aug 2024 12:41:55 +0300 Subject: [PATCH] Add preliminary support for branded primitive types --- CHANGELOG.md | 1 + .../main/kotlin/fi/evident/apina/cli/Apina.kt | 2 ++ .../evident/apina/cli/CommandLineArguments.kt | 20 ++++++++++++++++ .../model/settings/BrandedPrimitiveType.kt | 6 +++++ .../model/settings/TranslationSettings.kt | 6 +++-- .../fi/evident/apina/model/type/ApiType.kt | 24 +++++++++++++++---- .../apina/output/swift/SwiftGenerator.kt | 1 + .../output/ts/AbstractTypeScriptGenerator.kt | 8 ++++++- .../fi/evident/apina/spring/TypeTranslator.kt | 2 +- .../resources/typescript/runtime-common.ts | 4 ++++ .../model/settings/TranslationSettingsTest.kt | 4 ++-- .../evident/apina/gradle/tasks/ApinaTask.kt | 12 ++++++++++ 12 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 apina-core/src/main/kotlin/fi/evident/apina/model/settings/BrandedPrimitiveType.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 18caabda..8c1a18e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 0.24.0 unreleased +- Incubating support for branded primitive types. - Stricter typing in the runtime library. - Remove Dictionary in generated TypeScript code and replace uses with Record. This will probably not affect you unless you have imported Dictionary (in which case you can define your own alias with diff --git a/apina-cli/src/main/kotlin/fi/evident/apina/cli/Apina.kt b/apina-cli/src/main/kotlin/fi/evident/apina/cli/Apina.kt index e32efb3a..39dd4de7 100644 --- a/apina-cli/src/main/kotlin/fi/evident/apina/cli/Apina.kt +++ b/apina-cli/src/main/kotlin/fi/evident/apina/cli/Apina.kt @@ -43,6 +43,8 @@ object Apina { for (anImport in arguments.imports) processor.settings.addImport(anImport.module, anImport.types) + processor.settings.brandedPrimitiveTypes += arguments.brandedPrimitiveTypes + val output = processor.process() if (arguments.files.size == 2) { diff --git a/apina-cli/src/main/kotlin/fi/evident/apina/cli/CommandLineArguments.kt b/apina-cli/src/main/kotlin/fi/evident/apina/cli/CommandLineArguments.kt index 36af1bf6..540c15d8 100644 --- a/apina-cli/src/main/kotlin/fi/evident/apina/cli/CommandLineArguments.kt +++ b/apina-cli/src/main/kotlin/fi/evident/apina/cli/CommandLineArguments.kt @@ -1,8 +1,11 @@ package fi.evident.apina.cli +import fi.evident.apina.model.settings.BrandedPrimitiveType import fi.evident.apina.model.settings.OptionalTypeMode import fi.evident.apina.model.settings.Platform import fi.evident.apina.model.settings.TypeWriteMode +import fi.evident.apina.model.type.ApiType +import fi.evident.apina.model.type.ApiTypeName internal class CommandLineArguments { @@ -11,6 +14,7 @@ internal class CommandLineArguments { val controllerPatterns = mutableListOf() val endpointUrlMethods = mutableListOf() val imports = mutableListOf() + val brandedPrimitiveTypes = mutableListOf() var platform = Platform.ANGULAR var typeWriteMode = TypeWriteMode.INTERFACE var optionalTypeMode = OptionalTypeMode.NULL @@ -67,6 +71,22 @@ internal class CommandLineArguments { return } + val brandedPrimitiveType = parseOptionalWithValue("branded-primitive-types", arg) + if (brandedPrimitiveType != null) { + val definitions = brandedPrimitiveType.split(",") + for (definition in definitions) { + val values = definition.split(":", limit = 2) + if (values.size != 2) + throw IllegalArgumentException("invalid branded primitive type: $definition") + + brandedPrimitiveTypes += BrandedPrimitiveType( + brandedType = ApiTypeName(values[0]), + implementationType = ApiType.Primitive.forName(values[1]) + ) + } + return + } + if (arg.startsWith("--")) throw IllegalArgumentException("unknown argument $arg") diff --git a/apina-core/src/main/kotlin/fi/evident/apina/model/settings/BrandedPrimitiveType.kt b/apina-core/src/main/kotlin/fi/evident/apina/model/settings/BrandedPrimitiveType.kt new file mode 100644 index 00000000..c5790d6f --- /dev/null +++ b/apina-core/src/main/kotlin/fi/evident/apina/model/settings/BrandedPrimitiveType.kt @@ -0,0 +1,6 @@ +package fi.evident.apina.model.settings + +import fi.evident.apina.model.type.ApiType +import fi.evident.apina.model.type.ApiTypeName + +class BrandedPrimitiveType(val brandedType: ApiTypeName, val implementationType: ApiType) diff --git a/apina-core/src/main/kotlin/fi/evident/apina/model/settings/TranslationSettings.kt b/apina-core/src/main/kotlin/fi/evident/apina/model/settings/TranslationSettings.kt index 44d2b43e..83a4853e 100644 --- a/apina-core/src/main/kotlin/fi/evident/apina/model/settings/TranslationSettings.kt +++ b/apina-core/src/main/kotlin/fi/evident/apina/model/settings/TranslationSettings.kt @@ -15,6 +15,7 @@ class TranslationSettings { private val endpointUrlMethods = PatternSet() private val importsByModule = TreeMap() private val importedTypes = TreeSet() + val brandedPrimitiveTypes = mutableListOf() val nameTranslator = NameTranslator() var platform = Platform.ANGULAR var typeWriteMode = TypeWriteMode.INTERFACE @@ -39,8 +40,8 @@ class TranslationSettings { val imports: Collection get() = importsByModule.values - fun isImported(typeName: ApiTypeName) = - typeName in importedTypes + fun isImportedOrBrandedType(typeName: ApiTypeName) = + typeName in importedTypes || brandedPrimitiveTypes.any { typeName == it.brandedType } fun isProcessableController(name: String) = controllersToProcess.isEmpty || name in controllersToProcess @@ -58,4 +59,5 @@ class TranslationSettings { fun normalizeUrl(url: String): String = url.removePrefix(removedUrlPrefix) + } diff --git a/apina-core/src/main/kotlin/fi/evident/apina/model/type/ApiType.kt b/apina-core/src/main/kotlin/fi/evident/apina/model/type/ApiType.kt index 4351122f..a5f4aec1 100644 --- a/apina-core/src/main/kotlin/fi/evident/apina/model/type/ApiType.kt +++ b/apina-core/src/main/kotlin/fi/evident/apina/model/type/ApiType.kt @@ -13,7 +13,9 @@ sealed class ApiType { abstract override fun hashCode(): Int data class Array(val elementType: ApiType) : ApiType() { - override fun toTypeScript(optionalTypeMode: OptionalTypeMode) = "${elementType.toTypeScript(optionalTypeMode)}[]" + override fun toTypeScript(optionalTypeMode: OptionalTypeMode) = + "${elementType.toTypeScript(optionalTypeMode)}[]" + override fun toSwift() = "[${elementType.toSwift()}]" } @@ -27,14 +29,16 @@ sealed class ApiType { */ data class Class(val name: ApiTypeName) : ApiType(), Comparable { - constructor(name: String): this(ApiTypeName(name)) + constructor(name: String) : this(ApiTypeName(name)) + override fun toTypeScript(optionalTypeMode: OptionalTypeMode) = name.name override fun toSwift() = name.name override fun compareTo(other: Class) = name.compareTo(other.name) } data class Dictionary(private val valueType: ApiType) : ApiType() { - override fun toTypeScript(optionalTypeMode: OptionalTypeMode) = "Record" + override fun toTypeScript(optionalTypeMode: OptionalTypeMode) = + "Record" override fun toSwift() = "[String: ${valueType.toSwift()}]" } @@ -43,6 +47,7 @@ sealed class ApiType { OptionalTypeMode.UNDEFINED -> type.toTypeScript(optionalTypeMode) + " | undefined" OptionalTypeMode.NULL -> type.toTypeScript(optionalTypeMode) + " | null" } + override fun toSwift() = type.toSwift() + "?" override fun unwrapNullable() = type override fun nullable(): Nullable = this @@ -50,7 +55,8 @@ sealed class ApiType { class Primitive private constructor( private val typescriptName: String, - private val swiftName: String) : ApiType() { + private val swiftName: String, + ) : ApiType() { override fun toString() = typescriptName override fun toTypeScript(optionalTypeMode: OptionalTypeMode) = typescriptName @@ -65,6 +71,16 @@ sealed class ApiType { val INTEGER: ApiType = Primitive("number", swiftName = "Int") val FLOAT: ApiType = Primitive(typescriptName = "number", swiftName = "Float") val VOID: ApiType = Primitive(typescriptName = "void", swiftName = "Void") + + fun forName(name: String): ApiType = when (name) { + "any" -> ANY + "string" -> STRING + "boolean" -> BOOLEAN + "integer" -> INTEGER + "float" -> FLOAT + "void" -> VOID + else -> throw IllegalArgumentException("unknown primitive type: '$name'") + } } } } diff --git a/apina-core/src/main/kotlin/fi/evident/apina/output/swift/SwiftGenerator.kt b/apina-core/src/main/kotlin/fi/evident/apina/output/swift/SwiftGenerator.kt index 9a552a44..ed3b3cb5 100644 --- a/apina-core/src/main/kotlin/fi/evident/apina/output/swift/SwiftGenerator.kt +++ b/apina-core/src/main/kotlin/fi/evident/apina/output/swift/SwiftGenerator.kt @@ -22,6 +22,7 @@ class SwiftGenerator(val api: ApiDefinition, val settings: TranslationSettings) private fun writeTypes() { check(settings.imports.isEmpty()) { "Imports are not yet supported for Swift" } + check(settings.brandedPrimitiveTypes.isEmpty()) { "Branded primitive types are not yet supported for Swift" } if (api.typeAliases.isNotEmpty()) { for ((alias, target) in api.typeAliases) diff --git a/apina-core/src/main/kotlin/fi/evident/apina/output/ts/AbstractTypeScriptGenerator.kt b/apina-core/src/main/kotlin/fi/evident/apina/output/ts/AbstractTypeScriptGenerator.kt index 186394d9..8a612f90 100644 --- a/apina-core/src/main/kotlin/fi/evident/apina/output/ts/AbstractTypeScriptGenerator.kt +++ b/apina-core/src/main/kotlin/fi/evident/apina/output/ts/AbstractTypeScriptGenerator.kt @@ -64,6 +64,9 @@ abstract class AbstractTypeScriptGenerator( private fun writeTypes() { + for (type in settings.brandedPrimitiveTypes) + out.writeLine("export type ${type.brandedType.name} = Branded<${type.implementationType.toTypeScript(settings.optionalTypeMode)}, '${type.brandedType.name}'>;") + for (type in api.allBlackBoxClasses) out.writeLine("export type ${type.name} = {};") @@ -157,6 +160,9 @@ abstract class AbstractTypeScriptGenerator( private fun writeSerializerDefinitions() { out.write("function registerDefaultSerializers(config: ApinaConfig): void ").writeBlock { + for (type in settings.brandedPrimitiveTypes) + writeIdentitySerializer(type.brandedType) + for (aliasedType in api.typeAliases.keys) writeIdentitySerializer(aliasedType) @@ -260,7 +266,7 @@ abstract class AbstractTypeScriptGenerator( type is ApiType.Primitive -> type.toTypeScript(settings.optionalTypeMode) type is ApiType.Array -> qualifiedTypeName(type.elementType) + "[]" - settings.isImported(ApiTypeName(type.toTypeScript(settings.optionalTypeMode))) -> type.toTypeScript(settings.optionalTypeMode) + settings.isImportedOrBrandedType(ApiTypeName(type.toTypeScript(settings.optionalTypeMode))) -> type.toTypeScript(settings.optionalTypeMode) else -> type.toTypeScript(settings.optionalTypeMode) } diff --git a/apina-core/src/main/kotlin/fi/evident/apina/spring/TypeTranslator.kt b/apina-core/src/main/kotlin/fi/evident/apina/spring/TypeTranslator.kt index a173d19c..bfcfcc35 100644 --- a/apina-core/src/main/kotlin/fi/evident/apina/spring/TypeTranslator.kt +++ b/apina-core/src/main/kotlin/fi/evident/apina/spring/TypeTranslator.kt @@ -99,7 +99,7 @@ internal class TypeTranslator( private fun translateClassType(type: JavaType.Basic, env: TypeEnvironment): ApiType { val typeName = classNameForType(type) - if (settings.isImported(typeName)) + if (settings.isImportedOrBrandedType(typeName)) return ApiType.BlackBox(typeName) if (settings.isBlackBoxClass(type.name)) { diff --git a/apina-core/src/main/resources/typescript/runtime-common.ts b/apina-core/src/main/resources/typescript/runtime-common.ts index 3e494a3f..ee3b6d41 100644 --- a/apina-core/src/main/resources/typescript/runtime-common.ts +++ b/apina-core/src/main/resources/typescript/runtime-common.ts @@ -1,3 +1,7 @@ +declare const brand: unique symbol; + +export type Branded = T & { [brand]: TBrand }; + export class ApinaConfig { /** Prefix added for all API calls */ diff --git a/apina-core/src/test/kotlin/fi/evident/apina/model/settings/TranslationSettingsTest.kt b/apina-core/src/test/kotlin/fi/evident/apina/model/settings/TranslationSettingsTest.kt index 370393cd..b46a7604 100644 --- a/apina-core/src/test/kotlin/fi/evident/apina/model/settings/TranslationSettingsTest.kt +++ b/apina-core/src/test/kotlin/fi/evident/apina/model/settings/TranslationSettingsTest.kt @@ -18,7 +18,7 @@ class TranslationSettingsTest { settings.imports.any { it.moduleName == "mod2" && it.types == listOf("Baz") } for (type in listOf("Foo", "Bar", "Baz")) - assertTrue(settings.isImported(ApiTypeName(type))) + assertTrue(settings.isImportedOrBrandedType(ApiTypeName(type))) } @Test @@ -29,7 +29,7 @@ class TranslationSettingsTest { settings.imports.any { it.moduleName == "mod1" && it.types == listOf("Foo", "Bar", "Baz") } for (type in listOf("Foo", "Bar", "Baz")) - assertTrue(settings.isImported(ApiTypeName(type))) + assertTrue(settings.isImportedOrBrandedType(ApiTypeName(type))) } @Test diff --git a/apina-gradle/src/main/kotlin/fi/evident/apina/gradle/tasks/ApinaTask.kt b/apina-gradle/src/main/kotlin/fi/evident/apina/gradle/tasks/ApinaTask.kt index cbae01f1..e751ec5a 100644 --- a/apina-gradle/src/main/kotlin/fi/evident/apina/gradle/tasks/ApinaTask.kt +++ b/apina-gradle/src/main/kotlin/fi/evident/apina/gradle/tasks/ApinaTask.kt @@ -4,10 +4,13 @@ package fi.evident.apina.gradle.tasks import fi.evident.apina.ApinaProcessor import fi.evident.apina.java.reader.Classpath +import fi.evident.apina.model.settings.BrandedPrimitiveType import fi.evident.apina.model.settings.EnumMode import fi.evident.apina.model.settings.OptionalTypeMode import fi.evident.apina.model.settings.Platform import fi.evident.apina.model.settings.TypeWriteMode +import fi.evident.apina.model.type.ApiType +import fi.evident.apina.model.type.ApiTypeName import fi.evident.apina.spring.EndpointParameterNameNotDefinedException import org.gradle.api.DefaultTask import org.gradle.api.file.FileCollection @@ -44,6 +47,9 @@ abstract class ApinaTask : DefaultTask() { @get:Input abstract val classNameMapping: MapProperty + @get:Input + abstract val brandedPrimitiveTypes: MapProperty + @get:Input abstract val platform: Property @@ -100,6 +106,12 @@ abstract class ApinaTask : DefaultTask() { for ((key, value) in imports.get()) processor.settings.addImport(key, value) + for ((brandedType, implementationType) in brandedPrimitiveTypes.get()) + processor.settings.brandedPrimitiveTypes += BrandedPrimitiveType( + brandedType = ApiTypeName(brandedType), + implementationType = ApiType.Primitive.forName(implementationType) + ) + for ((qualifiedName, translatedName) in classNameMapping.get()) processor.settings.nameTranslator.registerClassName(qualifiedName, translatedName)