Spring REST Docs DSL
Provides a convenient way to document and test APIs with Spring REST Docs leveraging Kotlin DSL.
Our primary goal is to :
- Document APIs using Spring REST Docs
- Preserve coherent order between JSON and documentation
- Make API documentation code more readable
- Enable view filtering
This library comes with 3 levels of maturity (AutoDsl, Reflection and Standard), each one alleviating the boilerplate you need to write to document your API.
Index
Installation
Spring REST Docs DSL depends on Kotlin standard library and Spring REST Docs.
The current release is 0.6.2.
Configuration
Maven
<dependency>
<groupId>com.github.jntakpe</groupId>
<artifactId>spring-restdocs-dsl</artifactId>
<version>0.6.2</version>
<scope>test</scope>
</dependency>
Gradle
testImplementation 'com.github.jntakpe:spring-restdocs-dsl:0.6.2'
If you want to use autoDsl feature you must also add
compileOnly 'com.github.jntakpe:spring-restdocs-dsl-annotations:0.6.2'
compileOnly 'com.github.jntakpe:spring-restdocs-dsl-core:0.6.2'
testImplementation 'com.github.jntakpe:spring-restdocs-dsl-core:0.6.2'
kapt 'com.github.jntakpe:spring-restdocs-dsl-processor:0.6.2'
Usage
Sample
Given the following JSON document :
{
"module" : "",
"questions" : [ {
"label" : "",
"configuration" : {
"duration" : "",
"code" : false,
"multipleChoice" : false
},
"answerOptions" : [{
"label" : "",
"valid" : false,
"id" : ""
}],
"answers" : [ ],
"valid" : false,
"id" : ""
}],
"configuration" : {
"shuffled" : false,
"duration" : ""
},
"id" : ""
}
AutoDsl
AutoDsl generates some helper functions from your Kotlin classes.
Configuration
You can configure AutoDsl globally thanks to @EnableRestDocsAutoDsl
annotation. It has the following options :
Name | Kind | Description | Default |
---|---|---|---|
packages | Array | Packages containing classes to generate DSL from. As an alternative you can indivually mark such classes with @Doc annotation | empty |
trimSuffixes | Array | Trims suffixes of generated DSL function e.g. PetDto is generated as pet {} instead of petDto {} |
empty |
Note: Kapt is triggered before Kotlin compilation. If you use Intellij, kapt is not currently supported. To overcome this it is recommended to use Gradle to build your project. To do so choose ‘Gradle’ in ‘Settings > Build, Execution, Deployment > Build Tools > Gradle > Build.
Usage
Kapt generates DSL functions you can then use like this :
val initDoc = {
durationType = String::class
answerOption {
label = "Option's label"
valid = "Indicates if the option is valid"
id = "Option's unique identifier"
}
questionConfiguration {
duration = "Question's maximum duration"
code = "Indicates if the label should be formatted as code"
multipleChoice = "Indicates if question accepts multiple answers"
}
question {
label = "Question's label"
configuration = "Object containing question's configuration"
answerOptions = "Array containing the different possible answer options for the question"
answers = "Array containing the answer given by an user"
valid = "Field indicating if the given answer is valid"
id = "Question's unique identifier"
}
quizConfiguration {
duration = "Duration of the quiz. Equivalent of the total duration of all questions"
shuffled = "Indicates if the questions should be shuffled"
}
quiz {
module = "Module related to the quiz"
questions = "Array containing quiz questions"
configuration = "Object containing quiz configuration"
id = "Quiz unique identifier"
}
}
With AutoDsl you just have to type field's description. The rest is inferred thanks to reflection.
Note about initDoc
: if you use IntelliJ and chose to run your tests using JUnit, you need to call initDoc()
method in either a @BeforeAll
or a @BeforeEach
function ; otherwise it won't get evaluated. Using Gradle works thine.
Note about external classes : in this example we use java.time.Duration
which we don't own. AutoDsl identifies those classes alongside those not picked up by EnableRestDocsAutoDsl.packages
as external classes.
It then leaves you with 2 options regarding those classes :
- Document their fields like others. In this case syntax differs a bit and uses reflection syntax. For
java.time.Duration
a durationDoc field is generated which we would initialize like :
durationDoc = root {
field(Duration::nano, "Nanoseconds")
field(Duration::seconds, "seconds")
field(Duration::units, "Unit")
}
- Else you might have defined a custom way to serialize this type. In this case, for
java.time.Duration
as an example you can simply use the durationType field and pass it a Kotlin type matching the Json type once serialized :
durationType = String::class
// or if you serialize it with nanos
durationType = Long::class
Note : in your tests you can import auto-generated FieldDescriptors e.g. given a Quiz class, you can import a quizDoc top-level property.
Reflection
Reflection API brings some syntactic sugar compared to standard usage. Especially it alleviates :
- Path is inferred from given KProperty e.g. instead of
you can just passQuizDTO::module.name
QuizDTO::module
- Type is also inferred. You can just use the
field()
method instead ofstring(), boolean(), json(), array()...
- View and optionality are inferred
Note : in order to use it you must also add kotlin-reflect to your test classpath.
This enables us to write this :
val answerOptionDoc by obj {
field(AnswerOptionDTO::label, "Option's label")
field(AnswerOptionDTO::valid, "Indicates if the option is valid")
field(AnswerOptionDTO::id, "Option's unique identifier")
}
val questionConfigurationDoc by obj {
field<String>(QuestionConfigurationDTO::duration, "Question's maximum duration")
field(QuestionConfigurationDTO::code, "Indicates if the label should be formatted as code")
field(QuestionConfigurationDTO::multipleChoice, "Indicates if question accepts multiple answers")
}
val questionDoc by obj {
field(QuestionDTO::label, "Question's label")
field(QuestionDTO::configuration, questionConfigurationDoc, "Object containing question's configuration")
field(QuestionDTO::answerOptions, answerOptionDoc, "Array containing the different possible answer options for the question")
field(QuestionDTO::answers, "Array containing the answer given by an user")
field(QuestionDTO::valid, "Field indicating if the given answer is valid")
field(QuestionDTO::id, "Question's unique identifier")
}
val quizConfigurationDoc by obj {
field<String>(QuizConfigurationDTO::duration, "Duration of the quiz. Equivalent of the total duration of all questions")
field(QuizConfigurationDTO::shuffled, "Indicates if the questions should be shuffled")
}
val quizDoc by obj {
field(QuizDTO::module, "Module related to the quiz")
field(QuizDTO::questions, questionDoc, "Array containing quiz questions")
field(QuizDTO::configuration, quizConfigurationDoc, "Object containing quiz configuration")
field(QuizDTO::id, "Quiz unique identifier")
}
If you need to document an array of something (e.g. QuizDTO) you can use :
// reusing previously defined quizDoc
val quizzesDoc by arr<QuizDTO>(quizDoc) // Description will be inferred from reified type
// or if you want to explicitly define the description
val explicitQuizzesDoc by arr<QuizDTO>(quizDoc, "An array of quizzes")
If you need to enforce JSON type of a field e.g. java.time.Duration
you can used reified field()
method like :
field<String>(QuestionConfigurationDTO::duration, "Question's maximum duration")
Standard API
Using the standard Kotlin DSL, we write :
private fun quizResponse() = responseFields(quizDesc())
private fun quizDesc() = root {
string(QuizDTO::module.name, "Module related to the quiz")
array(QuizDTO::questions.name, "Array containing quiz questions") {
fields += questionDesc()
}
json(QuizDTO::configuration.name, "Object containing quiz configuration") {
string(QuizConfigurationDTO::duration.name, "Duration of the quiz. Equivalent of the total duration of all questions")
boolean(QuizConfigurationDTO::shuffled.name, "Indicates if the questions should be shuffled")
}
string(QuizDTO::id.name, "Quiz unique identifier")
}
private fun questionDesc() = root {
string(QuestionDTO::label.name, "Question's label")
json(QuestionDTO::configuration.name, "Object containing question's configuration") {
string(QuestionConfigurationDTO::duration.name, "Question's maximum duration")
boolean(QuestionConfigurationDTO::code.name, "Indicates if the label should be formatted as code")
boolean(QuestionConfigurationDTO::multipleChoice.name, "Indicates if question accepts multiple answers")
}
array(QuestionDTO::answerOptions.name, "Array containing the different possible answer options for the question") {
string(AnswerOptionDTO::label.name, "Option's label")
boolean(AnswerOptionDTO::valid.name, "Indicates if the option is valid")
string(AnswerOptionDTO::id.name, "Option's unique identifier")
}
array(QuestionDTO::answers.name, "Array containing the answer given by an user")
boolean(QuestionDTO::valid.name, "Field indicating if the given answer is valid")
string(QuestionDTO::id.name, "Question's unique identifier")
}
It feels natural and close to JSON syntax !
WebTestClient usage
In order to use those FieldDescriptors in our tests, some helpers are also provided :
// given our quizDoc previously written
quizDoc.asList<QuizDTO>() // An array of quizzes
quizDoc.asReq() // In request payload
quizDoc.asResp() // In response payload
quizDoc.asList<QuizDTO>().asResp() // Array of quizzes in response payload
quizDoc.asList<QuizDTO>("A list of quizzes").asReq() // Array of quizzes with explicit description in request payload
Standard Spring REST Docs usage
Using standard Spring REST Docs, we write :
private fun quizResponse() = responseFields(quizDesc())
private fun quizDesc() = mutableListOf(
fieldWithPath(QuizDTO::id.name).type(STRING).description("Quiz unique identifier"),
fieldWithPath(QuizDTO::questions.name).type(ARRAY).description("Array containing the quiz questions")
)
.apply { addAll(questionDesc("${QuizDTO::questions.name}[].")) }
.apply {
addAll(listOf(
fieldWithPath(QuizDTO::module.name).type(STRING).description("Module related to the quiz"),
fieldWithPath(QuizDTO::configuration.name).type(OBJECT).description("Object containing quiz configuration"),
fieldWithPath("${QuizDTO::configuration.name}.${QuizConfigurationDTO::duration.name}").type(STRING).description("Duration of the quiz. Equivalent of the total duration of all questions"),
fieldWithPath("${QuizDTO::configuration.name}.${QuizConfigurationDTO::shuffled.name}").type(BOOLEAN).description("Indicates if the questions should be shuffled")
))
}
fun questionDesc(prefix: String) = listOf(
fieldWithPath("$prefix${QuestionDTO::label.name}").type(STRING).description("Question's label"),
fieldWithPath("$prefix${QuestionDTO::configuration.name}").type(OBJECT).description("Object containing question's configuration"),
fieldWithPath("$prefix${QuestionDTO::configuration.name}.${QuestionConfigurationDTO::duration.name}").type(STRING).description("Question's maximum duration"),
fieldWithPath("$prefix${QuestionDTO::configuration.name}.${QuestionConfigurationDTO::code.name}").type(BOOLEAN).description("Indicates if the label should be formatted as code"),
fieldWithPath("$prefix${QuestionDTO::configuration.name}.${QuestionConfigurationDTO::multipleChoice.name}").type(BOOLEAN).description("Indicates if question accepts multiple answers"),
fieldWithPath("$prefix${QuestionDTO::answerOptions.name}").type(ARRAY).description("Array containing the different possible answer options for the question"),
fieldWithPath("$prefix${QuestionDTO::answerOptions.name}[].${AnswerOptionDTO::label.name}").type(STRING).description("Option's label"),
fieldWithPath("$prefix${QuestionDTO::answerOptions.name}[].${AnswerOptionDTO::valid.name}").type(BOOLEAN).description("Indicates if the option is valid"),
fieldWithPath("$prefix${QuestionDTO::answerOptions.name}[].${AnswerOptionDTO::id.name}").type(STRING).description("Option's unique identifier"),
fieldWithPath("$prefix${QuestionDTO::answers.name}").type(ARRAY).description("Array containing the given answer options identifiers"),
fieldWithPath("$prefix${QuestionDTO::valid.name}").type(BOOLEAN).description("Field indicating if the given answer is valid"),
fieldWithPath("$prefix${QuestionDTO::id.name}").type(STRING).description("Question's unique identifier")
)
The previous code has few majors flaws :
- It's cumbersome to write
- The fields ordering is hard to maintain
- The field prefix has to be explicit
For contributors
Debugging kapt
- In order to trigger kapt you need to execute
./gradlew kaptKotlin
- To enable debugging add
kapt.use.worker.api=true
andorg.gradle.caching=false
to yourgradle.properties
file