├── .github └── workflows │ ├── pr_master.main.kts │ └── pr_master.yaml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── publish.sh ├── settings.gradle.kts ├── src ├── commonMain │ └── kotlin │ │ └── com │ │ └── jillesvangurp │ │ └── jsondsl │ │ ├── CustomValue.kt │ │ ├── IJsonDsl.kt │ │ ├── JsonDsl.kt │ │ ├── JsonDslSerializer.kt │ │ ├── SimpleJsonSerializer.kt │ │ └── SimpleYamlSerializer.kt ├── commonTest │ └── kotlin │ │ └── com │ │ └── jillesvangurp │ │ └── jsondsl │ │ ├── CustomValueTest.kt │ │ ├── JsonDslTest.kt │ │ ├── YamlTest.kt │ │ └── termquery.kt ├── jsMain │ └── kotlin │ │ └── com │ │ └── jillesvangurp │ │ └── jsondsl │ │ └── jssupport.kt └── jvmTest │ └── kotlin │ └── com │ └── jillesvangurp │ └── jsondsl │ └── readme │ ├── ReadmeGenerationTest.kt │ ├── intro.md │ └── outro.md └── versions.properties /.github/workflows/pr_master.main.kts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env kotlin 2 | 3 | @file:DependsOn("io.github.typesafegithub:github-workflows-kt:1.8.0") 4 | 5 | import io.github.typesafegithub.workflows.actions.actions.CheckoutV4 6 | import io.github.typesafegithub.workflows.actions.actions.GithubScriptV7 7 | import io.github.typesafegithub.workflows.actions.actions.SetupJavaV4 8 | import io.github.typesafegithub.workflows.actions.actions.SetupNodeV4 9 | import io.github.typesafegithub.workflows.actions.docker.BuildPushActionV5 10 | import io.github.typesafegithub.workflows.actions.docker.SetupBuildxActionV3 11 | import io.github.typesafegithub.workflows.actions.googlegithubactions.AuthV2 12 | import io.github.typesafegithub.workflows.actions.googlegithubactions.SetupGcloudV1 13 | import io.github.typesafegithub.workflows.actions.googlegithubactions.SetupGcloudV2 14 | import io.github.typesafegithub.workflows.actions.gradle.GradleBuildActionV2 15 | import io.github.typesafegithub.workflows.domain.RunnerType 16 | import io.github.typesafegithub.workflows.domain.triggers.PullRequest 17 | import io.github.typesafegithub.workflows.domain.triggers.Push 18 | import io.github.typesafegithub.workflows.dsl.expressions.expr 19 | import io.github.typesafegithub.workflows.dsl.workflow 20 | import io.github.typesafegithub.workflows.yaml.writeToFile 21 | 22 | val workflow = workflow( 23 | name = "Process Pull Request", 24 | on = listOf( 25 | Push( 26 | branches = listOf("main") 27 | ), 28 | PullRequest( 29 | branches = listOf("main") 30 | ), 31 | ), 32 | sourceFile = __FILE__.toPath(), 33 | targetFileName = "pr_master.yaml", 34 | ) { 35 | job( 36 | id = "build-and-test", 37 | name = "Build And Test", 38 | runsOn = RunnerType.UbuntuLatest, 39 | timeoutMinutes = 30, 40 | ) { 41 | uses( 42 | name = "checkout", 43 | action = CheckoutV4() 44 | ) 45 | uses( 46 | name = "setup jdk", 47 | action = SetupJavaV4( 48 | javaPackage = SetupJavaV4.JavaPackage.Jdk, 49 | javaVersion = "21", 50 | distribution = SetupJavaV4.Distribution.Corretto, 51 | cache = SetupJavaV4.BuildPlatform.Gradle, 52 | ) 53 | ) 54 | run { 55 | val gradleBuildStep = uses( 56 | name = "build with gradle", 57 | action = GradleBuildActionV2( 58 | arguments = "check", 59 | ) 60 | ) 61 | } 62 | 63 | } 64 | } 65 | 66 | workflow.writeToFile(addConsistencyCheck = true) 67 | -------------------------------------------------------------------------------- /.github/workflows/pr_master.yaml: -------------------------------------------------------------------------------- 1 | # This file was generated using Kotlin DSL (.github/workflows/pr_master.main.kts). 2 | # If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file. 3 | # Generated with https://github.com/typesafegithub/github-workflows-kt 4 | 5 | name: 'Process Pull Request' 6 | on: 7 | push: 8 | branches: 9 | - 'main' 10 | pull_request: 11 | branches: 12 | - 'main' 13 | jobs: 14 | check_yaml_consistency: 15 | name: 'Check YAML consistency' 16 | runs-on: 'ubuntu-latest' 17 | steps: 18 | - id: 'step-0' 19 | name: 'Check out' 20 | uses: 'actions/checkout@v4' 21 | - id: 'step-1' 22 | name: 'Execute script' 23 | run: 'rm ''.github/workflows/pr_master.yaml'' && ''.github/workflows/pr_master.main.kts''' 24 | - id: 'step-2' 25 | name: 'Consistency check' 26 | run: 'git diff --exit-code ''.github/workflows/pr_master.yaml''' 27 | build-and-test: 28 | name: 'Build And Test' 29 | runs-on: 'ubuntu-latest' 30 | needs: 31 | - 'check_yaml_consistency' 32 | timeout-minutes: 30 33 | steps: 34 | - id: 'step-0' 35 | name: 'checkout' 36 | uses: 'actions/checkout@v4' 37 | - id: 'step-1' 38 | name: 'setup jdk' 39 | uses: 'actions/setup-java@v4' 40 | with: 41 | java-version: '21' 42 | distribution: 'corretto' 43 | java-package: 'jdk' 44 | cache: 'gradle' 45 | - id: 'step-2' 46 | name: 'build with gradle' 47 | uses: 'gradle/gradle-build-action@v2' 48 | with: 49 | arguments: 'check' 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings 4 | *~ 5 | target 6 | test-output 7 | .idea 8 | *iml 9 | .gradle 10 | build 11 | out 12 | .kotlintest 13 | localRepo 14 | kotlin-js-store/ 15 | .kotlin 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2024, Jilles van Gurp 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JsonDsl 2 | 3 | [![Process Pull Request](https://github.com/jillesvangurp/json-dsl/actions/workflows/pr_master.yaml/badge.svg)](https://github.com/jillesvangurp/json-dsl/actions/workflows/pr_master.yaml) 4 | 5 | JsonDsl is a multi platform kotlin library that helps you build Kotlin DSLs for JSON and YAML dialects. 6 | DSLs made with this library are easy to extend with custom fields by users via a MutableMap. 7 | 8 | # About DSLs 9 | 10 | A Domain Specific Language (DSL) is a language that is intended to allow users to program or specify things in language that closely matches their domain. They are popular for configuration files, for use with certain frameworks or tools, and there are a lot of niche tools, frameworks, and other software packages out there that implement them. 11 | 12 | Some general purpose languages have a syntax that makes it easy to (ab)use their features to do something similar. Lisp and Ruby are a good example of languages that have historically been used for this. Like those languages, Kotlin includes a few features that enable this. Which makes Kotlin very well suited for implementing all sorts of DSLs. 13 | 14 | There are Kotlin DSLs for all sorts of things. A popular example is the HTML DSL that comes with things like Ktor and kotlin-js. Spring bundles a lot of Kotlin DSLs for it's Java framework that make using that a lot nicer than from Java. There are lots of examples. 15 | 16 | ## Making easy to extend Kotlin DSLs for JSON/YAML dialects 17 | 18 | Of course, creating model classes for your JSON domain model and annotating thosre with annotations for e.g. `kotlinx.serialization`, `jackson`, etc. is a perfectly valid way to start creating a DSL for your JSON or YAML dialect of choice. 19 | 20 | However, using JSON frameworks like that have some limitations. What if your JSON dialect evolves and somebody adds some new features? Unless you change your model class, it would not be possible to access such new features via the Kotlin DSL. Or what if your JSON dialect is vast and complicated. Do you have to support all of its features? How do you decide what to allow and not allow in your Kotlin DSL. 21 | 22 | This library started out as few classes in my [kt-search](https://github.com/jillesvangurp/kt-search) project. Kt-search implements a client library for Elasticsearch and Opensearch. Elasticsearch has several JSON dialects that are used for querying, defining index mappings, settings, and a few other things. Especially the query language has a large number of features and is constantly evolving. 23 | 24 | Not only do I have to worry about implementing each and every little feature these DSLs have and keeping up with upstream additions to OpenSearch and Elasticsearch. I also have to worry about supporting query and mapping features added via custom plugins. This is very challenging. And it was the main reason I created json-dsl: so I don't have to keep up. 25 | 26 | ## Strongly typed and Flexible 27 | 28 | The key feature in json-dsl is that it uses a `MutableMap` and property delegation for implementing DSL classes. This simple approach enables you to define classes with properties that delegate storing their value to this map. For anything that your 29 | classes don't implement, the user can simply modify the underlying map directly using a simple `put`. 30 | 31 | This simple approach gives users a nice fallback for things your DSL classes don't implement and it relieves Kotlin DSL creators from having to provide support for every new feature the upstream JSON dialect has or adds over time. You can provide a decent experience for your users with minimal effort. And you users can always work around whatever you did not implement. 32 | 33 | With kt-search, I simply focus on supporting all the commonly used, and some less commonly used things in the Elastic DSLs. But for everything else, I just rely on letting the user modify the underlying map themselves. A lot of pull requests I get on this project are people adding features they need in the DSLs. So, over time, feature support has gotten more comprehensive. 34 | 35 | ## Gradle 36 | 37 | This library is published to our own maven repository. Simply add the repository like this: 38 | 39 | ```kotlin 40 | repositories { 41 | mavenCentral() 42 | maven("https://maven.tryformation.com/releases") { 43 | // optional but it speeds up the gradle dependency resolution 44 | content { 45 | includeGroup("com.jillesvangurp") 46 | } 47 | } 48 | } 49 | ``` 50 | 51 | And then you can add the dependency: 52 | 53 | ```kotlin 54 | // check the latest release tag for the latest version 55 | implementation("com.jillesvangurp:json-dsl:3.x.y") 56 | ``` 57 | 58 | If you were using json-dsl via kt-search before, you can update simply by bumping the version of json-dsl to 3.0. Previously, 2.x was released along with kt-search and has now been removed from that project. 59 | 60 | 61 | ## Examples 62 | 63 | All the examples in this README are implemented using 64 | my [kotlin4example](https://github.com/jillesvangurp/kotlin4example) library. You can find 65 | the source code that generates this README [here](https://github.com/jillesvangurp/json-dsl/blob/main/src/jvmTest/kotlin/com/jillesvangurp/jsondsl/readme/ReadmeGenerationTest.kt). 66 | 67 | A more expanded version of these examples can be found in the form of a 68 | Jupyter notebook [here](https://github.com/jillesvangurp/json-dsl-jupyter) 69 | 70 | ### Hello World 71 | 72 | Let's start with a simple example. 73 | 74 | ```kotlin 75 | class MyDsl : JsonDsl() { 76 | // adds a string property that the user can assign 77 | var message by property() 78 | } 79 | 80 | // a helper function to create MyDsl instances 81 | fun myDsl(block: MyDsl.() -> Unit): MyDsl { 82 | return MyDsl().apply(block) 83 | } 84 | 85 | val json = myDsl { 86 | message = "Hello world" 87 | }.json(pretty = true) 88 | 89 | println(json) 90 | ``` 91 | 92 | The json extension function uses a json serializer to produce 93 | pretty printed json: 94 | 95 | ```json 96 | { 97 | "message": "Hello world" 98 | } 99 | ``` 100 | 101 | There is also a YAML serializer. More on that below. 102 | 103 | ### Common Kotlin Types 104 | 105 | JSON is a fairly simple data format. It has numbers, booleans, strings, lists and dictionaries. And null 106 | values. Kotlin has a bit richer type system and mapping that to JSON is key to providing rich Kotlin DSL. 107 | 108 | JsonDsl does a best effort to do map Kotlin types correctly to the intended JSON equivalent. 109 | 110 | It understands all the primitives, Maps and Lists. But also Arrays, Sets, Sequences, etc. 111 | And of course other JsonDsl classes, so you can nest them. And it falls back to using 112 | `toString()` for everything else. 113 | 114 | ```kotlin 115 | class MyDsl : JsonDsl() { 116 | var intVal by property() 117 | var boolVal by property() 118 | var doubleVal by property() 119 | var arrayVal by property>() 120 | var listVal by property>() 121 | var mapVal by property>() 122 | var idontknow by property() 123 | } 124 | 125 | // using kotlin's apply here, you can add helper functions of course 126 | MyDsl().apply { 127 | intVal = 1 128 | boolVal = true 129 | doubleVal = PI 130 | arrayVal = arrayOf("hello", "world") 131 | listVal = listOf("1", "2") 132 | mapVal = mapOf( 133 | "Key" to "Value" 134 | ) 135 | 136 | // The Any type is a bit of free for all 137 | idontknow = mapOf( 138 | "arrays" to arrayOf( 139 | 1, 2, "3", 4.0, 140 | mapOf("this" to "is valid JSON"), "mixing types is allowed in JSON" 141 | ), 142 | "sequences" to sequenceOf(1,"2",3.0) 143 | ) 144 | } 145 | ``` 146 | 147 | This does the right things with all the used Kotlin types, including `Any`: 148 | 149 | ```json 150 | { 151 | "int_val": 1, 152 | "bool_val": true, 153 | "double_val": 3.141592653589793, 154 | "array_val": [ 155 | "hello", 156 | "world" 157 | ], 158 | "list_val": [ 159 | "1", 160 | "2" 161 | ], 162 | "map_val": { 163 | "Key": "Value" 164 | }, 165 | "idontknow": { 166 | "arrays": [ 167 | 1, 168 | 2, 169 | "3", 170 | 4.0, 171 | { 172 | "this": "is valid JSON" 173 | }, 174 | "mixing types is allowed in JSON" 175 | ], 176 | "sequences": [ 177 | 1, 178 | "2", 179 | 3.0 180 | ] 181 | } 182 | } 183 | ``` 184 | 185 | ### Manipulating the Map directly 186 | 187 | As mentioned, JsonDsl delegates the storing of properties to a `MutableMap`. 188 | 189 | So, all sub classes have direct access to that map. And you can put anything you want into it. 190 | 191 | ```kotlin 192 | class MyDsl : JsonDsl() { 193 | var foo by property() 194 | } 195 | 196 | MyDsl().apply { 197 | // nicely typed. 198 | foo = "bar" 199 | 200 | // but we never defined a bar property 201 | this["bar"] = "foo" 202 | // or this ... 203 | this["whatever"] = listOf( 204 | MyDsl().apply { 205 | this["you"] = "can add anything you want" 206 | }, 207 | 42 208 | ) 209 | 210 | // RawJson is a Kotlin value class 211 | this["inline_json"] = RawJson(""" 212 | { 213 | "if":"you need to", 214 | "you":"can even add json in string form", 215 | } 216 | """.trimIndent()) 217 | } 218 | ``` 219 | 220 | ```json 221 | { 222 | "foo": "bar", 223 | "bar": "foo", 224 | "whatever": [ 225 | { 226 | "you": "can add anything you want" 227 | }, 228 | 42 229 | ], 230 | "inline_json": { 231 | "if":"you need to", 232 | "you":"can even add json in string form", 233 | } 234 | } 235 | ``` 236 | 237 | ### snake_casing, custom names, defaults 238 | 239 | A lot of JSON dialects use snake cased field names. Kotlin of course uses 240 | camel case for its identifiers and it has certain things that you can't redefine. 241 | Like the `size` property on `Map`, which is implemented by JsonDsl; or certain keywords. 242 | 243 | ```kotlin 244 | class MyDsl : JsonDsl( 245 | // this will snake case all the names 246 | namingConvention = PropertyNamingConvention.ConvertToSnakeCase 247 | ) { 248 | // -> camel_case 249 | var camelCase by property() 250 | // unfortunately Map defines a size val already 251 | var mySize by property( 252 | customPropertyName = "size" 253 | ) 254 | // val is a keyword in Kotlin 255 | var myVal by property( 256 | customPropertyName = "val" 257 | ) 258 | // Kotlin has default values, JSON does not. 259 | var m by property( 260 | customPropertyName = "meaning_of_life", 261 | defaultValue = 42 262 | ) 263 | } 264 | 265 | MyDsl().apply { 266 | camelCase = true 267 | mySize = Int.MAX_VALUE 268 | myVal = "hello" 269 | } 270 | ``` 271 | 272 | ```json 273 | { 274 | "meaning_of_life": 42, 275 | "camel_case": true, 276 | "size": 2147483647, 277 | "val": "hello" 278 | } 279 | ``` 280 | 281 | ### Custom values 282 | 283 | Sometimes you want to have the serialized version of a value be different 284 | from the kotlin type that you are using. For this we have added the 285 | CustomValue interface. 286 | 287 | A simple use case for this could be Enums: 288 | 289 | ```kotlin 290 | enum class Grades(override val value: Double) : CustomValue { 291 | Excellent(7.0), 292 | Pass(5.51), 293 | Fail(3.0), 294 | ; 295 | } 296 | ``` 297 | 298 | ```kotlin 299 | println(withJsonDsl { 300 | this["grade"] = Grades.Excellent 301 | }.json(true)) 302 | ``` 303 | 304 | Note how the grade's Double value is used instead of the name. 305 | 306 | The withJsonDsl function is a simple extension function that 307 | creates a JsonDsl for you and applies the block to it. 308 | 309 | ```json 310 | { 311 | "grade": 7.0 312 | } 313 | ``` 314 | 315 | You can also construct more complex ways to serialize your classes. 316 | 317 | ```kotlin 318 | data class Person( 319 | val firstName: String, 320 | val lastName: String): CustomValue> { 321 | override val value = 322 | listOf(firstName, lastName) 323 | } 324 | 325 | withJsonDsl { 326 | this["person"] = Person("Jane", "Doe") 327 | }.json(true) 328 | ``` 329 | 330 | And of course your custom value can be a JsonDsl too. 331 | 332 | ```kotlin 333 | data class Person( 334 | val firstName: String, 335 | val lastName: String): CustomValue { 336 | override val value = withJsonDsl { 337 | this["person"] = withJsonDsl { 338 | this["fn"] = firstName 339 | this["ln"] = lastName 340 | this["full_name"] = "$firstName $lastName" 341 | } 342 | } 343 | } 344 | ``` 345 | 346 | You can also rely on the `toString()` function: 347 | 348 | ```kotlin 349 | data class FooBar(val foo:String="foo", val bar: String="bar") 350 | println(withJsonDsl { 351 | this["foo"]=FooBar() 352 | }) 353 | ``` 354 | 355 | Note how it simply uses `toString()` on the data class 356 | 357 | ```json 358 | { 359 | "foo": "FooBar(foo=foo, bar=bar)" 360 | } 361 | ``` 362 | 363 | This also works for things like enums, value classes, and other Kotlin language constructs. 364 | 365 | ## YAML 366 | 367 | While initially written to support JSON, I also added a YAML serializer that you may use to 368 | create Kotlin DSLs for YAML based DSLs. So, you could use this to build Kotlin DSLs for things 369 | like Github actions, Kubernetes, or other common things that use YAML. 370 | 371 | ```kotlin 372 | class YamlDSL : JsonDsl() { 373 | var str by property() 374 | var map by property>() 375 | var list by property>() 376 | } 377 | val dsl = YamlDSL().apply { 378 | str=""" 379 | Multi line 380 | Strings are 381 | supported 382 | and 383 | preserve their 384 | indentation! 385 | """.trimIndent() 386 | map = mapOf( 387 | "foo" to "bar", 388 | "num" to PI, 389 | "bool" to true, 390 | "notABool" to "false" 391 | ) 392 | } 393 | // default is true for including --- 394 | print(dsl.yaml(includeYamlDocumentStart = false)) 395 | ``` 396 | 397 | This prints the YAML below: 398 | 399 | ```yaml 400 | str: | 401 | Multi line 402 | Strings are 403 | supported 404 | and 405 | preserve their 406 | indentation! 407 | map: 408 | foo: bar 409 | num: 3.141592653589793 410 | bool: true 411 | notABool: "false" 412 | ``` 413 | 414 | There are other tree like formats that might be supported in the future like TOML, properties, 415 | and other formats. I welcome pull requests for this provided they don't add any library dependencies. 416 | 417 | ## A real life, complex example 418 | 419 | Here's a bit of the kt-search Kotlin DSL that I lifted 420 | from my kt-search library. It implements a minimal 421 | query and only supports one of the (many) types of queries 422 | supported by Elasticsearch. 423 | 424 | Like many real life 425 | JSON, the Elasticsearch DSL is quite complicated and challenging 426 | to model. This is why I created this library. 427 | 428 | The code below is a good illustration of several things you can 429 | do in Kotlin to make life nice for your DSL users. 430 | 431 | ```kotlin 432 | // using DslMarkers is useful with 433 | // complicated DSLs 434 | @DslMarker 435 | annotation class SearchDSLMarker 436 | 437 | interface QueryClauses 438 | 439 | // abbreviated version of the 440 | // Elasticsearch Query DSL in kt-search 441 | class QueryDsl: 442 | JsonDsl(namingConvention = PropertyNamingConvention.ConvertToSnakeCase), 443 | // helper interface that we define 444 | // extension functions on 445 | QueryClauses 446 | { 447 | // Elasticsearch often wraps objects in 448 | // another object. So we use a custom 449 | // setter here to hide that. 450 | var query: ESQuery 451 | get() { 452 | val map = 453 | this["query"] as Map 454 | val (name, details) = map.entries.first() 455 | // reconstruct the ESQuery 456 | return ESQuery(name, details) 457 | } 458 | set(value) { 459 | // queries extend ESQuery 460 | // which takes care of the wrapping 461 | // via wrapWithName 462 | this["query"] = value.wrapWithName() 463 | }} 464 | 465 | // easy way to create a query 466 | fun query(block: QueryDsl.()->Unit): QueryDsl { 467 | return QueryDsl().apply(block) 468 | } 469 | 470 | @SearchDSLMarker 471 | open class ESQuery( 472 | val name: String, 473 | val queryDetails: JsonDsl = JsonDsl() 474 | ) { 475 | 476 | // Elasticsearch wraps everything in an outer object 477 | // with the name as its only key 478 | fun wrapWithName() = withJsonDsl() { 479 | this[name] = queryDetails 480 | } 481 | } 482 | 483 | // the dsl class for creating term queries 484 | // this is one of the most basic queries 485 | // in elasticsearch 486 | @SearchDSLMarker 487 | class TermQuery( 488 | field: String, 489 | value: String, 490 | termQueryConfig: TermQueryConfig = TermQueryConfig(), 491 | block: (TermQueryConfig.() -> Unit)? = null 492 | ) : ESQuery("term") { 493 | // on init, apply the block to the configuration and 494 | // assign it in the queryDetails from the parent 495 | init { 496 | queryDetails.put(field, termQueryConfig, PropertyNamingConvention.AsIs) 497 | termQueryConfig.value = value 498 | block?.invoke(termQueryConfig) 499 | } 500 | } 501 | 502 | // configuration for term queries 503 | // this is a subset of the supported 504 | // properties. 505 | class TermQueryConfig : JsonDsl() { 506 | var value by property() 507 | var boost by property() 508 | } 509 | 510 | // making this an extension function ensures it is available 511 | // on the receiver of the query {..} block 512 | fun QueryClauses.term( 513 | field: String, 514 | value: String, 515 | block: (TermQueryConfig.() -> Unit)? = null 516 | ) = 517 | TermQuery(field, value, block = block) 518 | 519 | // of course users of this DSL would 520 | // be storing json documents in elasticsearch 521 | // and they probably have model classes with 522 | // properties. 523 | // so supporting property references 524 | // for field names is a nice thing 525 | fun QueryClauses.term( 526 | field: KProperty<*>, 527 | value: String, 528 | block: (TermQueryConfig.() -> Unit)? = null 529 | ) = 530 | TermQuery(field.name, value, block = block) 531 | ``` 532 | 533 | And this is how your users would use this DSL. 534 | 535 | ```kotlin 536 | class MyModelClassInES(val myField: String) 537 | val q = query { 538 | query = term(MyModelClassInES::myField, "some value") 539 | } 540 | val pretty = q.json(pretty = true) 541 | println(pretty) 542 | ``` 543 | 544 | So, we created a query outer object with a query property. 545 | And we assigned a term query instance to that. 546 | 547 | In JSON form this looks as follows: 548 | 549 | ```json 550 | { 551 | "query": { 552 | "term": { 553 | "myField": { 554 | "value": "some value" 555 | } 556 | } 557 | } 558 | } 559 | ``` 560 | 561 | Note how it correctly wrapped the term query with an object. And how it correctly 562 | assigns the `TermConfiguration` in an object that has the field value as the key. 563 | Also note how we use a property reference here to avoid having to use 564 | a string literal. 565 | 566 | Of course, the Elasticsearch Query DSL support in 567 | [kt-search](https://github.com/jillesvangurp/kt-search) is a great 568 | reference for how to use JsonDsl. 569 | 570 | ## Multi platform 571 | 572 | This is a Kotlin multi platform library that should work on most kotlin platforms (jvm, js, ios, wasm, linux/windows/mac native, android, etc). 573 | 574 | My intention is to keep this code very portable and lightweight and not introduce any dependencies other than the Kotlin standard library. 575 | 576 | ## Using JsonDsl with Javascript libraries 577 | 578 | If you use kotlin-js, there are a lot of Javascript libraries out there that have functions that expect some sort of Javascript objects as a parameter. Integrating such libraries into Kotlin typically requires writing some type mappings to be able to call functions in these libraries and defining external class definitions for any models. 579 | 580 | With JsonDsl you can skip a lot of these class definitions and simply create a Kotlin DSL instead. Or use simply use it in schemaless mode and rely on the convenient mappings included for the default Kotlin collection classes. You can use lists, enums, maps, etc. and it will do the right thing. You have the full flexibility of JsonDsl to make things as type safe as you need them to be. 581 | 582 | There is a `toJsObject()` extension function that is available in kotlin-js that you can use to convert any JsonDsl instance to a javascript object. 583 | 584 | ## Parsing 585 | 586 | This library is not intended as a substitute for kotlinx.serialization or other JSON parsing frameworks. It is instead intended for writing Kotlin DSLs that you can use to generate valid JSON or YAML. JsonDsl does not support any form of parsing. 587 | 588 | ## Development and stability 589 | 590 | Before I extracted it from there, this library was part of [kt-search](https://github.com/jillesvangurp/kt-search), which has been out there for several years and has a steadily growing user base. So, even though this library is relatively new, the code base has been stable and actively used for several years. 591 | 592 | Other than cleaning the code a bit up for public use, there were no compatibility breaking changes. I want to keep the API for json-dsl stable and will not make any major changes unless there is a really good reason. 593 | 594 | This also means there won't be a lot of commits or updates since things are stable and pretty much working as intended. And because I have a few users of kt-search, I also don't want to burden them with compatibility breaking changes. Unless somebody finds a bug or asks for reasonable changes, the only changes likely to happen will be occasional dependency updates. 595 | 596 | ## Libraries using Json Dsl 597 | 598 | - [kt-search](https://github.com/jillesvangurp/kt-search) 599 | 600 | If you use this and find it useful, please add yourself to the list by creating a pull request on 601 | [outro.md](src/jvmTest/com/jillesvangurp/jsondsl/readme/outro.md) as this readme is generated using 602 | my [kotlin4example](https://github.com/jillesvangurp/kotlin4example) library. 603 | 604 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalWasmDsl::class) 2 | 3 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 4 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 5 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 6 | 7 | plugins { 8 | kotlin("multiplatform") 9 | kotlin("plugin.serialization") 10 | `maven-publish` 11 | } 12 | 13 | repositories { 14 | mavenCentral() 15 | maven(url = "https://jitpack.io") { 16 | content { 17 | includeGroup("com.github.jillesvangurp") 18 | } 19 | } 20 | } 21 | 22 | kotlin { 23 | jvm { 24 | // should work for android as well 25 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 26 | compilerOptions { 27 | jvmTarget = JvmTarget.JVM_11 28 | } 29 | } 30 | js(IR) { 31 | nodejs { 32 | testTask { 33 | useMocha { 34 | // javascript is a lot slower than Java, we hit the default timeout of 2000 35 | timeout = "60s" 36 | } 37 | } 38 | } 39 | } 40 | linuxX64() 41 | linuxArm64() 42 | mingwX64() 43 | macosX64() 44 | macosArm64() 45 | iosArm64() 46 | iosSimulatorArm64() 47 | iosX64() 48 | iosSimulatorArm64() 49 | wasmJs { 50 | browser() 51 | nodejs() 52 | d8() 53 | } 54 | // blocked on kotest assertions wasm release 55 | // wasmWasi() 56 | 57 | sourceSets { 58 | 59 | commonMain { 60 | dependencies { 61 | implementation(kotlin("stdlib-common")) 62 | } 63 | } 64 | 65 | commonTest { 66 | dependencies { 67 | implementation(kotlin("test-common")) 68 | implementation(kotlin("test-annotations-common")) 69 | implementation("io.kotest:kotest-assertions-core:_") 70 | } 71 | } 72 | 73 | jvmMain { 74 | dependencies { 75 | implementation(kotlin("stdlib-jdk8")) 76 | } 77 | } 78 | jvmTest { 79 | dependencies { 80 | implementation("com.github.jillesvangurp:kotlin4example:_") 81 | runtimeOnly("org.junit.jupiter:junit-jupiter:_") 82 | implementation(kotlin("test-junit")) 83 | } 84 | } 85 | 86 | jsMain { 87 | dependencies { 88 | implementation(kotlin("stdlib-js")) 89 | } 90 | } 91 | 92 | jsTest { 93 | dependencies { 94 | implementation(kotlin("test-js")) 95 | } 96 | } 97 | 98 | wasmJsTest { 99 | dependencies { 100 | implementation(kotlin("test-wasm-js")) 101 | } 102 | } 103 | 104 | all { 105 | languageSettings { 106 | languageVersion = "1.9" 107 | apiVersion = "1.9" 108 | } 109 | languageSettings.optIn("kotlin.RequiresOptIn") 110 | } 111 | } 112 | } 113 | 114 | tasks.named("iosSimulatorArm64Test") { 115 | // requires IOS simulator and tens of GB of other stuff to be installed 116 | // so keep it disabled 117 | enabled = false 118 | } 119 | 120 | publishing { 121 | publications { 122 | withType { 123 | pom { 124 | url.set("https://github.com/jillesvangurp/json-dsl") 125 | 126 | licenses { 127 | license { 128 | name.set("MIT License") 129 | url.set("https://github.com/jillesvangurp/json-dsl/blob/master/LICENSE") 130 | } 131 | } 132 | 133 | developers { 134 | developer { 135 | id.set("jillesvangurp") 136 | name.set("Jilles van Gurp") 137 | email.set("jilles@no-reply.github.com") 138 | } 139 | } 140 | 141 | scm { 142 | connection.set("scm:git:git://github.com/jillesvangurp/json-dsl.git") 143 | developerConnection.set("scm:git:ssh://github.com:jillesvangurp/json-dsl.git") 144 | url.set("https://github.com/jillesvangurp/json-dsl") 145 | } 146 | } 147 | } 148 | } 149 | 150 | repositories { 151 | maven { 152 | // GOOGLE_APPLICATION_CREDENTIALS env var must be set for this to work 153 | // public repository is at https://maven.tryformation.com/releases 154 | url = uri("gcs://mvn-public-tryformation/releases") 155 | name = "FormationPublic" 156 | } 157 | } 158 | } 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | version=0.0.0.1-SNAPSHOT 2 | group=com.jillesvangurp 3 | kotlin.mpp.stability.nowarn=true 4 | org.gradle.configuration-cache=true 5 | org.gradle.jvmargs=-XX:MaxMetaspaceSize=512m -Xmx2g -Dkotlin.daemon.jvm.options=-Xmx2g 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jillesvangurp/json-dsl/2be37486b78911e3820d8bfc8aead3c673de5153/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | die () { 5 | echo >&2 "$@" 6 | exit 1 7 | } 8 | 9 | [ "$#" -eq 1 ] || die "1 argument required, $# provided" 10 | echo $1 | grep -E -q '^[0-9]+\.[0-9]+(\.[0-9]+).*?$' || die "Semantic Version argument required, $1 provided" 11 | 12 | [[ -z $(git status -s) ]] || die "git status is not clean" 13 | 14 | export TAG=$1 15 | 16 | gradle -Pversion="$TAG" publish 17 | 18 | echo "tagging" 19 | git tag "$TAG" 20 | 21 | echo "publishing $TAG" 22 | 23 | git push --tags 24 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "json-dsl" 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | plugins { 10 | id("de.fayard.refreshVersions") version "0.60.5" 11 | } 12 | 13 | refreshVersions { 14 | } 15 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jillesvangurp/jsondsl/CustomValue.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | /** 4 | * Used for custom serialization where toString method does not provide the correct value. 5 | * 6 | * Note, you can use any type for the value. Including for example JsonDsl. So, you can construct 7 | * arbitrarily complex custom values for your classes. 8 | */ 9 | interface CustomValue { 10 | val value: T 11 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jillesvangurp/jsondsl/IJsonDsl.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | import kotlin.properties.ReadWriteProperty 4 | import kotlin.reflect.KProperty 5 | 6 | /** 7 | * Base interface for JsonDsl; this allows using interface delegation on classes that 8 | * extend the JsonDsl class. 9 | * 10 | * See kt-searches `SearchDsl` for an example of this. 11 | */ 12 | interface IJsonDsl : MutableMap { 13 | val defaultNamingConvention: PropertyNamingConvention 14 | fun put(key: String, value: Any?, namingConvention: PropertyNamingConvention=defaultNamingConvention) 15 | 16 | fun put( 17 | key: KProperty<*>, 18 | value: Any?, 19 | namingConvention: PropertyNamingConvention = PropertyNamingConvention.ConvertToSnakeCase 20 | ) 21 | 22 | operator fun get(key: String,namingConvention: PropertyNamingConvention=defaultNamingConvention): T? 23 | fun get(key: KProperty<*>, namingConvention: PropertyNamingConvention=defaultNamingConvention): T? 24 | 25 | /** 26 | * Property delegate that stores the value in the MapBackedProperties. Use this to create type safe 27 | * properties. 28 | */ 29 | fun property(): ReadWriteProperty 30 | 31 | /** 32 | * Property delegate that stores the value in the MapBackedProperties; uses the customPropertyName instead of the 33 | * kotlin property name. Use this to create type safe properties in case the property name you need overlaps clashes 34 | * with a kotlin keyword or super class property or method. For example, "size" is also a method on 35 | * MapBackedProperties and thus cannot be used as a kotlin property name in a Kotlin class implementing Map. 36 | */ 37 | fun property(customPropertyName: String, defaultValue: T?=null): ReadWriteProperty 38 | 39 | /** 40 | * Helper to manipulate list value objects. 41 | */ 42 | fun getOrCreateMutableList(key: String): MutableList 43 | } 44 | 45 | /** 46 | * Helper function to construct a MapBackedProperties with some content. 47 | * 48 | * Passes on the defaultNamingConvention to withJsonDsl. 49 | */ 50 | fun IJsonDsl.dslObject( 51 | namingConvention: PropertyNamingConvention = defaultNamingConvention, 52 | block: JsonDsl.() -> Unit 53 | )= withJsonDsl(namingConvention=namingConvention, block) 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jillesvangurp/jsondsl/JsonDsl.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | import kotlin.jvm.JvmInline 4 | import kotlin.properties.ReadWriteProperty 5 | import kotlin.reflect.KProperty 6 | 7 | @DslMarker 8 | annotation class JsonDslMarker 9 | 10 | private val re = "(?<=[a-z0-9])[A-Z]".toRegex() 11 | fun String.camelCase2SnakeCase(): String { 12 | return re.replace(this) { m -> "_${m.value}" }.lowercase() 13 | } 14 | 15 | enum class PropertyNamingConvention { 16 | AsIs, 17 | ConvertToSnakeCase 18 | } 19 | 20 | fun String.convertPropertyName(namingConvention: PropertyNamingConvention):String { 21 | return when(namingConvention) { 22 | PropertyNamingConvention.AsIs -> this // e.g. kotlin convention is camelCase 23 | PropertyNamingConvention.ConvertToSnakeCase -> this.camelCase2SnakeCase() 24 | } 25 | } 26 | /** 27 | * Mutable Map of String to Any that normalizes the keys to use underscores. You can use this as a base class 28 | * for creating Kotlin DSLs for Json DSLs such as the Elasticsearch query DSL. 29 | */ 30 | @Suppress("UNCHECKED_CAST") 31 | @JsonDslMarker 32 | open class JsonDsl( 33 | private val namingConvention: PropertyNamingConvention = PropertyNamingConvention.ConvertToSnakeCase, 34 | @Suppress("PropertyName") internal val _properties: MutableMap = mutableMapOf(), 35 | ) : MutableMap by _properties, IJsonDsl { 36 | override val defaultNamingConvention: PropertyNamingConvention = namingConvention 37 | 38 | override fun get(key: String,namingConvention: PropertyNamingConvention) = _properties[key.convertPropertyName(namingConvention)] as T 39 | override fun get(key: KProperty<*>,namingConvention: PropertyNamingConvention) = get(key.name,namingConvention) as T 40 | 41 | override fun put(key: String, value: Any?, namingConvention: PropertyNamingConvention) { 42 | _properties[key.convertPropertyName(namingConvention)] = value 43 | } 44 | override fun put(key: KProperty<*>, value: Any?, namingConvention: PropertyNamingConvention) { 45 | _properties[key.name.convertPropertyName(namingConvention)] = value 46 | } 47 | 48 | /** 49 | * Property delegate that stores the value in the MapBackedProperties. Use this to create type safe 50 | * properties. 51 | */ 52 | override fun property(): ReadWriteProperty { 53 | return object : ReadWriteProperty { 54 | override fun getValue(thisRef: Any, property: KProperty<*>): T { 55 | val propertyName = property.name.convertPropertyName(namingConvention) 56 | return (_properties[propertyName]) as T 57 | } 58 | 59 | override fun setValue(thisRef: Any, property: KProperty<*>, value: T) { 60 | _properties[property.name.convertPropertyName(namingConvention)] = value as Any 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Property delegate that stores the value in the MapBackedProperties; uses the [customPropertyName] instead of the 67 | * kotlin property name. 68 | * 69 | * Use can this to, for example, create type safe properties in case the property name you need overlaps clashes 70 | * with a kotlin keyword or super class property or method. For example, "size" is also a method on 71 | * MapBackedProperties and thus cannot be used as a kotlin property name in a Kotlin class implementing Map. 72 | * 73 | * If you specify a [defaultValue], it is added to the map. 74 | */ 75 | override fun property(customPropertyName: String, defaultValue: T?): ReadWriteProperty { 76 | if(defaultValue!= null) { 77 | _properties[customPropertyName] = defaultValue 78 | } 79 | return object : ReadWriteProperty { 80 | override fun getValue(thisRef: Any, property: KProperty<*>): T { 81 | return _properties[customPropertyName].let { currentValue -> 82 | if(currentValue == null && defaultValue != null) { 83 | _properties[customPropertyName] = defaultValue 84 | } 85 | _properties[customPropertyName] 86 | } as T 87 | } 88 | 89 | override fun setValue(thisRef: Any, property: KProperty<*>, value: T) { 90 | _properties[customPropertyName] = value as Any // cast is needed here apparently 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * Helper to manipulate list value objects. 97 | */ 98 | override fun getOrCreateMutableList(key: String): MutableList { 99 | val list = this[key] as MutableList? 100 | if (list == null) { 101 | this[key] = mutableListOf() 102 | } 103 | return this[key] as MutableList 104 | } 105 | 106 | override fun toString(): String { 107 | return this.json(pretty = true) 108 | } 109 | 110 | } 111 | 112 | fun JsonDsl.json(pretty: Boolean=false) = SimpleJsonSerializer().serialize(this,pretty) 113 | 114 | fun JsonDsl.yaml(includeYamlDocumentStart: Boolean = true) = SimpleYamlSerializer(includeYamlDocumentStart) 115 | .serialize(this) 116 | 117 | fun withJsonDsl( 118 | namingConvention: PropertyNamingConvention = PropertyNamingConvention.AsIs, 119 | block: JsonDsl.() -> Unit 120 | ) = JsonDsl(namingConvention=namingConvention).apply { 121 | block.invoke(this) 122 | } 123 | 124 | @JvmInline 125 | value class RawJson(val value: String) 126 | 127 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jillesvangurp/jsondsl/JsonDslSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | interface JsonDslSerializer { 4 | fun serialize( 5 | properties: JsonDsl, 6 | pretty: Boolean = false, 7 | ): String 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jillesvangurp/jsondsl/SimpleJsonSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | /** 4 | * To keep this framework light weight, I decided to implement my own json serializer. 5 | * It's not that hard and it means that people can use whatever json framework they want. 6 | * 7 | * This is a one way street. This library does not support parsing and JsonDsl model classes are not 8 | * a substitute for a model classes in e.g. kotlinx.serialization. 9 | */ 10 | class SimpleJsonSerializer : JsonDslSerializer { 11 | override fun serialize(properties: JsonDsl, pretty: Boolean): String { 12 | val buf = StringBuilder() 13 | write(buf = buf, indent = 0, indentStep = 2, pretty = pretty, obj = properties) 14 | return buf.toString() 15 | } 16 | 17 | private fun write(buf: StringBuilder, indent: Int, indentStep: Int = 2, pretty: Boolean, obj: Any?) { 18 | when (obj) { 19 | null -> buf.append("null") 20 | is RawJson -> buf.append(obj.value) 21 | is Number -> buf.append(obj.toString()) 22 | is CharSequence -> { 23 | buf.append('"') 24 | obj.toString().escapeJson(buf) 25 | buf.append('"') 26 | } 27 | 28 | is Char -> { 29 | buf.append('"') 30 | obj.toString().escapeJson(buf) 31 | buf.append('"') 32 | } 33 | 34 | is Boolean -> buf.append(obj.toString()) 35 | is Map<*, *> -> { 36 | buf.append("{") 37 | val iterator = obj.entries.filter { it.value != null }.iterator() 38 | while (iterator.hasNext()) { 39 | val entry = iterator.next() 40 | buf.newLine(indent + 1, indentStep, pretty) 41 | buf.append('"') 42 | entry.key.toString().escapeJson(buf) 43 | buf.append("\":") 44 | buf.space(pretty) 45 | write( 46 | buf = buf, 47 | indent = indent + 1, 48 | indentStep = indentStep, 49 | pretty = pretty, 50 | obj = entry.value 51 | ) 52 | if (iterator.hasNext()) { 53 | buf.append(',') 54 | } 55 | } 56 | buf.newLine(indent, indentStep, pretty) 57 | buf.append("}") 58 | } 59 | 60 | is Iterator<*> -> { 61 | writeIterator(buf, obj, indent, indentStep, pretty) 62 | } 63 | is Iterable<*> -> { 64 | writeIterator(buf, obj.iterator(), indent, indentStep, pretty) 65 | } 66 | is Sequence<*> -> { 67 | writeIterator(buf, obj.iterator(), indent, indentStep, pretty) 68 | } 69 | is Array<*> -> { 70 | writeIterator(buf, obj.iterator(), indent, indentStep, pretty) 71 | } 72 | 73 | is CustomValue<*> -> write(buf, indent, indentStep, pretty, obj.value) 74 | 75 | else -> { 76 | // fallback to just treating everything else as a String 77 | buf.append('"') 78 | obj.toString().escapeJson(buf) 79 | buf.append('"') 80 | } 81 | } 82 | } 83 | 84 | private fun writeIterator( 85 | buf: StringBuilder, 86 | iterator: Iterator<*>, 87 | indent: Int, 88 | indentStep: Int, 89 | pretty: Boolean 90 | ) { 91 | buf.append('[') 92 | 93 | while (iterator.hasNext()) { 94 | val v = iterator.next() 95 | buf.newLine(indent + 1, indentStep, pretty) 96 | write(buf = buf, indent = indent + 1, indentStep = indentStep, pretty = pretty, obj = v) 97 | if (iterator.hasNext()) { 98 | buf.append(',') 99 | buf.space(pretty) 100 | } 101 | } 102 | buf.newLine(indent, indentStep, pretty) 103 | buf.append(']') 104 | } 105 | 106 | private fun String.escapeJson(buf: StringBuilder) { 107 | for (c in this) { 108 | when (c) { 109 | '\\' -> { 110 | buf.append('\\') 111 | buf.append(c) 112 | } 113 | 114 | '"' -> { 115 | buf.append('\\') 116 | buf.append(c) 117 | } 118 | 119 | '\b' -> { 120 | buf.append('\\') 121 | buf.append('b') 122 | } 123 | 124 | '\n' -> { 125 | buf.append('\\') 126 | buf.append('n') 127 | } 128 | 129 | '\t' -> { 130 | buf.append('\\') 131 | buf.append('t') 132 | } 133 | 134 | '\r' -> { 135 | buf.append('\\') 136 | buf.append('r') 137 | } 138 | 139 | else -> { 140 | when { 141 | c.isControl() -> { 142 | val code = c.code.toString(16).padStart(4, '0') 143 | buf.append("\\u${code.substring(code.length - 4)}") 144 | } 145 | 146 | else -> { 147 | buf.append(c) 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | private fun Char.isControl(): Boolean = this in '\u0000'..'\u001F' || this in '\u007F'..'\u009F' 155 | 156 | private fun StringBuilder.newLine(indent: Int, indentStep: Int, pretty: Boolean) { 157 | if (pretty) { 158 | append('\n') 159 | append(" ".repeat(indent * indentStep)) 160 | } 161 | } 162 | 163 | private fun StringBuilder.space(pretty: Boolean) { 164 | if (pretty) { 165 | append(' ') 166 | } 167 | } 168 | } 169 | 170 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jillesvangurp/jsondsl/SimpleYamlSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | class SimpleYamlSerializer(val includeYamlDocumentStart: Boolean=true) { 4 | fun serialize(properties: JsonDsl): String { 5 | val buf = StringBuilder() 6 | if(includeYamlDocumentStart) { 7 | buf.append("---\n") 8 | } 9 | write(buf, 0, properties, isRoot = true) 10 | return buf.toString().trimEnd() 11 | } 12 | 13 | private fun write(buf: StringBuilder, indent: Int, obj: Any?, isRoot: Boolean = false) { 14 | when (obj) { 15 | null -> buf.append("null\n") 16 | is RawJson -> buf.append("${obj.value}\n") 17 | is Number, is Boolean -> buf.append("$obj\n") 18 | is String, is CharSequence -> { 19 | if (obj.toString().contains("\n")) { 20 | // Multiline string, use literal style 21 | buf.append("|-\n") 22 | obj.toString().lineSequence().forEach { line -> 23 | buf.append(" ".repeat(indent + 2)) 24 | buf.append(line) 25 | buf.append("\n") 26 | } 27 | } else { 28 | // Single line string 29 | val escaped = obj.toString().yamlEscape() 30 | buf.append("$escaped\n") 31 | } 32 | } 33 | is Map<*, *> -> { 34 | if (!isRoot) buf.append("\n") 35 | writeMap(buf, indent, obj) 36 | } 37 | is Sequence<*> -> { 38 | if (!isRoot) buf.append("\n") 39 | writeIterator(buf, indent, obj.iterator()) 40 | } 41 | is Iterator<*> -> { 42 | if (!isRoot) buf.append("\n") 43 | writeIterator(buf, indent, obj) 44 | } 45 | is Iterable<*> -> { 46 | if (!isRoot) buf.append("\n") 47 | writeIterator(buf, indent, obj.iterator()) 48 | } 49 | is Array<*> -> { 50 | if (!isRoot) buf.append("\n") 51 | writeIterator(buf, indent, obj.iterator()) 52 | } 53 | else -> buf.append("${obj.toString().yamlEscape()}\n") 54 | } 55 | } 56 | 57 | private fun writeMap(buf: StringBuilder, indent: Int, map: Map<*, *>) { 58 | map.entries.filter { it.value != null }.forEach { entry -> 59 | buf.append(" ".repeat(indent)) 60 | buf.append("${entry.key.toString().yamlEscape()}:") 61 | if (entry.value is String && entry.value.toString().contains("\n")) { 62 | // Handle multiline string 63 | buf.append(" |\n") // Note the space before the pipe 64 | val additionalIndent = " ".repeat(indent + 2) // Adjust indent as needed 65 | entry.value.toString().lineSequence().forEach { line -> 66 | buf.append(additionalIndent) 67 | buf.append(line) 68 | buf.append("\n") 69 | } 70 | } else { 71 | buf.append(" ") 72 | // Proceed with normal serialization for other types 73 | write(buf, indent + 2, entry.value) 74 | } 75 | } 76 | } 77 | 78 | private fun writeIterator(buf: StringBuilder, indent: Int, iterable: Iterator<*>) { 79 | iterable.forEach { element -> 80 | buf.append(" ".repeat(indent)) 81 | buf.append("-") 82 | if(element is String) { 83 | if(!element.contains('\n')) { 84 | buf.append(" ") 85 | } 86 | } else { 87 | buf.append(" ") 88 | } 89 | write(buf, indent + 2, element, isRoot = false) 90 | } 91 | } 92 | 93 | } 94 | fun String.yamlEscape(): String { 95 | // Matches YAML special characters and patterns requiring quotes 96 | val specialChars = listOf(':', '#', '[', ']', '{', '}', ',', '&', '*', '?', '|', '-', '<', '>', '=', '!', '%', '@', '\\') 97 | val booleanStrings = setOf("true", "false", "yes", "no", "on", "off", "null") 98 | 99 | // Check for conditions requiring quotes 100 | val needsQuotes = this.any { it in specialChars || it.isWhitespace() } || 101 | this.trim() != this || 102 | this.toIntOrNull() != null || 103 | this.toDoubleOrNull() != null || 104 | booleanStrings.contains(this.lowercase()) || 105 | this.isEmpty() 106 | 107 | return if (needsQuotes) { 108 | // Apply YAML escaping rules 109 | "\"" + this 110 | .replace("\\", "\\\\") // Escape backslashes 111 | .replace("\"", "\\\"") + "\"" // Escape quotes 112 | } else { 113 | this 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jillesvangurp/jsondsl/CustomValueTest.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | import io.kotest.matchers.shouldBe 4 | import kotlin.test.Test 5 | 6 | enum class GradeEnum(override val value: Int) : CustomValue { 7 | A(1), 8 | B(2) 9 | } 10 | 11 | class DslWithEnum : JsonDsl() { 12 | var grade by property() 13 | } 14 | 15 | class CustomValueTest { 16 | 17 | @Test 18 | fun enumSerialization() { 19 | val dsl = DslWithEnum().apply { 20 | grade = GradeEnum.A 21 | } 22 | 23 | dsl.json() shouldBe """{"grade":1}""" 24 | } 25 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jillesvangurp/jsondsl/JsonDslTest.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.kotest.matchers.string.shouldContain 5 | import io.kotest.matchers.string.shouldNotContain 6 | import kotlin.test.Test 7 | 8 | class MyDsl:JsonDsl() { 9 | var foo by property() 10 | // will be snake_cased in the json 11 | var meaningOfLife by property() 12 | // we override the property name here 13 | var l by property>("a_custom_list") 14 | var m by property>() 15 | } 16 | 17 | class JsonDslTest { 18 | 19 | @Test 20 | fun shouldSnakeCaseNames() { 21 | "fooBarFooBar".camelCase2SnakeCase() shouldBe "foo_bar_foo_bar" 22 | "foo_BarFooBar".camelCase2SnakeCase() shouldBe "foo_bar_foo_bar" 23 | "foo1Bar1Foo1Bar".camelCase2SnakeCase() shouldBe "foo1_bar1_foo1_bar" 24 | } 25 | 26 | @Test 27 | fun shouldProduceValidJsonAndPropertyNameHandling() { 28 | // you may want to introduce some shorthand for this in your own dsls 29 | val myDsl = MyDsl().apply { 30 | foo = "Hello\tWorld" 31 | meaningOfLife = 42 32 | l = listOf("1", 2, 3) 33 | m = mapOf(42 to "fortytwo") 34 | } 35 | myDsl.json() shouldBe "{\"foo\":\"Hello\\tWorld\",\"meaning_of_life\":42,\"a_custom_list\":[\"1\",2,3],\"m\":{\"42\":\"fortytwo\"}}" 36 | myDsl.json(pretty = true) shouldBe 37 | """ 38 | { 39 | "foo": "Hello\tWorld", 40 | "meaning_of_life": 42, 41 | "a_custom_list": [ 42 | "1", 43 | 2, 44 | 3 45 | ], 46 | "m": { 47 | "42": "fortytwo" 48 | } 49 | }""".trimIndent() 50 | } 51 | 52 | @Test 53 | fun shouldIndentCorrectly() { 54 | withJsonDsl { 55 | this["f"]= mapOf("f" to mapOf("f" to 1)) 56 | }.json(true) shouldBe """ 57 | { 58 | "f": { 59 | "f": { 60 | "f": 1 61 | } 62 | } 63 | } 64 | """.trimIndent() 65 | 66 | withJsonDsl { 67 | this["f1"] = 1 68 | this["f2"] = withJsonDsl { 69 | this["f1"] = 1 70 | } 71 | }.json(true) shouldBe """ 72 | { 73 | "f1": 1, 74 | "f2": { 75 | "f1": 1 76 | } 77 | } 78 | """.trimIndent() 79 | } 80 | 81 | @Test 82 | fun shouldNotSerializeNullValues() { 83 | withJsonDsl { 84 | this["foo"] = null 85 | this["bar"] = 42 86 | }.json(true) shouldNotContain "foo" 87 | } 88 | 89 | @Test 90 | fun termQueryShouldIndentCorrectly() { 91 | val q = query { 92 | query = term("foo","bar") 93 | } 94 | println(q.json(true)) 95 | } 96 | 97 | @Test 98 | fun shouldRespectSnakeCasing() { 99 | class FooDsl: JsonDsl(namingConvention = PropertyNamingConvention.ConvertToSnakeCase) { 100 | var myFoo by property(customPropertyName = "bar") 101 | var bar by property(customPropertyName = "foobar", defaultValue = "xxx") 102 | } 103 | FooDsl().apply { 104 | myFoo = "foobar" 105 | }.json(pretty = true).let {json -> 106 | println(json) 107 | json.shouldContain(""" 108 | "bar": "foobar" 109 | """.trimIndent()) 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jillesvangurp/jsondsl/YamlTest.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | import io.kotest.matchers.shouldBe 4 | import kotlin.test.Test 5 | 6 | class YamlTest { 7 | val serializer = SimpleYamlSerializer() 8 | 9 | @Test 10 | fun shouldProduceYaml() { 11 | val q = query { 12 | query = term("foo", "bar") { 13 | boost = 2.0 14 | } 15 | } 16 | val yaml = serializer.serialize(q) 17 | println(yaml) 18 | } 19 | 20 | @Test 21 | fun shouldNotAlterSimpleAlphanumericStrings() { 22 | val input = "simpleString123" 23 | input.yamlEscape() shouldBe "simpleString123" 24 | } 25 | 26 | @Test 27 | fun shouldQuoteStringsWithSpecialCharacters() { 28 | val input = "special:string, with:special#characters" 29 | input.yamlEscape() shouldBe "\"special:string, with:special#characters\"" 30 | } 31 | 32 | @Test 33 | fun shouldQuoteStringsThatAreBooleanLiterals() { 34 | val trueString = "true" 35 | trueString.yamlEscape() shouldBe "\"true\"" 36 | 37 | val falseString = "false" 38 | falseString.yamlEscape() shouldBe "\"false\"" 39 | } 40 | 41 | @Test 42 | fun shouldQuoteStringsWithLeadingOrTrailingSpaces() { 43 | val input = " leadingOrTrailingSpaces " 44 | input.yamlEscape() shouldBe "\" leadingOrTrailingSpaces \"" 45 | } 46 | 47 | @Test 48 | fun shouldEscapeBackslashesAndQuotes() { 49 | val input = "string with \\backslash and \"quote\"" 50 | input.yamlEscape() shouldBe "\"string with \\\\backslash and \\\"quote\\\"\"" 51 | } 52 | 53 | @Test 54 | fun shouldQuoteEmptyStrings() { 55 | val input = "" 56 | input.yamlEscape() shouldBe "\"\"" 57 | } 58 | 59 | @Test 60 | fun shouldQuoteNumericStrings() { 61 | val input = "12345" 62 | input.yamlEscape() shouldBe "\"12345\"" 63 | } 64 | 65 | @Test 66 | fun shouldQuoteStringsThatLookLikeNumbers() { 67 | val input = "12.345" 68 | input.yamlEscape() shouldBe "\"12.345\"" 69 | } 70 | 71 | @Test 72 | fun thisShouldBeValid() { 73 | val complexData = mapOf( 74 | "specialCharacters" to "special:string, with:special#characters", 75 | "booleanLiteral" to "true", 76 | "numericString" to "12345", 77 | "multilineString" to """ 78 | Line one 79 | Line two 80 | Line three 81 | """.trimIndent(), 82 | "listOfValues" to listOf( 83 | "simpleValue", 84 | "12.345", 85 | mapOf( 86 | "nestedMap" to mapOf( 87 | "nestedKey" to "nested:value", 88 | "emptyString" to "", 89 | "spaceString" to " ", 90 | "quotedString" to "string with \"quotes\" and \\backslashes\\" 91 | ), 92 | "nestedList" to listOf( 93 | "item1", 94 | "item with spaces", 95 | "true", // Boolean literal as a string 96 | "null" // Null literal as a string 97 | ) 98 | ) 99 | ), 100 | "leadingTrailingSpaces" to " leadingOrTrailingSpaces ", 101 | "booleanTrue" to true, // Actual boolean value 102 | "booleanFalse" to false, // Actual boolean value 103 | "nullValue" to null, // Actual null value 104 | "numericValue" to 67890, 105 | "lorem" to loremIpsumSample 106 | ) 107 | 108 | val yaml = serializer.serialize(withJsonDsl { 109 | this["complex"] = complexData 110 | }) 111 | // FIXME assert some stuff to validate this 112 | // for now, I've passed this through yamllint.com and it seems OK 113 | println(yaml) 114 | } 115 | 116 | @Test 117 | fun shouldNotAddSpaces() { 118 | 119 | val yml = withJsonDsl { 120 | val str = """ 121 | Multi line 122 | Strings are 123 | supported 124 | and 125 | preserve their 126 | indentation! 127 | """.trimIndent() 128 | this["s"] = str 129 | this["a"] = """ 130 | noIndent 131 | twoIndent 132 | fourIndent 133 | """.trimIndent() 134 | }.yaml() 135 | println(yml) 136 | yml.lines().let { lines -> 137 | val indent = lines.first { it.contains("noIndent") }.indexOf("noIndent") 138 | (lines.first { it.contains("twoIndent") }.indexOf("twoIndent") - indent) shouldBe 2 139 | (lines.first { it.contains("fourIndent") }.indexOf("fourIndent") - indent) shouldBe 4 140 | } 141 | } 142 | } 143 | 144 | 145 | val loremIpsumText = """ 146 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum vestibulum. 147 | Sed ac felis sit amet ligula pharetra condimentum. Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, 148 | euismod in, pharetra a, ultricies in, diam. Sed arcu. Cras consequat. 149 | 150 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum vestibulum. 151 | Sed ac felis sit amet ligula pharetra condimentum. Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, 152 | euismod in, pharetra a, ultricies in, diam. Sed arcu. Cras consequat. 153 | 154 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum vestibulum. 155 | Sed ac felis sit amet ligula pharetra condimentum. Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, 156 | euismod in, pharetra a, ultricies in, diam. Sed arcu. Cras consequat. 157 | 158 | 159 | 160 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum vestibulum. 161 | Sed ac felis sit amet ligula pharetra condimentum. Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, 162 | euismod in, pharetra a, ultricies in, diam. Sed arcu. Cras consequat. 163 | 164 | 165 | The end. 166 | """.trimIndent() 167 | 168 | val loremIpsumSample = withJsonDsl { 169 | this["foo"] = withJsonDsl { 170 | this["bar"] = loremIpsumText 171 | this["foo"] = listOf( 172 | loremIpsumText, 173 | loremIpsumText, 174 | loremIpsumText, 175 | loremIpsumText, 176 | ) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jillesvangurp/jsondsl/termquery.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UNCHECKED_CAST") 2 | 3 | package com.jillesvangurp.jsondsl 4 | 5 | import com.jillesvangurp.jsondsl.* 6 | import kotlin.reflect.KProperty 7 | 8 | // BEGIN kt-search-based-example 9 | // using DslMarkers is useful with 10 | // complicated DSLs 11 | @DslMarker 12 | annotation class SearchDSLMarker 13 | 14 | interface QueryClauses 15 | 16 | // abbreviated version of the 17 | // Elasticsearch Query DSL in kt-search 18 | class QueryDsl: 19 | JsonDsl(namingConvention = PropertyNamingConvention.ConvertToSnakeCase), 20 | // helper interface that we define 21 | // extension functions on 22 | QueryClauses 23 | { 24 | // Elasticsearch often wraps objects in 25 | // another object. So we use a custom 26 | // setter here to hide that. 27 | var query: ESQuery 28 | get() { 29 | val map = 30 | this["query"] as Map 31 | val (name, details) = map.entries.first() 32 | // reconstruct the ESQuery 33 | return ESQuery(name, details) 34 | } 35 | set(value) { 36 | // queries extend ESQuery 37 | // which takes care of the wrapping 38 | // via wrapWithName 39 | this["query"] = value.wrapWithName() 40 | }} 41 | 42 | // easy way to create a query 43 | fun query(block: QueryDsl.()->Unit): QueryDsl { 44 | return QueryDsl().apply(block) 45 | } 46 | 47 | @SearchDSLMarker 48 | open class ESQuery( 49 | val name: String, 50 | val queryDetails: JsonDsl = JsonDsl() 51 | ) { 52 | 53 | // Elasticsearch wraps everything in an outer object 54 | // with the name as its only key 55 | fun wrapWithName() = withJsonDsl() { 56 | this[name] = queryDetails 57 | } 58 | } 59 | 60 | // the dsl class for creating term queries 61 | // this is one of the most basic queries 62 | // in elasticsearch 63 | @SearchDSLMarker 64 | class TermQuery( 65 | field: String, 66 | value: String, 67 | termQueryConfig: TermQueryConfig = TermQueryConfig(), 68 | block: (TermQueryConfig.() -> Unit)? = null 69 | ) : ESQuery("term") { 70 | // on init, apply the block to the configuration and 71 | // assign it in the queryDetails from the parent 72 | init { 73 | queryDetails.put(field, termQueryConfig, PropertyNamingConvention.AsIs) 74 | termQueryConfig.value = value 75 | block?.invoke(termQueryConfig) 76 | } 77 | } 78 | 79 | // configuration for term queries 80 | // this is a subset of the supported 81 | // properties. 82 | class TermQueryConfig : JsonDsl() { 83 | var value by property() 84 | var boost by property() 85 | } 86 | 87 | // making this an extension function ensures it is available 88 | // on the receiver of the query {..} block 89 | fun QueryClauses.term( 90 | field: String, 91 | value: String, 92 | block: (TermQueryConfig.() -> Unit)? = null 93 | ) = 94 | TermQuery(field, value, block = block) 95 | 96 | // of course users of this DSL would 97 | // be storing json documents in elasticsearch 98 | // and they probably have model classes with 99 | // properties. 100 | // so supporting property references 101 | // for field names is a nice thing 102 | fun QueryClauses.term( 103 | field: KProperty<*>, 104 | value: String, 105 | block: (TermQueryConfig.() -> Unit)? = null 106 | ) = 107 | TermQuery(field.name, value, block = block) 108 | 109 | // END kt-search-based-example 110 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/com/jillesvangurp/jsondsl/jssupport.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl 2 | 3 | /** 4 | * Extension function to convert the Kotlin JsonDsl to an equivalent Javascript object that you 5 | * can pass as a parameter to Javascript libraries. 6 | * 7 | * This enables you to use JsonDsl as an alternative to complex type mappings for such libraries. 8 | * Since JSON is effectively valid javascript, a lot of javascript libraries use some form 9 | * of JSON as their input. 10 | * 11 | * Returns a Javascript object from the Kotlin JsonDsl. 12 | */ 13 | fun JsonDsl.toJsObject(): dynamic { 14 | fun convert(obj: Any?): dynamic { 15 | return when (obj) { 16 | null -> null 17 | is RawJson -> JSON.parse(obj.value) 18 | is Number -> obj.toDouble() // some number types come out looking a bit funny otherwise 19 | is Boolean, is String -> obj 20 | is Char -> obj.toString() 21 | is Map<*, *> -> { 22 | val jsObj = js("{}") 23 | obj.forEach { (key, value) -> 24 | if (key is String) { 25 | jsObj[key] = convert(value) 26 | } 27 | } 28 | jsObj 29 | } 30 | is List<*> -> obj.map { convert(it) }.toTypedArray() 31 | is Set<*> -> obj.map { convert(it) }.toTypedArray() 32 | is Array<*> -> obj.map { convert(it) }.toTypedArray() 33 | is Iterator<*> -> obj.asSequence().map { convert(it) }.toList().toTypedArray() 34 | is Iterable<*> -> obj.map { convert(it) }.toTypedArray() 35 | is CustomValue<*> -> convert(obj.value) 36 | else -> obj.toString() // Fallback for unknown types 37 | } 38 | } 39 | return convert(this) 40 | } -------------------------------------------------------------------------------- /src/jvmTest/kotlin/com/jillesvangurp/jsondsl/readme/ReadmeGenerationTest.kt: -------------------------------------------------------------------------------- 1 | package com.jillesvangurp.jsondsl.readme 2 | 3 | import com.jillesvangurp.jsondsl.* 4 | import com.jillesvangurp.kotlin4example.SourceRepository 5 | import kotlin.test.Test 6 | import java.io.File 7 | import kotlin.math.PI 8 | import kotlin.reflect.KProperty 9 | 10 | const val githubLink = "https://github.com/jillesvangurp/json-dsl" 11 | 12 | val sourceGitRepository = SourceRepository( 13 | repoUrl = githubLink, 14 | sourcePaths = setOf("src/commonMain/kotlin", "src/commonTest/kotlin","src/jvmTest/kotlin") 15 | ) 16 | 17 | // grades-enum 18 | enum class Grades(override val value: Double) : CustomValue { 19 | Excellent(7.0), 20 | Pass(5.51), 21 | Fail(3.0), 22 | ; 23 | } 24 | // grades-enum 25 | 26 | class ReadmeGenerationTest { 27 | 28 | @Test 29 | fun `generate docs`() { 30 | File(".", "README.md").writeText( 31 | """ 32 | # JsonDsl 33 | 34 | """.trimIndent().trimMargin() + "\n\n" + readmeMd.value 35 | ) 36 | } 37 | } 38 | 39 | val readmeMd = sourceGitRepository.md { 40 | includeMdFile("intro.md") 41 | 42 | section("Examples") { 43 | +""" 44 | All the examples in this README are implemented using 45 | my [kotlin4example](https://github.com/jillesvangurp/kotlin4example) library. You can find 46 | the source code that generates this README ${mdLinkToSelf("here")}. 47 | 48 | A more expanded version of these examples can be found in the form of a 49 | Jupyter notebook [here](https://github.com/jillesvangurp/json-dsl-jupyter) 50 | """.trimIndent() 51 | subSection("Hello World") { 52 | +""" 53 | Let's start with a simple example. 54 | """.trimIndent() 55 | example { 56 | class MyDsl : JsonDsl() { 57 | // adds a string property that the user can assign 58 | var message by property() 59 | } 60 | 61 | // a helper function to create MyDsl instances 62 | fun myDsl(block: MyDsl.() -> Unit): MyDsl { 63 | return MyDsl().apply(block) 64 | } 65 | 66 | val json = myDsl { 67 | message = "Hello world" 68 | }.json(pretty = true) 69 | 70 | println(json) 71 | }.let { 72 | +""" 73 | The json extension function uses a json serializer to produce 74 | pretty printed json: 75 | """.trimIndent() 76 | 77 | mdCodeBlock(it.stdOut, type = "json") 78 | 79 | +""" 80 | There is also a YAML serializer. More on that below. 81 | """.trimIndent() 82 | } 83 | } 84 | 85 | subSection("Common Kotlin Types") { 86 | +""" 87 | JSON is a fairly simple data format. It has numbers, booleans, strings, lists and dictionaries. And null 88 | values. Kotlin has a bit richer type system and mapping that to JSON is key to providing rich Kotlin DSL. 89 | 90 | JsonDsl does a best effort to do map Kotlin types correctly to the intended JSON equivalent. 91 | 92 | It understands all the primitives, Maps and Lists. But also Arrays, Sets, Sequences, etc. 93 | And of course other JsonDsl classes, so you can nest them. And it falls back to using 94 | `toString()` for everything else. 95 | """.trimIndent() 96 | 97 | example { 98 | class MyDsl : JsonDsl() { 99 | var intVal by property() 100 | var boolVal by property() 101 | var doubleVal by property() 102 | var arrayVal by property>() 103 | var listVal by property>() 104 | var mapVal by property>() 105 | var idontknow by property() 106 | } 107 | 108 | // using kotlin's apply here, you can add helper functions of course 109 | MyDsl().apply { 110 | intVal = 1 111 | boolVal = true 112 | doubleVal = PI 113 | arrayVal = arrayOf("hello", "world") 114 | listVal = listOf("1", "2") 115 | mapVal = mapOf( 116 | "Key" to "Value" 117 | ) 118 | 119 | // The Any type is a bit of free for all 120 | idontknow = mapOf( 121 | "arrays" to arrayOf( 122 | 1, 2, "3", 4.0, 123 | mapOf("this" to "is valid JSON"), "mixing types is allowed in JSON" 124 | ), 125 | "sequences" to sequenceOf(1,"2",3.0) 126 | ) 127 | } 128 | }.result.getOrThrow()!!.let { 129 | +""" 130 | This does the right things with all the used Kotlin types, including `Any`: 131 | """.trimIndent() 132 | mdCodeBlock(it.json(true), type = "json") 133 | } 134 | } 135 | subSection("Manipulating the Map directly") { 136 | +""" 137 | As mentioned, JsonDsl delegates the storing of properties to a `MutableMap`. 138 | 139 | So, all sub classes have direct access to that map. And you can put anything you want into it. 140 | """.trimIndent() 141 | example { 142 | class MyDsl : JsonDsl() { 143 | var foo by property() 144 | } 145 | 146 | MyDsl().apply { 147 | // nicely typed. 148 | foo = "bar" 149 | 150 | // but we never defined a bar property 151 | this["bar"] = "foo" 152 | // or this ... 153 | this["whatever"] = listOf( 154 | MyDsl().apply { 155 | this["you"] = "can add anything you want" 156 | }, 157 | 42 158 | ) 159 | 160 | // RawJson is a Kotlin value class 161 | this["inline_json"] = RawJson(""" 162 | { 163 | "if":"you need to", 164 | "you":"can even add json in string form", 165 | } 166 | """.trimIndent()) 167 | } 168 | }.result.getOrThrow()!!.let { 169 | mdCodeBlock(it.json(true), type = "json") 170 | } 171 | } 172 | subSection("snake_casing, custom names, defaults") { 173 | +""" 174 | A lot of JSON dialects use snake cased field names. Kotlin of course uses 175 | camel case for its identifiers and it has certain things that you can't redefine. 176 | Like the `size` property on `Map`, which is implemented by JsonDsl; or certain keywords. 177 | 178 | """.trimIndent() 179 | 180 | example { 181 | class MyDsl : JsonDsl( 182 | // this will snake case all the names 183 | namingConvention = PropertyNamingConvention.ConvertToSnakeCase 184 | ) { 185 | // -> camel_case 186 | var camelCase by property() 187 | // unfortunately Map defines a size val already 188 | var mySize by property( 189 | customPropertyName = "size" 190 | ) 191 | // val is a keyword in Kotlin 192 | var myVal by property( 193 | customPropertyName = "val" 194 | ) 195 | // Kotlin has default values, JSON does not. 196 | var m by property( 197 | customPropertyName = "meaning_of_life", 198 | defaultValue = 42 199 | ) 200 | } 201 | 202 | MyDsl().apply { 203 | camelCase = true 204 | mySize = Int.MAX_VALUE 205 | myVal = "hello" 206 | } 207 | }.result.getOrThrow()!!.let { 208 | mdCodeBlock(it.json(true), type = "json") 209 | } 210 | } 211 | subSection("Custom values") { 212 | +""" 213 | Sometimes you want to have the serialized version of a value be different 214 | from the kotlin type that you are using. For this we have added the 215 | CustomValue interface. 216 | 217 | A simple use case for this could be Enums: 218 | """.trimIndent() 219 | 220 | exampleFromSnippet(ReadmeGenerationTest::class,"grades-enum") 221 | example { 222 | println(withJsonDsl { 223 | this["grade"] = Grades.Excellent 224 | }.json(true)) 225 | }.let { 226 | +""" 227 | Note how the grade's Double value is used instead of the name. 228 | 229 | The withJsonDsl function is a simple extension function that 230 | creates a JsonDsl for you and applies the block to it. 231 | """.trimIndent() 232 | mdCodeBlock(it.stdOut,"json") 233 | } 234 | 235 | +""" 236 | You can also construct more complex ways to serialize your classes. 237 | """.trimIndent() 238 | 239 | example { 240 | data class Person( 241 | val firstName: String, 242 | val lastName: String): CustomValue> { 243 | override val value = 244 | listOf(firstName, lastName) 245 | } 246 | 247 | withJsonDsl { 248 | this["person"] = Person("Jane", "Doe") 249 | }.json(true) 250 | } 251 | 252 | +""" 253 | And of course your custom value can be a JsonDsl too. 254 | """.trimIndent() 255 | example { 256 | data class Person( 257 | val firstName: String, 258 | val lastName: String): CustomValue { 259 | override val value = withJsonDsl { 260 | this["person"] = withJsonDsl { 261 | this["fn"] = firstName 262 | this["ln"] = lastName 263 | this["full_name"] = "$firstName $lastName" 264 | } 265 | } 266 | } 267 | } 268 | 269 | +""" 270 | You can also rely on the `toString()` function: 271 | """.trimIndent() 272 | example { 273 | data class FooBar(val foo:String="foo", val bar: String="bar") 274 | println(withJsonDsl { 275 | this["foo"]=FooBar() 276 | }) 277 | }.let { 278 | +""" 279 | Note how it simply uses `toString()` on the data class 280 | """.trimIndent() 281 | mdCodeBlock(it.stdOut,"json") 282 | +""" 283 | This also works for things like enums, value classes, and other Kotlin language constructs. 284 | """.trimIndent() 285 | } 286 | } 287 | } 288 | section("YAML") { 289 | +""" 290 | While initially written to support JSON, I also added a YAML serializer that you may use to 291 | create Kotlin DSLs for YAML based DSLs. So, you could use this to build Kotlin DSLs for things 292 | like Github actions, Kubernetes, or other common things that use YAML. 293 | """.trimIndent() 294 | 295 | example { 296 | class YamlDSL : JsonDsl() { 297 | var str by property() 298 | var map by property>() 299 | var list by property>() 300 | } 301 | val dsl = YamlDSL().apply { 302 | str=""" 303 | Multi line 304 | Strings are 305 | supported 306 | and 307 | preserve their 308 | indentation! 309 | """.trimIndent() 310 | map = mapOf( 311 | "foo" to "bar", 312 | "num" to PI, 313 | "bool" to true, 314 | "notABool" to "false" 315 | ) 316 | } 317 | // default is true for including --- 318 | print(dsl.yaml(includeYamlDocumentStart = false)) 319 | }.let { 320 | +""" 321 | This prints the YAML below: 322 | """.trimIndent() 323 | 324 | mdCodeBlock(it.stdOut,"yaml", reIndent = false) 325 | } 326 | +""" 327 | There are other tree like formats that might be supported in the future like TOML, properties, 328 | and other formats. I welcome pull requests for this provided they don't add any library dependencies. 329 | """.trimIndent() 330 | 331 | } 332 | section("A real life, complex example") { 333 | +""" 334 | Here's a bit of the kt-search Kotlin DSL that I lifted 335 | from my kt-search library. It implements a minimal 336 | query and only supports one of the (many) types of queries 337 | supported by Elasticsearch. 338 | 339 | Like many real life 340 | JSON, the Elasticsearch DSL is quite complicated and challenging 341 | to model. This is why I created this library. 342 | 343 | The code below is a good illustration of several things you can 344 | do in Kotlin to make life nice for your DSL users. 345 | """.trimIndent() 346 | exampleFromSnippet("com/jillesvangurp/jsondsl/termquery.kt", "kt-search-based-example", allowLongLines = true) 347 | +""" 348 | And this is how your users would use this DSL. 349 | """.trimIndent() 350 | example { 351 | class MyModelClassInES(val myField: String) 352 | val q = query { 353 | query = term(MyModelClassInES::myField, "some value") 354 | } 355 | val pretty = q.json(pretty = true) 356 | println(pretty) 357 | }.let { 358 | +""" 359 | So, we created a query outer object with a query property. 360 | And we assigned a term query instance to that. 361 | 362 | In JSON form this looks as follows: 363 | """.trimIndent() 364 | mdCodeBlock(it.stdOut,"json") 365 | +""" 366 | Note how it correctly wrapped the term query with an object. And how it correctly 367 | assigns the `TermConfiguration` in an object that has the field value as the key. 368 | Also note how we use a property reference here to avoid having to use 369 | a string literal. 370 | 371 | Of course, the Elasticsearch Query DSL support in 372 | [kt-search](https://github.com/jillesvangurp/kt-search) is a great 373 | reference for how to use JsonDsl. 374 | """.trimIndent() 375 | } 376 | } 377 | includeMdFile("outro.md") 378 | } 379 | 380 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/com/jillesvangurp/jsondsl/readme/intro.md: -------------------------------------------------------------------------------- 1 | [![Process Pull Request](https://github.com/jillesvangurp/json-dsl/actions/workflows/pr_master.yaml/badge.svg)](https://github.com/jillesvangurp/json-dsl/actions/workflows/pr_master.yaml) 2 | 3 | JsonDsl is a multi platform kotlin library that helps you build Kotlin DSLs for JSON and YAML dialects. 4 | DSLs made with this library are easy to extend with custom fields by users via a MutableMap. 5 | 6 | # About DSLs 7 | 8 | A Domain Specific Language (DSL) is a language that is intended to allow users to program or specify things in language that closely matches their domain. They are popular for configuration files, for use with certain frameworks or tools, and there are a lot of niche tools, frameworks, and other software packages out there that implement them. 9 | 10 | Some general purpose languages have a syntax that makes it easy to (ab)use their features to do something similar. Lisp and Ruby are a good example of languages that have historically been used for this. Like those languages, Kotlin includes a few features that enable this. Which makes Kotlin very well suited for implementing all sorts of DSLs. 11 | 12 | There are Kotlin DSLs for all sorts of things. A popular example is the HTML DSL that comes with things like Ktor and kotlin-js. Spring bundles a lot of Kotlin DSLs for it's Java framework that make using that a lot nicer than from Java. There are lots of examples. 13 | 14 | ## Making easy to extend Kotlin DSLs for JSON/YAML dialects 15 | 16 | Of course, creating model classes for your JSON domain model and annotating thosre with annotations for e.g. `kotlinx.serialization`, `jackson`, etc. is a perfectly valid way to start creating a DSL for your JSON or YAML dialect of choice. 17 | 18 | However, using JSON frameworks like that have some limitations. What if your JSON dialect evolves and somebody adds some new features? Unless you change your model class, it would not be possible to access such new features via the Kotlin DSL. Or what if your JSON dialect is vast and complicated. Do you have to support all of its features? How do you decide what to allow and not allow in your Kotlin DSL. 19 | 20 | This library started out as few classes in my [kt-search](https://github.com/jillesvangurp/kt-search) project. Kt-search implements a client library for Elasticsearch and Opensearch. Elasticsearch has several JSON dialects that are used for querying, defining index mappings, settings, and a few other things. Especially the query language has a large number of features and is constantly evolving. 21 | 22 | Not only do I have to worry about implementing each and every little feature these DSLs have and keeping up with upstream additions to OpenSearch and Elasticsearch. I also have to worry about supporting query and mapping features added via custom plugins. This is very challenging. And it was the main reason I created json-dsl: so I don't have to keep up. 23 | 24 | ## Strongly typed and Flexible 25 | 26 | The key feature in json-dsl is that it uses a `MutableMap` and property delegation for implementing DSL classes. This simple approach enables you to define classes with properties that delegate storing their value to this map. For anything that your 27 | classes don't implement, the user can simply modify the underlying map directly using a simple `put`. 28 | 29 | This simple approach gives users a nice fallback for things your DSL classes don't implement and it relieves Kotlin DSL creators from having to provide support for every new feature the upstream JSON dialect has or adds over time. You can provide a decent experience for your users with minimal effort. And you users can always work around whatever you did not implement. 30 | 31 | With kt-search, I simply focus on supporting all the commonly used, and some less commonly used things in the Elastic DSLs. But for everything else, I just rely on letting the user modify the underlying map themselves. A lot of pull requests I get on this project are people adding features they need in the DSLs. So, over time, feature support has gotten more comprehensive. 32 | 33 | ## Gradle 34 | 35 | This library is published to our own maven repository. Simply add the repository like this: 36 | 37 | ```kotlin 38 | repositories { 39 | mavenCentral() 40 | maven("https://maven.tryformation.com/releases") { 41 | // optional but it speeds up the gradle dependency resolution 42 | content { 43 | includeGroup("com.jillesvangurp") 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | And then you can add the dependency: 50 | 51 | ```kotlin 52 | // check the latest release tag for the latest version 53 | implementation("com.jillesvangurp:json-dsl:3.x.y") 54 | ``` 55 | 56 | If you were using json-dsl via kt-search before, you can update simply by bumping the version of json-dsl to 3.0. Previously, 2.x was released along with kt-search and has now been removed from that project. 57 | 58 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/com/jillesvangurp/jsondsl/readme/outro.md: -------------------------------------------------------------------------------- 1 | ## Multi platform 2 | 3 | This is a Kotlin multi platform library that should work on most kotlin platforms (jvm, js, ios, wasm, linux/windows/mac native, android, etc). 4 | 5 | My intention is to keep this code very portable and lightweight and not introduce any dependencies other than the Kotlin standard library. 6 | 7 | ## Using JsonDsl with Javascript libraries 8 | 9 | If you use kotlin-js, there are a lot of Javascript libraries out there that have functions that expect some sort of Javascript objects as a parameter. Integrating such libraries into Kotlin typically requires writing some type mappings to be able to call functions in these libraries and defining external class definitions for any models. 10 | 11 | With JsonDsl you can skip a lot of these class definitions and simply create a Kotlin DSL instead. Or use simply use it in schemaless mode and rely on the convenient mappings included for the default Kotlin collection classes. You can use lists, enums, maps, etc. and it will do the right thing. You have the full flexibility of JsonDsl to make things as type safe as you need them to be. 12 | 13 | There is a `toJsObject()` extension function that is available in kotlin-js that you can use to convert any JsonDsl instance to a javascript object. 14 | 15 | ## Parsing 16 | 17 | This library is not intended as a substitute for kotlinx.serialization or other JSON parsing frameworks. It is instead intended for writing Kotlin DSLs that you can use to generate valid JSON or YAML. JsonDsl does not support any form of parsing. 18 | 19 | ## Development and stability 20 | 21 | Before I extracted it from there, this library was part of [kt-search](https://github.com/jillesvangurp/kt-search), which has been out there for several years and has a steadily growing user base. So, even though this library is relatively new, the code base has been stable and actively used for several years. 22 | 23 | Other than cleaning the code a bit up for public use, there were no compatibility breaking changes. I want to keep the API for json-dsl stable and will not make any major changes unless there is a really good reason. 24 | 25 | This also means there won't be a lot of commits or updates since things are stable and pretty much working as intended. And because I have a few users of kt-search, I also don't want to burden them with compatibility breaking changes. Unless somebody finds a bug or asks for reasonable changes, the only changes likely to happen will be occasional dependency updates. 26 | 27 | ## Libraries using Json Dsl 28 | 29 | - [kt-search](https://github.com/jillesvangurp/kt-search) 30 | 31 | If you use this and find it useful, please add yourself to the list by creating a pull request on 32 | [outro.md](src/jvmTest/com/jillesvangurp/jsondsl/readme/outro.md) as this readme is generated using 33 | my [kotlin4example](https://github.com/jillesvangurp/kotlin4example) library. 34 | -------------------------------------------------------------------------------- /versions.properties: -------------------------------------------------------------------------------- 1 | #### Dependencies and Plugin versions with their available updates. 2 | #### Generated by `./gradlew refreshVersions` version 0.60.5 3 | #### 4 | #### Don't manually edit or split the comments that start with four hashtags (####), 5 | #### they will be overwritten by refreshVersions. 6 | #### 7 | #### suppress inspection "SpellCheckingInspection" for whole file 8 | #### suppress inspection "UnusedProperty" for whole file 9 | 10 | version.com.github.jillesvangurp..kotlin4example=1.1.6 11 | 12 | version.junit.jupiter=5.11.4 13 | 14 | version.kotest=5.9.1 15 | ## # available=6.0.0.M1 16 | 17 | version.kotlin=2.1.0 18 | ## # available=2.1.10-RC 19 | ## # available=2.1.20-Beta1 20 | --------------------------------------------------------------------------------