diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml
index 49c537fd..87650ee9 100644
--- a/.github/workflows/continuous-integration-workflow.yml
+++ b/.github/workflows/continuous-integration-workflow.yml
@@ -20,7 +20,7 @@ jobs:
sudo tar vxf wkhtmltox-0.12.4_linux-generic-amd64.tar.xz
sudo mv wkhtmltox/bin/wkhtmlto* /usr/bin
- name: Build with Gradle
- run: ./gradlew clean test shadowJar --stacktrace --no-daemon
+ run: ./gradlew clean check --stacktrace --no-daemon
env:
NO_NEXUS: true
- uses: actions/cache@v1
@@ -34,29 +34,7 @@ jobs:
if: ${{ always() }}
with:
name: JUnit Report
- path: build/reports/tests/test/**
- - name: copy created artifacts into docker context
- run: |
- cp build/libs/*-all.jar ./docker/app.jar
- - name: Build docker image
- if: success()
- run: |
- COMMIT_AUTHOR=$(git --no-pager show -s --format='%an (%ae)' $GITHUB_SHA)
- COMMIT_MESSAGE=$(git log -1 --pretty=%B $GITHUB_SHA)
- COMMIT_TIME=$(git show -s --format=%ci $GITHUB_SHA)
- BUILD_TIME=$(date -u "+%Y-%m-%d %H:%M:%S %z")
- docker build \
- --label "ods.build.job.url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
- --label "ods.build.source.repo.ref=$GITHUB_REF" \
- --label "ods.build.source.repo.commit.author=$COMMIT_AUTHOR" \
- --label "ods.build.source.repo.commit.msg=$COMMIT_MESSAGE" \
- --label "ods.build.source.repo.commit.sha=$GITHUB_SHA" \
- --label "ods.build.source.repo.commit.timestamp=$COMMIT_TIME" \
- --label "ods.build.source.repo.url=https://github.com/$GITHUB_REPOSITORY.git" \
- --label "ods.build.timestamp=$BUILD_TIME" \
- -t ods-document-generation-svc:local .
- docker inspect ods-document-generation-svc:local --format='{{.Config.Labels}}'
- working-directory: docker
+ path: build/reports/**/**
- name: Push docker image
if: success() && github.repository == 'opendevstack/ods-document-generation-svc' && github.event_name == 'push'
shell: bash
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9832222e..c3bc7201 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,15 +4,21 @@
- Fix TIR and DTR documents are not properly indexed ([#55](https://github.com/opendevstack/ods-document-generation-svc/pull/55))
- Fix wkhtmltox hangs ([#66](https://github.com/opendevstack/ods-document-generation-svc/pull/66))
- Improve memory management and error handling ([#70](https://github.com/opendevstack/ods-document-generation-svc/pull/70))
+- Use Markdown Architectural Decision Records https://adr.github.io/madr/
+- Improve maintainability by adding SpringBoot framework
+- Added IT (Docker tests)
+- Added performance tests
+- logback.xml can be overridden from command line
-### Fixed
-- Github template tests fail in proxy environment ([#56](https://github.com/opendevstack/ods-document-generation-svc/issues/56))
-
-## [4.0] - 2021-15-11
+## [4.0] - 2021-18-11
### Added
- Added log to print /document endpoint input
+### Fixed
+- Github template tests fail in proxy environment ([#56](https://github.com/opendevstack/ods-document-generation-svc/issues/56))
+- Fix TIR and DTR documents are not properly indexed ([#55](https://github.com/opendevstack/ods-document-generation-svc/pull/55))
+
### Changed
- Updated maxRequestSize value from 100m to 200m
diff --git a/build.gradle b/build.gradle
index c8ec71f2..70e030aa 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,5 +1,10 @@
buildscript {
ext {
+ groovyallVersion = '3.0.9'
+ springbootVersion = "2.6.2"
+ spockCoreVersion = "2.0-groovy-3.0"
+ spockReportsVersion = '2.1-groovy-3.0'
+
nexus_url = "${project.findProperty('nexus_url') ?: System.getenv('NEXUS_HOST')}"
nexus_user = "${project.findProperty('nexus_user') ?: System.getenv('NEXUS_USERNAME')}"
nexus_pw = "${project.findProperty('nexus_pw') ?: System.getenv('NEXUS_PASSWORD')}"
@@ -7,14 +12,6 @@ buildscript {
if (!no_nexus && (nexus_url == "null" || nexus_user == "null" || nexus_pw == "null")) {
throw new GradleException("property no_nexus='false' (or not defined) but at least one of the properties nexus_url, nexus_user or nexus_pw is not configured. Please configure those properties!")
}
-
- def folderRel = (String)("${project.findProperty('nexus_folder_releases') ?: System.getenv('NEXUS_FOLDER_RELEASES')}")
- nexusFolderReleases = folderRel == "null" ? "maven-releases" : folderRel
-
- def folderSnaps = (String)("${project.findProperty('nexus_folder_snapshots') ?: System.getenv('NEXUS_FOLDER_SNAPSHOTS')}")
- nexusFolderSnapshots = folderSnaps == "null" ? "maven-snapshots" : folderSnaps
-
- snippetsDir = file('build/generated-snippets')
}
repositories {
@@ -51,32 +48,21 @@ buildscript {
}
}
-buildscript {
- ext {
- joobyVersion = "1.6.6"
- }
-
- dependencies {
- classpath "org.jooby:jooby-gradle-plugin:$joobyVersion"
- }
-}
-
plugins {
- id "com.github.johnrengelman.shadow"
- id "com.google.osdetector" version "1.6.2"
- id "io.spring.dependency-management" version "1.0.8.RELEASE"
+ id "groovy"
+ id 'com.adarshr.test-logger' version '3.1.0'
+ id 'jacoco'
+ id 'org.springframework.boot' version "${springbootVersion}"
+ id 'com.bmuschko.docker-spring-boot-application' version '7.1.0'
+ id "io.gatling.gradle" version "3.7.3"
}
-
-apply plugin: "application"
-apply plugin: "com.github.johnrengelman.shadow"
-apply plugin: "groovy"
-apply plugin: "jooby"
-apply plugin: "jacoco"
+// related to gatling and Springboot
+ext['netty.version'] = '4.0.51.Final'
repositories {
+ mavenCentral()
if (no_nexus) {
println("using repositories 'jcenter' and 'mavenCentral', because property no_nexus=$no_nexus")
- jcenter()
mavenCentral()
} else {
println("using nexus repositories")
@@ -107,74 +93,143 @@ repositories {
}
group = 'org.ods'
-version = '0.1'
-mainClassName = "app.App"
-sourceCompatibility = 1.8
+version = '1.0'
-dependencyManagement {
- imports {
- mavenBom "org.jooby:jooby-bom:$joobyVersion"
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(11))
}
}
+compileGroovy {
+ groovyOptions.javaAnnotationProcessing = true
+}
+
dependencies {
- implementation "com.github.ben-manes.caffeine:caffeine:2.7.0"
- implementation "com.github.jknack:handlebars:4.1.2"
- implementation "commons-io:commons-io:2.11.0"
- implementation "io.github.openfeign:feign-core:10.2.3"
- implementation "io.github.openfeign:feign-gson:10.2.3"
- implementation "io.github.openfeign:feign-okhttp:10.2.3"
- implementation "net.lingala.zip4j:zip4j:1.3.3"
- implementation "org.apache.httpcomponents:httpclient:4.5.8"
- implementation "org.codehaus.groovy:groovy-all:2.5.7"
- implementation "org.jooby:jooby-jackson"
- implementation "org.jooby:jooby-netty"
-
- implementation "io.netty:netty-transport-native-epoll:${dependencyManagement.importedProperties['netty.version']}:${osdetector.classifier.contains('linux') ? 'linux-x86_64' : ''}"
- implementation "io.netty:netty-tcnative-boringssl-static:${dependencyManagement.importedProperties['boringssl.version']}"
+ implementation (group: 'org.codehaus.groovy', name: 'groovy-all', version: groovyallVersion){
+ exclude group: "org.codehaus.groovy", module: "groovy-test-junit5"
+ }
+ implementation "org.springframework.boot:spring-boot-starter:${springbootVersion}"
+ implementation "org.springframework.boot:spring-boot-starter-web:${springbootVersion}"
+ implementation "org.springframework.boot:spring-boot-starter-cache:${springbootVersion}"
+
+ implementation("javax.inject:javax.inject:1")
+ implementation("javax.cache:cache-api:1.1.1")
+
+ implementation 'com.fasterxml.jackson.core:jackson-databind'
+ implementation 'com.github.ben-manes.caffeine:caffeine:3.0.5'
+ implementation 'com.github.jknack:handlebars:4.3.0'
+ implementation 'commons-io:commons-io:2.11.0'
+ implementation 'io.github.openfeign:feign-core:11.8'
+ implementation 'io.github.openfeign:feign-gson:11.8'
+ implementation 'io.github.openfeign:feign-okhttp:11.8'
+ implementation 'net.lingala.zip4j:zip4j:2.9.1'
+ implementation 'org.apache.httpcomponents:httpclient:4.5.13'
implementation "org.apache.pdfbox:pdfbox:2.0.24"
- testImplementation "junit:junit:4.12"
- testImplementation "com.github.stefanbirkner:system-rules:1.19.0" // for managing environment variables
- testImplementation "com.github.tomakehurst:wiremock:2.23.2" // for mocking HTTP server reponses
- testImplementation "io.rest-assured:rest-assured:4.0.0" // for validating REST services
- testImplementation "org.spockframework:spock-core:1.3-groovy-2.5"
+ testImplementation "org.junit.jupiter:junit-jupiter-api"
+ testImplementation "org.junit.jupiter:junit-jupiter-engine"
+ testImplementation("uk.org.webcompere:system-stubs-core:1.2.0")
+ testImplementation "org.testcontainers:spock:1.16.2"
+ testImplementation("org.testcontainers:testcontainers:1.16.2")
- testImplementation "cglib:cglib-nodep:3.3.0" // for mocking classes
- testImplementation "org.objenesis:objenesis:3.1"
-}
+ testImplementation("org.spockframework:spock-core:${spockCoreVersion}")
+ testImplementation ("com.athaydes:spock-reports:$spockReportsVersion"){ transitive = false }
+ testImplementation "org.spockframework:spock-spring:${spockCoreVersion}"
+ testImplementation "org.springframework.boot:spring-boot-starter-test:${springbootVersion}"
-import com.github.jengelman.gradle.plugins.shadow.transformers.NewGroovyExtensionModuleTransformer
+ testImplementation "com.github.stefanbirkner:system-rules:1.19.0"
+ testImplementation 'com.github.tomakehurst:wiremock:2.27.2'
+ testImplementation 'io.rest-assured:rest-assured:4.4.0'
-shadowJar {
- mergeServiceFiles()
- mergeGroovyExtensionModules()
- transform(NewGroovyExtensionModuleTransformer)
+ gatlingImplementation 'org.awaitility:awaitility:4.1.1'
+ gatlingImplementation 'io.rest-assured:rest-assured:4.4.0'
}
test {
- outputs.dir snippetsDir
-
- // Use overrides in conf/application.test.conf in ConfigFactory.load()
- // TODO: setting application.env should be enough, but apparently isn't
- systemProperty "application.env", "test"
- systemProperty "config.resource", "application.test.conf"
-
testLogging {
showStandardStreams = true
+ exceptionFormat = 'full'
}
+ filter {
+ includeTestsMatching "*Spec"
+ }
+ systemProperty 'com.athaydes.spockframework.report.outputDir', 'build/reports/spock'
+ maxHeapSize = "2048m"
+ jvmArgs "-XX:MaxPermSize=256m"
+ useJUnitPlatform()
finalizedBy jacocoTestReport
}
+task dockerTest(type: Test) {
+ dependsOn(test)
+ group("verification")
+ filter {
+ includeTestsMatching "*IT"
+ }
+ systemProperty 'com.athaydes.spockframework.report.outputDir', 'build/reports/spock'
+ useJUnitPlatform()
+}
+
jacocoTestReport {
+ dependsOn test
reports {
xml.enabled true
+ html.enabled true
+ }
+}
+
+jacocoTestCoverageVerification {
+ dependsOn jacocoTestReport
+ violationRules {
+ rule {
+ limit {
+ minimum = 0.7
+ }
+ }
}
}
-/** We diverge from the default resources structure to adopt the Jooby standard: */
-sourceSets.main.resources {
- srcDirs = ["conf", "public"]
+import com.bmuschko.gradle.docker.tasks.image.*
+task buildImage(type: DockerBuildImage) {
+ inputDir = file("src/main/resources")
+ images.add('docgen-base:latest')
+}
+
+docker {
+ springBootApplication {
+ baseImage = 'docgen-base:latest'
+ ports = [9090, 8080]
+ images = ['ods-document-generation-svc:local']
+ jvmArgs = ["-XX:+UseCompressedOops", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100"]
+ }
}
+buildImage.dependsOn(bootJar)
+dockerBuildImage.dependsOn(buildImage)
+dockerTest.dependsOn(dockerBuildImage)
+
+import com.bmuschko.gradle.docker.tasks.container.*
+
+task createDocGenServer(type: DockerCreateContainer) {
+ dependsOn dockerBuildImage
+ targetImageId dockerBuildImage.getImageId()
+ hostConfig.portBindings = ['8080:8080']
+ hostConfig.autoRemove = true
+}
+
+task startDocGenServer(type: DockerStartContainer) {
+ dependsOn createDocGenServer
+ targetContainerId createDocGenServer.getContainerId()
+}
+
+task stopDocGenServer(type: DockerStopContainer) {
+ targetContainerId createDocGenServer.getContainerId()
+}
+
+gatlingRun.group("verification")
+gatlingRun.dependsOn(dockerTest, startDocGenServer)
+gatlingRun.finalizedBy(stopDocGenServer)
+
+check.dependsOn(jacocoTestCoverageVerification, gatlingRun)
\ No newline at end of file
diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle
deleted file mode 100644
index 026e9fd4..00000000
--- a/buildSrc/build.gradle
+++ /dev/null
@@ -1,58 +0,0 @@
-ext {
- nexus_url = "${project.findProperty('nexus_url') ?: System.getenv('NEXUS_HOST')}"
- nexus_user = "${project.findProperty('nexus_user') ?: System.getenv('NEXUS_USERNAME')}"
- nexus_pw = "${project.findProperty('nexus_pw') ?: System.getenv('NEXUS_PASSWORD')}"
- no_nexus = (project.findProperty('no_nexus') ?: System.getenv('NO_NEXUS') ?: false).toBoolean()
- if (!no_nexus && (nexus_url == "null" || nexus_user == "null" || nexus_pw == "null")) {
- throw new GradleException("property no_nexus='false' (or not defined) but at least one of the properties nexus_url, nexus_user or nexus_pw is not configured. Please configure those properties!")
- }
-
- def folderRel = (String)("${project.findProperty('nexus_folder_releases') ?: System.getenv('NEXUS_FOLDER_RELEASES')}")
- nexusFolderReleases = folderRel == "null" ? "maven-releases" : folderRel
-
- def folderSnaps = (String)("${project.findProperty('nexus_folder_snapshots') ?: System.getenv('NEXUS_FOLDER_SNAPSHOTS')}")
- nexusFolderSnapshots = folderSnaps == "null" ? "maven-snapshots" : folderSnaps
-
- snippetsDir = file('build/generated-snippets')
-}
-
-repositories {
- if (no_nexus) {
- println("using repositories 'jcenter' and 'mavenCentral', because property no_nexus=$no_nexus")
- jcenter()
- mavenCentral()
- } else {
- println("using nexus repositories")
- maven() {
- url "${nexus_url}/repository/jcenter/"
- credentials {
- username = "${nexus_user}"
- password = "${nexus_pw}"
- }
- }
-
- maven() {
- url "${nexus_url}/repository/maven-public/"
- credentials {
- username = "${nexus_user}"
- password = "${nexus_pw}"
- }
- }
-
- maven() {
- url "${nexus_url}/repository/atlassian_public/"
- credentials {
- username = "${nexus_user}"
- password = "${nexus_pw}"
- }
- }
- }
-}
-
-apply plugin: 'groovy'
-
-dependencies {
- implementation gradleApi()
- implementation 'com.github.jengelman.gradle.plugins:shadow:5.2.0'
- implementation "org.codehaus.plexus:plexus-utils:3.0.24"
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/NewGroovyExtensionModuleTransformer.groovy b/buildSrc/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/NewGroovyExtensionModuleTransformer.groovy
deleted file mode 100644
index 523fc90a..00000000
--- a/buildSrc/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/NewGroovyExtensionModuleTransformer.groovy
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package com.github.jengelman.gradle.plugins.shadow.transformers
-
-import shadow.org.apache.tools.zip.ZipEntry
-import shadow.org.apache.tools.zip.ZipOutputStream
-import org.codehaus.plexus.util.IOUtil
-import org.gradle.api.file.FileTreeElement
-
-/**
- * Modified from https://github.com/johnrengelman/shadow/blob/7.1.1/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/transformers/GroovyExtensionModuleTransformer.groovy
- * ---
- * Modified from eu.appsatori.gradle.fatjar.tasks.PrepareFiles.groovy
- *
- * Resource transformer that merges Groovy extension module descriptor files into a single file. If there are several
- * META-INF/services/org.codehaus.groovy.runtime.ExtensionModule resources spread across many JARs the individual
- * entries will all be merged into a single META-INF/services/org.codehaus.groovy.runtime.ExtensionModule resource
- * packaged into the resultant JAR produced by the shadowing process.
- */
-@CacheableTransformer
-class NewGroovyExtensionModuleTransformer implements Transformer {
-
- private static final GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATH =
- "META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule"
-
- private static final MODULE_NAME_KEY = 'moduleName'
- private static final MODULE_VERSION_KEY = 'moduleVersion'
- private static final EXTENSION_CLASSES_KEY = 'extensionClasses'
- private static final STATIC_EXTENSION_CLASSES_KEY = 'staticExtensionClasses'
-
- private static final MERGED_MODULE_NAME = 'MergedByShadowJar'
- private static final MERGED_MODULE_VERSION = '1.0.0'
-
- private final Properties module = new Properties()
-
- @Override
- boolean canTransformResource(FileTreeElement element) {
- return element.relativePath.pathString == GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATH
- }
-
- @Override
- void transform(TransformerContext context) {
- def props = new Properties()
- props.load(context.is)
- props.each { String key, String value ->
- switch (key) {
- case MODULE_NAME_KEY:
- handle(key, value) {
- module.setProperty(key, MERGED_MODULE_NAME)
- }
- break
- case MODULE_VERSION_KEY:
- handle(key, value) {
- module.setProperty(key, MERGED_MODULE_VERSION)
- }
- break
- case [EXTENSION_CLASSES_KEY, STATIC_EXTENSION_CLASSES_KEY]:
- handle(key, value) { String existingValue ->
- def newValue = "${existingValue},${value}"
- module.setProperty(key, newValue)
- }
- break
- }
- }
- }
-
- private handle(String key, String value, Closure mergeValue) {
- def existingValue = module.getProperty(key)
- if (existingValue) {
- mergeValue(existingValue)
- } else {
- module.setProperty(key, value)
- }
- }
-
- @Override
- boolean hasTransformedResource() {
- return module.size() > 0
- }
-
- @Override
- void modifyOutputStream(ZipOutputStream os, boolean preserveFileTimestamps) {
- ZipEntry entry = new ZipEntry(GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATH)
- entry.time = TransformerContext.getEntryTimestamp(preserveFileTimestamps, entry.time)
- os.putNextEntry(entry)
- IOUtil.copy(toInputStream(module), os)
- os.closeEntry()
- }
-
- private static InputStream toInputStream(Properties props) {
- def baos = new ByteArrayOutputStream()
- props.store(baos, null)
- return new ByteArrayInputStream(baos.toByteArray())
- }
-
-}
diff --git a/conf/application.conf b/conf/application.conf
deleted file mode 100644
index 0a5afc15..00000000
--- a/conf/application.conf
+++ /dev/null
@@ -1,19 +0,0 @@
-# add or override properties
-# See https://github.com/typesafehub/config/blob/master/HOCON.md for more details
-application {
- port = 8080
-
- documents {
- cache {
- basePath = "/tmp/doc-gen-templates"
- }
- }
-}
-
-server {
- maxRequestSize = 200m
-
- http {
- MaxRequestSize = 200m
- }
-}
diff --git a/conf/application.test.conf b/conf/application.test.conf
deleted file mode 100644
index fe0c7f49..00000000
--- a/conf/application.test.conf
+++ /dev/null
@@ -1,7 +0,0 @@
-# add or override properties
-# See https://github.com/typesafehub/config/blob/master/HOCON.md for more details
-include "application"
-
-application {
- port = 9000
-}
diff --git a/conf/logback.xml b/conf/logback.xml
deleted file mode 100644
index bdb1e0bc..00000000
--- a/conf/logback.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
- [%d{ISO8601}]-[%thread] %-5level %logger - %msg%n
-
-
-
-
-
-
-
diff --git a/docker/Dockerfile b/docker/Dockerfile
deleted file mode 100644
index 6ba6c5d8..00000000
--- a/docker/Dockerfile
+++ /dev/null
@@ -1,28 +0,0 @@
-FROM adoptopenjdk/openjdk8:ubi-jre
-
-MAINTAINER martin.etmajer@boehringer-ingelheim.com
-
-WORKDIR /app
-COPY app.jar ./app.jar
-COPY entrypoint.sh ./entrypoint.sh
-
-# Install wkhtmltopdf
-RUN yum update -y && \
- yum install -y libX11 libXext libXrender libjpeg xz xorg-x11-fonts-Type1 git-core && \
- curl -kLO http://mirror.centos.org/centos/8/AppStream/aarch64/os/Packages/xorg-x11-fonts-75dpi-7.5-19.el8.noarch.rpm && \
- rpm -Uvh xorg-x11-fonts-75dpi-7.5-19.el8.noarch.rpm && \
- curl -kLO https://github.com/wkhtmltopdf/wkhtmltopdf/releases/download/0.12.5/wkhtmltox-0.12.5-1.centos8.x86_64.rpm && \
- rpm -Uvh wkhtmltox-0.12.5-1.centos8.x86_64.rpm && chmod +x entrypoint.sh
-
-# See https://docs.openshift.com/container-platform/3.9/creating_images/guidelines.html
-RUN chgrp -R 0 /app && \
- chmod -R g=u /app
-
-USER 1001
-
-EXPOSE 8080
-ENV JAVA_MEM_XMX="512m" \
- JAVA_MEM_XMS="128m" \
- JAVA_OPTS="-XX:+UseCompressedOops -XX:+UseG1GC -XX:+UseStringDeduplication -XX:MaxGCPauseMillis=1000"
-
-ENTRYPOINT /app/entrypoint.sh
diff --git a/docs/decisions/0000-use-markdown-architectural-decision-records.md b/docs/decisions/0000-use-markdown-architectural-decision-records.md
new file mode 100644
index 00000000..9ce4bb1b
--- /dev/null
+++ b/docs/decisions/0000-use-markdown-architectural-decision-records.md
@@ -0,0 +1,28 @@
+# Use Markdown Architectural Decision Records
+
+## Context and Problem Statement
+
+We want to record architectural decisions made in this project.
+Which format and structure should these records follow?
+
+## Considered Options
+
+* [MADR](https://adr.github.io/madr/) 2.1.2 The Markdown Architectural Decision Records
+* [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) The first incarnation of the term "ADR"
+* [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) The Y-Statements
+* Other templates listed at
+* Formless No conventions for file format and structure
+
+## Decision Outcome
+
+Chosen option: "MADR 2.1.2", because
+
+* Implicit assumptions should be made explicit.
+ Design documentation is important to enable people understanding the decisions later on.
+ See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940).
+* The MADR format is lean and fits our development style.
+* The MADR structure is comprehensible and facilitates usage & maintenance.
+* The MADR project is vivid.
+* Version 2.1.2 is the latest one available when starting to document ADRs.
+
+
\ No newline at end of file
diff --git a/docs/decisions/0001-use-spring-framework.md b/docs/decisions/0001-use-spring-framework.md
new file mode 100644
index 00000000..3da24d89
--- /dev/null
+++ b/docs/decisions/0001-use-spring-framework.md
@@ -0,0 +1,36 @@
+# Improve Maintainability
+
+* Status: accepted
+* Deciders: Sergio Sacristán
+* Date: 2021-12-22
+
+## Context and Problem Statement
+
+In order to evolve DocGen service and migrate here the LevaDoc feature, initially implemented
+in the SharedLib, we need to improve LevaDoc architecture to make it more maintainable.
+
+## Decision Drivers
+
+* Maintainability: speed up the development with better modularization of the code
+* Testability.
+* Extensibility.
+* Performance (of course we should take care of Performance, but as the API will be executed from a batch,
+we don't care if the response takes 2 seconds more or less)
+
+## Considered Options
+
+* jooby with Dagger and Groovy: poor documentation and examples
+* jooby with SpringFramework and Groovy: poor documentation and examples
+* SpringFramework and Groovy
+
+## Decision Outcome
+
+Chosen option: "SpringFramework", because:
+- It has the best integration with more frameworks, means also better extensibility
+- There's a lot of documentation and examples. Easy to solve problems
+- There's a bigger community of users: easy to involve new developers
+
+### Negative Consequences
+
+* We should do a big refactor
+
diff --git a/docs/decisions/0002-testing-strategy.md b/docs/decisions/0002-testing-strategy.md
new file mode 100644
index 00000000..8b18d4bc
--- /dev/null
+++ b/docs/decisions/0002-testing-strategy.md
@@ -0,0 +1,74 @@
+# Autoamated testing strategy
+
+* Status: accepted
+* Deciders: Sergio Sacristán
+* Date: 2021-12-22
+
+## Context and Problem Statement
+
+When unit testing a service, the standard unit is usually the service class, simple as that. The test will mock out the layer underneath in this case the DAO/DAL layer and verify the interactions on it. Exact same thing for the DAO layer mocking out the interactions with the database (HibernateTemplate in this example) and verifying the interactions with that.
+
+This is a valid approach, but it leads to brittle tests adding or removing a layer almost always means rewriting the tests entirely. This happens because the tests rely on the exact structure of the layers, and a change to that means a change to the tests.
+To avoid this kind of inflexibility, we can grow the scope of the unit test by changing the definition of the unit we can look at a persistent operation as a unit, from the Service Layer through the DAO and all the way day to the raw persistence whatever that is. Now, the unit test will consume the API of the Service Layer and will have the raw persistence mocked out in this case, the "templates.repository"
+
+## Decision Drivers
+
+* Optimize testing effort
+* Improve test quality
+
+## Decision Outcome
+
+https://github.com/portainer/portainer
+https://localhost:9443/
+admin 12345678
+
+### Positive Consequences
+
+* {e.g., improvement of quality attribute satisfaction, follow-up decisions required, …}
+* …
+
+### Negative Consequences
+
+* {e.g., compromising quality attribute, follow-up decisions required, …}
+* …
+
+## Pros and Cons of the Options
+
+### {option 1}
+
+{example | description | pointer to more information | …}
+
+* Good, because {argument a}
+* Good, because {argument b}
+* Bad, because {argument c}
+* …
+
+### {option 2}
+
+{example | description | pointer to more information | …}
+
+* Good, because {argument a}
+* Good, because {argument b}
+* Bad, because {argument c}
+* …
+
+### {option 3}
+
+{example | description | pointer to more information | …}
+
+* Good, because {argument a}
+* Good, because {argument b}
+* Bad, because {argument c}
+* …
+
+## Links
+
+* {Link type} {Link to ADR}
+* …
+
+
+
+
+
+
+
diff --git a/docs/decisions/adr-template.md b/docs/decisions/adr-template.md
new file mode 100644
index 00000000..2b3a5497
--- /dev/null
+++ b/docs/decisions/adr-template.md
@@ -0,0 +1,74 @@
+# {short title of solved problem and solution}
+
+* Status: {proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)}
+* Deciders: {list everyone involved in the decision}
+* Date: {YYYY-MM-DD when the decision was last updated}
+
+Technical Story: {description | ticket/issue URL}
+
+## Context and Problem Statement
+
+{Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.}
+
+## Decision Drivers
+
+* {driver 1, e.g., a force, facing concern, …}
+* {driver 2, e.g., a force, facing concern, …}
+* …
+
+## Considered Options
+
+* {option 1}
+* {option 2}
+* {option 3}
+* …
+
+## Decision Outcome
+
+Chosen option: "{option 1}", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}.
+
+### Positive Consequences
+
+* {e.g., improvement of quality attribute satisfaction, follow-up decisions required, …}
+* …
+
+### Negative Consequences
+
+* {e.g., compromising quality attribute, follow-up decisions required, …}
+* …
+
+## Pros and Cons of the Options
+
+### {option 1}
+
+{example | description | pointer to more information | …}
+
+* Good, because {argument a}
+* Good, because {argument b}
+* Bad, because {argument c}
+* …
+
+### {option 2}
+
+{example | description | pointer to more information | …}
+
+* Good, because {argument a}
+* Good, because {argument b}
+* Bad, because {argument c}
+* …
+
+### {option 3}
+
+{example | description | pointer to more information | …}
+
+* Good, because {argument a}
+* Good, because {argument b}
+* Bad, because {argument c}
+* …
+
+## Links
+
+* {Link type} {Link to ADR}
+* …
+
+
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index f4d7b2bf..2e6e5897 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/src/gatling/java/org/ods/doc/gen/GatlingRunner.java b/src/gatling/java/org/ods/doc/gen/GatlingRunner.java
new file mode 100644
index 00000000..50ffab0e
--- /dev/null
+++ b/src/gatling/java/org/ods/doc/gen/GatlingRunner.java
@@ -0,0 +1,13 @@
+package org.ods.doc.gen;
+
+import io.gatling.app.Gatling;
+import io.gatling.core.config.GatlingPropertiesBuilder;
+
+public class GatlingRunner {
+ public static void main(String[] args) {
+ GatlingPropertiesBuilder props = new GatlingPropertiesBuilder();
+ props.simulationClass(LoadSimulation.class.getName());
+ props.resultsDirectory("build/reports/gatling");
+ Gatling.fromMap(props.build());
+ }
+}
diff --git a/src/gatling/java/org/ods/doc/gen/LoadSimulation.java b/src/gatling/java/org/ods/doc/gen/LoadSimulation.java
new file mode 100644
index 00000000..576a907e
--- /dev/null
+++ b/src/gatling/java/org/ods/doc/gen/LoadSimulation.java
@@ -0,0 +1,56 @@
+package org.ods.doc.gen;
+
+import io.gatling.javaapi.core.ScenarioBuilder;
+import io.gatling.javaapi.core.Simulation;
+import io.restassured.http.ContentType;
+import org.awaitility.Awaitility;
+
+import java.util.concurrent.TimeUnit;
+
+import static io.gatling.javaapi.core.CoreDsl.atOnceUsers;
+import static io.gatling.javaapi.core.CoreDsl.exec;
+import static io.gatling.javaapi.core.CoreDsl.global;
+import static io.gatling.javaapi.core.CoreDsl.scenario;
+import static io.gatling.javaapi.http.HttpDsl.http;
+import static io.gatling.javaapi.http.HttpDsl.status;
+import static io.restassured.RestAssured.given;
+
+public class LoadSimulation extends Simulation {
+
+ public static final String HEALTH = "http://localhost:8080/health";
+ // TODO -> change POST_PDF by "http://localhost:8080/document" and test a big doc
+ public static final String POST_PDF = "http://localhost:8080/health";
+ public static final int STATUS_CODE_OK = 200;
+
+ public static final int EXECUTION_TIMES = 100;
+
+ // Assertions: https://gatling.io/docs/gatling/reference/current/core/assertions/
+ public static final double SUCCESSFUL_REQUEST_PERCENT = 100.0;
+ public static final int MAX_RESPONSE_TIME = 400;
+ public static final int MEAN_RESPONSE_TIME = 245;
+
+ ScenarioBuilder scn = scenario( "postPDF").repeat(EXECUTION_TIMES).on(
+ exec(
+ http("POST_PDF")
+ .get(POST_PDF)
+ .asJson()
+ .check(status().is(STATUS_CODE_OK))
+ ).pause(1)
+ );
+
+ {
+ waitUntilDocGenIsUp();
+ setUp(scn.injectOpen(atOnceUsers(1))).assertions(
+ global().successfulRequests().percent().is(SUCCESSFUL_REQUEST_PERCENT),
+ global().responseTime().max().lt(MAX_RESPONSE_TIME),
+ global().responseTime().mean().lt(MEAN_RESPONSE_TIME)
+ );;
+ }
+
+ private void waitUntilDocGenIsUp() {
+ Awaitility.await().atMost(60, TimeUnit.SECONDS).pollInterval(5, TimeUnit.SECONDS).until(() ->
+ {
+ return given().contentType(ContentType.JSON).when().get(HEALTH).getStatusCode() == STATUS_CODE_OK;
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/groovy/app/App.groovy b/src/main/groovy/app/App.groovy
deleted file mode 100644
index aee17004..00000000
--- a/src/main/groovy/app/App.groovy
+++ /dev/null
@@ -1,127 +0,0 @@
-package app
-
-import com.typesafe.config.ConfigFactory
-import groovy.util.logging.Slf4j
-import org.jooby.Jooby
-import org.jooby.MediaType
-import org.jooby.json.Jackson
-import util.DocUtils
-import util.FileTools
-
-import java.nio.file.Files
-import java.nio.file.StandardOpenOption
-
-import static groovy.json.JsonOutput.prettyPrint
-import static groovy.json.JsonOutput.toJson
-import static org.jooby.JoobyExtension.get
-import static org.jooby.JoobyExtension.post
-
-@Slf4j
-class App extends Jooby {
-
- {
-
- use(new Jackson())
- use(new DocGen())
-
- post(this, "/document", { req, rsp ->
-
- Map body = req.body().to(HashMap.class)
-
- validateRequestParams(body)
-
- if (log.isDebugEnabled()) {
- log.debug("Input request body data before send it to convert it to a pdf: ")
- log.debug(prettyPrint(toJson(body.data)))
- }
-
- FileTools.newTempFile('document', '.b64') { dataFile ->
- new DocGen().generate(body.metadata.type, body.metadata.version, body.data).withFile { pdf ->
- body = null // Not used anymore. Let it be garbage-collected.
- dataFile.withOutputStream { os ->
- Base64.getEncoder().wrap(os).withStream { encOs ->
- Files.copy(pdf, encOs)
- }
- }
- }
- def dataLength = Files.size(dataFile)
- rsp.length(dataLength + RES_PREFIX.length + RES_SUFFIX.length)
- rsp.type(MediaType.json)
- def prefixIs = new ByteArrayInputStream(RES_PREFIX)
- def suffixIs = new ByteArrayInputStream(RES_SUFFIX)
- Files.newInputStream(dataFile, StandardOpenOption.DELETE_ON_CLOSE).initResource { dataIs ->
- // Jooby is asynchronous. Upon return of the send method, the response has not necessarily
- // been sent. For this reason, we rely on Jooby to close the InputStream and the temporary file
- // will be deleted on close.
- rsp.send(new SequenceInputStream(Collections.enumeration([prefixIs, dataIs, suffixIs])))
- }
- }
-
- })
- .consumes(MediaType.json)
- .produces(MediaType.json)
-
- get(this, "/health", { req, rsp ->
- def message = null
- def status = "passing"
- def statusCode = 200
-
- try {
- FileTools.withTempFile("document", ".html") { documentHtmlFile ->
- documentHtmlFile << "document"
-
- DocGen.Util.convertHtmlToPDF(documentHtmlFile, null).withFile { pdf ->
- def header = DocUtils.getPDFHeader(pdf)
- if (header != PDF_HEADER) {
- message = "conversion form HTML to PDF failed"
- status = "failing"
- statusCode = 500
- }
- return pdf
- }
-
- }
- } catch (e) {
- message = e.message
- status = "failing"
- statusCode = 500
- }
-
- def result = [
- service: "docgen",
- status: status,
- time: new Date().toString()
- ]
-
- if (message) {
- result.message = message
- }
-
- rsp.status(statusCode).send(result)
- })
- .produces(MediaType.json)
- }
-
- private static final RES_PREFIX = '{"data":"'.getBytes('US-ASCII')
- private static final RES_SUFFIX = '"}'.getBytes('US-ASCII')
- private static final PDF_HEADER = '%PDF-1.4'
-
- private static void validateRequestParams(Map body) {
- if (body?.metadata?.type == null) {
- throw new IllegalArgumentException("missing argument 'metadata.type'")
- }
-
- if (body?.metadata?.version == null) {
- throw new IllegalArgumentException("missing argument 'metadata.version'")
- }
-
- if (body?.data == null) {
- throw new IllegalArgumentException("missing argument 'data'")
- }
- }
-
- static void main(String... args) {
- ConfigFactory.invalidateCaches()
- run(App.class, args)
- }
-}
diff --git a/src/main/groovy/app/BitBucketDocumentTemplatesStore.groovy b/src/main/groovy/app/BitBucketDocumentTemplatesStore.groovy
deleted file mode 100644
index de9728d6..00000000
--- a/src/main/groovy/app/BitBucketDocumentTemplatesStore.groovy
+++ /dev/null
@@ -1,130 +0,0 @@
-package app
-
-import feign.Response
-import feign.codec.ErrorDecoder
-import util.DocUtils
-import com.typesafe.config.Config
-import com.typesafe.config.ConfigFactory
-
-import feign.Feign
-import feign.Headers
-import feign.Param
-import feign.RequestLine
-import feign.auth.BasicAuthRequestInterceptor
-import feign.FeignException
-
-import org.apache.http.client.utils.URIBuilder
-import util.FileTools
-
-import java.nio.file.Files
-import java.nio.file.Path
-import java.nio.file.StandardCopyOption
-
-interface BitBucketDocumentTemplatesStoreHttpAPI {
- @Headers("Accept: application/octet-stream")
- @RequestLine("GET /rest/api/latest/projects/{documentTemplatesProject}/repos/{documentTemplatesRepo}/archive?at=refs/heads/release/v{version}&format=zip")
- Response getTemplatesZipArchiveForVersion(@Param("documentTemplatesProject") String documentTemplatesProject, @Param("documentTemplatesRepo") String documentTemplatesRepo, @Param("version") String version)
-}
-
-class BitBucketDocumentTemplatesStore implements DocumentTemplatesStore {
-
- Config config
-
- // TODO: use dependency injection
- BitBucketDocumentTemplatesStore() {
- this.config = ConfigFactory.load()
- }
-
- // Get document templates of a specific version into a target directory
- Path getTemplatesForVersion(String version, Path targetDir) {
- def uri = getZipArchiveDownloadURI(version)
-
- Feign.Builder builder = Feign.builder()
-
- def bitbucketUserName = System.getenv("BITBUCKET_USERNAME")
- def bitbucketPassword = System.getenv("BITBUCKET_PASSWORD")
- if (bitbucketUserName && bitbucketPassword) {
- builder.requestInterceptor(new BasicAuthRequestInterceptor(
- bitbucketUserName, bitbucketPassword
- ))
- }
-
- BitBucketDocumentTemplatesStoreHttpAPI store = builder.target(
- BitBucketDocumentTemplatesStoreHttpAPI.class,
- uri.getScheme() + "://" + uri.getAuthority()
- )
-
-
- def bitbucketRepo = System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_REPO")
- def bitbucketProject = System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT")
- try {
- return store.getTemplatesZipArchiveForVersion(
- bitbucketProject,
- bitbucketRepo,
- version
- ).withCloseable { response ->
- if (response.status() >= 300) {
- def methodKey =
- 'BitBucketDocumentTemplatesStoreHttpAPI#getTemplatesZipArchiveForVersion(String,String,String)'
- throw new ErrorDecoder.Default().decode(methodKey, response)
- }
- return FileTools.withTempFile('tmpl', 'zip') { zipArchive ->
- response.body().withCloseable { body ->
- body.asInputStream().withStream { is ->
- Files.copy(is, zipArchive, StandardCopyOption.REPLACE_EXISTING)
- }
- }
- return DocUtils.extractZipArchive(zipArchive, targetDir)
- }
- }
- } catch (FeignException callException) {
- def baseErrMessage = "Could not get document zip from '${uri}'!"
- def baseRepoErrMessage = "${baseErrMessage}\rIn repository '${bitbucketRepo}' - "
- if (callException instanceof FeignException.BadRequest) {
- throw new RuntimeException ("${baseRepoErrMessage}" +
- "is there a correct release branch configured, called 'release/v${version}'?")
- } else if (callException instanceof FeignException.Unauthorized) {
- def bbUserNameError = bitbucketUserName ?: 'Anyone'
- throw new RuntimeException ("${baseRepoErrMessage}" +
- "does '${bbUserNameError}' have access?")
- } else if (callException instanceof FeignException.NotFound) {
- throw new RuntimeException ("${baseErrMessage}" +
- "\rDoes repository '${bitbucketRepo}' in project: '${bitbucketProject}' exist?")
- } else {
- throw callException
- }
- }
- }
-
- // Get a URI to download document templates of a specific version
- URI getZipArchiveDownloadURI(String version) {
- return new URIBuilder(System.getenv("BITBUCKET_URL"))
- .setPath("/rest/api/latest/projects/${System.getenv('BITBUCKET_DOCUMENT_TEMPLATES_PROJECT')}/repos/${System.getenv('BITBUCKET_DOCUMENT_TEMPLATES_REPO')}/archive")
- .addParameter("at", "refs/heads/release/v${version}")
- .addParameter("format", "zip")
- .build()
- }
-
- boolean isApplicableToSystemConfig ()
- {
- List missingEnvs = [ ]
- if (!System.getenv("BITBUCKET_URL")) {
- missingEnvs << "BITBUCKET_URL"
- }
-
- if (!System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT")) {
- missingEnvs << "BITBUCKET_DOCUMENT_TEMPLATES_PROJECT"
- }
-
- if (!System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_REPO")) {
- missingEnvs << "BITBUCKET_DOCUMENT_TEMPLATES_REPO"
- }
-
- if (missingEnvs.size() > 0) {
- println "[ERROR]: Bitbucket adapter not applicable - missing config '${missingEnvs}'"
- return false
- }
-
- return true
- }
-}
diff --git a/src/main/groovy/app/DocGen.groovy b/src/main/groovy/app/DocGen.groovy
deleted file mode 100644
index 849c783d..00000000
--- a/src/main/groovy/app/DocGen.groovy
+++ /dev/null
@@ -1,312 +0,0 @@
-package app
-
-import com.github.benmanes.caffeine.cache.Cache
-import com.github.benmanes.caffeine.cache.Caffeine
-import com.github.jknack.handlebars.Handlebars
-import com.github.jknack.handlebars.io.FileTemplateLoader
-import com.google.inject.Binder
-import com.typesafe.config.Config
-import com.typesafe.config.ConfigFactory
-import org.apache.commons.io.file.PathUtils
-import org.apache.commons.io.output.TeeOutputStream
-import org.apache.pdfbox.io.MemoryUsageSetting
-import org.apache.pdfbox.pdmodel.PDDocument
-import org.apache.pdfbox.pdmodel.PDDocumentNameDestinationDictionary
-import org.apache.pdfbox.pdmodel.PDPage
-import org.apache.pdfbox.pdmodel.common.PDNameTreeNode
-import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo
-import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination
-import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink
-import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode
-import util.FileTools
-
-import java.nio.file.Files
-import java.nio.file.Path
-import java.nio.file.Paths
-import java.nio.file.StandardCopyOption
-import java.time.Duration
-
-import org.apache.commons.io.FilenameUtils
-import org.jooby.Env
-import org.jooby.Jooby
-
-class DocGen implements Jooby.Module {
-
- Config config
- Cache templatesCache
-
- // TODO: use dependency injection
- DocGen() {
- this.config = ConfigFactory.load()
-
- this.templatesCache = Caffeine.newBuilder()
- .expireAfterWrite(Duration.ofDays(1))
- .removalListener({ key, graph, cause ->
- def path = getPathForTemplatesVersion(key)
- if (Files.exists(path)) {
- PathUtils.deleteDirectory(path)
- }
- })
- .build()
- }
-
- void configure(Env env, Config config, Binder binder) {
- }
-
- // Get document templates for a specific version
- private Path getTemplates(def version) {
- DocumentTemplatesStore store = new BitBucketDocumentTemplatesStore()
- if (!store.isApplicableToSystemConfig()) {
- store = new GithubDocumentTemplatesStore()
- }
- println ("Using templates @${store.getZipArchiveDownloadURI(version)}")
-
- def path = templatesCache.getIfPresent(version)
- if (path == null) {
- path = store.getTemplatesForVersion(version, getPathForTemplatesVersion(version))
- templatesCache.put(version, path)
- }
-
- return path
- }
-
- // Generate a PDF document for a combination of template type, version and data
- Path generate(String type, String version, Object data) {
- // Copy the templates directory including with any assets into a temporary location
- return FileTools.withTempDir("${type}-v${version}") { tmpDir ->
- PathUtils.copyDirectory(getTemplates(version), tmpDir, StandardCopyOption.REPLACE_EXISTING)
-
- // Get partial templates from the temporary location and manipulate as needed
- def partials = getPartialTemplates(tmpDir, type)
-
- // Transform paths to partial templates to paths to rendered HTML files
- partials = partials.collectEntries { name, path ->
- // Write an .html file next to the .tmpl file containing the executed template
- def htmlFile = Paths.get(FilenameUtils.removeExtension(path.toString()))
- Util.executeTemplate(path, htmlFile, data)
- return [ name, htmlFile ]
- }
-
- // Convert the executed templates into a PDF document
- return Util.convertHtmlToPDF(partials.document, data)
- }
- }
-
- // Read partial templates for a template type and version from the basePath directory
- private static Map getPartialTemplates(Path basePath, String type) {
- def partials = [
- document: Paths.get(basePath.toString(), "templates", "${type}.html.tmpl"),
- header: Paths.get(basePath.toString(), "templates", "header.inc.html.tmpl"),
- footer: Paths.get(basePath.toString(), "templates", "footer.inc.html.tmpl")
- ]
-
- partials.each { name, path ->
- // Check if the partial template exists
- if (!Files.exists(path)) {
- throw new FileNotFoundException("could not find required template part '${name}' at '${path}'")
- }
-
- FileTools.newTempFile("${name}_tmpl") { tmp ->
- path.withReader { reader ->
- tmp.withWriter { writer ->
- reader.eachLine { line ->
- def replaced = line.replaceAll('\t', '')
- writer.write(replaced)
- }
- }
- }
- Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING)
- }
- }
-
- return partials
- }
-
- // Get a path to a directory holding document templates for a specific version
- private Path getPathForTemplatesVersion(String version) {
- return Paths.get(this.config.getString("application.documents.cache.basePath"), version)
- }
-
- class Util {
- // Execute a document template with the necessary data
- static private void executeTemplate(Path path, Path dest, Object data) {
- // TODO: throw if template variables are not provided
- def loader = new FileTemplateLoader("", "")
- dest.withWriter { writer ->
- new Handlebars(loader)
- .compile(path.toString())
- .apply(data, writer)
- }
- }
-
- // Convert a HTML document, with an optional header and footer, into a PDF
- static Path convertHtmlToPDF(Path documentHtmlFile, Object data) {
- def cmd = ["wkhtmltopdf", "--encoding", "UTF-8", "--no-outline", "--print-media-type"]
- cmd << "--enable-local-file-access"
- cmd.addAll(["-T", "40", "-R", "25", "-B", "25", "-L", "25"])
-
- if (data?.metadata?.header) {
- if (data.metadata.header.size() > 1) {
- cmd.addAll(["--header-center", """${data.metadata.header[0]}
-${data.metadata.header[1]}"""])
- } else {
- cmd.addAll(["--header-center", data.metadata.header[0]])
- }
-
- cmd.addAll(["--header-font-size", "10", "--header-spacing", "10"])
- }
-
- cmd.addAll(["--footer-center", "'Page [page] of [topage]'", "--footer-font-size", "10"])
-
- if (data?.metadata?.orientation) {
- cmd.addAll(["--orientation", data.metadata.orientation])
- }
-
- cmd << documentHtmlFile.toAbsolutePath().toString()
-
- return FileTools.newTempFile("document", ".pdf") { documentPDFFile ->
- cmd << documentPDFFile.toAbsolutePath().toString()
-
- println "[INFO]: executing cmd: ${cmd}"
-
- def result = shell(cmd)
- if (result.rc != 0) {
- println "[ERROR]: ${cmd} has exited with code ${result.rc}"
- println "[ERROR]: ${result.stderr}"
- throw new IllegalStateException(
- "PDF Creation of ${documentHtmlFile} failed!\r:${result.stderr}\r:Error code:${result.rc}")
- }
-
- fixDestinations(documentPDFFile.toFile())
- }
- }
-
- // Execute a command in the shell
- static private Map shell(List cmd) {
-
- def proc = cmd.execute()
- def stderr = null
- def rc = FileTools.withTempFile("shell", ".stderr") { tempFile ->
- tempFile.withOutputStream { tempFileOutputStream ->
- new TeeOutputStream(System.err, tempFileOutputStream).withStream { errOutputStream ->
- proc.waitForProcessOutput(System.out, errOutputStream)
- }
- }
- def exitValue = proc.exitValue()
- if (exitValue) {
- stderr = tempFile.text
- }
- return exitValue
- }
-
- return [
- rc: rc,
- stderr: stderr
- ]
- }
-
- private static final long MAX_MEMORY_TO_FIX_DESTINATIONS = 8192L
-
- /**
- * Fixes malformed PDF documents which use page numbers in local destinations, referencing the same document.
- * Page numbers should be used only for references to external documents.
- * These local destinations must use indirect page object references.
- * Note that these malformed references are not correctly renumbered when merging documents.
- * This method finds these malformed references and replaces the page numbers by the corresponding
- * page object references.
- * If the document is not malformed, this method will leave it unchanged.
- *
- * @param pdf a PDF file.
- */
- private static void fixDestinations(File pdf) {
- def memoryUsageSetting = MemoryUsageSetting.setupMixed(MAX_MEMORY_TO_FIX_DESTINATIONS)
- PDDocument.load(pdf, memoryUsageSetting).withCloseable { doc ->
- fixDestinations(doc)
- doc.save(pdf)
- }
- }
-
- /**
- * Fixes malformed PDF documents which use page numbers in local destinations, referencing the same document.
- * Page numbers should be used only for references to external documents.
- * These local destinations must use indirect page object references.
- * Note that these malformed references are not correctly renumbered when merging documents.
- * This method finds these malformed references and replaces the page numbers by the corresponding
- * page object references.
- * If the document is not malformed, this method will leave it unchanged.
- *
- * @param doc a PDF document.
- */
- private static void fixDestinations(PDDocument doc) {
- def pages = doc.pages as List // Accessing pages by index is slow. This will make it fast.
- fixExplicitDestinations(pages)
- def catalog = doc.documentCatalog
- fixNamedDestinations(catalog, pages)
- fixOutline(catalog, pages)
- }
-
- private static fixExplicitDestinations(pages) {
- pages.each { page ->
- page.getAnnotations { it instanceof PDAnnotationLink }.each { link ->
- fixDestinationOrAction(link, pages)
- }
- }
- }
-
- private static fixNamedDestinations(catalog, pages) {
- fixStringDestinations(catalog.names?.dests, pages)
- fixNameDestinations(catalog.dests, pages)
- }
-
- private static fixOutline(catalog, pages) {
- def outline = catalog.documentOutline
- if (outline != null) {
- fixOutlineNode(outline, pages)
- }
- }
-
- private static fixStringDestinations(PDNameTreeNode node, pages) {
- if (node) {
- node.names?.each { name, dest -> fixDestination(dest, pages) }
- node.kids?.each { fixStringDestinations(it, pages) }
- }
- }
-
- private static fixNameDestinations(PDDocumentNameDestinationDictionary dests, pages) {
- dests?.COSObject?.keySet()*.name.each { name ->
- def dest = dests.getDestination(name)
- if (dest instanceof PDPageDestination) {
- fixDestination(dest, pages)
- }
- }
- }
-
- private static fixOutlineNode(PDOutlineNode node, pages) {
- node.children().each { item ->
- fixDestinationOrAction(item, pages)
- fixOutlineNode(item, pages)
- }
- }
-
- private static fixDestinationOrAction(item, pages) {
- def dest = item.destination
- if (dest == null) {
- def action = item.action
- if (action instanceof PDActionGoTo) {
- dest = action.destination
- }
- }
- if (dest instanceof PDPageDestination) {
- fixDestination(dest, pages)
- }
- }
-
- private static fixDestination(PDPageDestination dest, List pages) {
- def pageNum = dest.pageNumber
- if (pageNum != -1) {
- dest.setPage(pages[pageNum])
- }
- }
-
- }
-}
diff --git a/src/main/groovy/app/DocumentTemplatesStore.groovy b/src/main/groovy/app/DocumentTemplatesStore.groovy
deleted file mode 100644
index 6854ed09..00000000
--- a/src/main/groovy/app/DocumentTemplatesStore.groovy
+++ /dev/null
@@ -1,14 +0,0 @@
-package app
-
-import java.nio.file.Path
-
-interface DocumentTemplatesStore {
-
- // Get document templates of a specific version into a target directory
- Path getTemplatesForVersion(String version, Path targetDir)
-
- // Get a URI to download document templates of a specific version
- URI getZipArchiveDownloadURI(String version)
-
- boolean isApplicableToSystemConfig ()
-}
diff --git a/src/main/groovy/app/GithubDocumentTemplatesStore.groovy b/src/main/groovy/app/GithubDocumentTemplatesStore.groovy
deleted file mode 100644
index 27d1dfc5..00000000
--- a/src/main/groovy/app/GithubDocumentTemplatesStore.groovy
+++ /dev/null
@@ -1,94 +0,0 @@
-package app
-
-import feign.Response
-import feign.codec.ErrorDecoder
-import okhttp3.OkHttpClient
-import org.apache.http.client.utils.URIBuilder
-import com.typesafe.config.Config
-import com.typesafe.config.ConfigFactory
-import feign.Feign
-import feign.Headers
-import feign.Param
-import feign.RequestLine
-import util.DocUtils
-import util.FileTools
-
-import java.nio.file.Files
-import java.nio.file.Path
-import java.nio.file.StandardCopyOption
-
-interface GithubDocumentTemplatesStoreHttpAPI {
- @Headers("Accept: application/octet-stream")
- @RequestLine("GET /opendevstack/ods-document-generation-templates/archive/v{version}.zip")
- Response getTemplatesZipArchiveForVersion(@Param("version") String version)
-}
-
-class GithubDocumentTemplatesStore implements DocumentTemplatesStore {
-
- Config config
-
- // TODO: use dependency injection
- GithubDocumentTemplatesStore() {
- this.config = ConfigFactory.load()
- }
-
- // Get document templates of a specific version into a target directory
- Path getTemplatesForVersion(String version, Path targetDir) {
- def uri = getZipArchiveDownloadURI(version)
- Feign.Builder builder = createBuilder()['builder']
-
- GithubDocumentTemplatesStoreHttpAPI store = builder.target(
- GithubDocumentTemplatesStoreHttpAPI.class,
- uri.getScheme() + "://" + uri.getAuthority()
- )
-
- return store.getTemplatesZipArchiveForVersion(version).withCloseable { response ->
- if (response.status() >= 300) {
- def methodKey =
- 'GithubDocumentTemplatesStoreHttpAPI#getTemplatesZipArchiveForVersion(String)'
- throw new ErrorDecoder.Default().decode(methodKey, response)
- }
- return FileTools.withTempFile('tmpl', 'zip') { zipArchive ->
- response.body().withCloseable { body ->
- body.asInputStream().withStream { is ->
- Files.copy(is, zipArchive, StandardCopyOption.REPLACE_EXISTING)
- }
- }
- return DocUtils.extractZipArchive(
- zipArchive, targetDir, "ods-document-generation-templates-${version}")
- }
- }
- }
-
- // Get a URI to download document templates of a specific version
- URI getZipArchiveDownloadURI(String version) {
- // for testing
- String githubUrl = System.getenv("GITHUB_HOST") ?: "https://www.github.com"
- return new URIBuilder(githubUrl)
- .setPath("/opendevstack/ods-document-generation-templates/archive/v${version}.zip")
- .build()
- }
-
- // proxy setup, we return a map for testing
- Map createBuilder () {
- String[] httpProxyHost = System.getenv('HTTP_PROXY')?.trim()?.replace('http://','')?.split(':')
- println ("Proxy setup: ${httpProxyHost ?: 'not found' }")
- if (httpProxyHost && !System.getenv("GITHUB_HOST")) {
- int httpProxyPort = httpProxyHost.size() == 2 ? Integer.parseInt(httpProxyHost[1]) : 80
- Proxy proxy = new Proxy(Proxy.Type.HTTP,
- new InetSocketAddress(httpProxyHost[0], httpProxyPort))
- OkHttpClient okHttpClient = new OkHttpClient().newBuilder().proxy(proxy).build()
- return [
- 'builder': Feign.builder().client(new feign.okhttp.OkHttpClient(okHttpClient)),
- 'proxy' : proxy
- ]
- } else {
- return ['builder' : Feign.builder()]
- }
- }
-
- boolean isApplicableToSystemConfig ()
- {
- return true
- }
-}
diff --git a/src/main/groovy/org/jooby/JoobyExtension.groovy b/src/main/groovy/org/jooby/JoobyExtension.groovy
deleted file mode 100644
index c5d24be5..00000000
--- a/src/main/groovy/org/jooby/JoobyExtension.groovy
+++ /dev/null
@@ -1,32 +0,0 @@
-package org.jooby
-
-import org.jooby.Jooby
-import org.jooby.Route
-
-/** Example on how to hack Groovy so we can use groovy closure on script routes. */
-class JoobyExtension {
-
- private static Route.Filter toHandler(Closure closure) {
- if (closure.maximumNumberOfParameters == 0) {
- Route.ZeroArgHandler handler = { closure() }
- return handler
- } else if (closure.maximumNumberOfParameters == 1) {
- Route.OneArgHandler handler = { req -> closure(req) }
- return handler
- } else if (closure.maximumNumberOfParameters == 2) {
- Route.Handler handler = { req, rsp -> closure(req, rsp) }
- return handler
- }
-
- Route.Filter handler = { req, rsp, chain -> closure(req, rsp, chain) }
- return handler
- }
-
- static Route.Definition get(Jooby self, String pattern, Closure closure) {
- return self.get(pattern, toHandler(closure));
- }
-
- static Route.Definition post(Jooby self, String pattern, Closure closure) {
- return self.post(pattern, toHandler(closure));
- }
-}
diff --git a/src/main/groovy/org/ods/doc/gen/App.groovy b/src/main/groovy/org/ods/doc/gen/App.groovy
new file mode 100644
index 00000000..102c46d3
--- /dev/null
+++ b/src/main/groovy/org/ods/doc/gen/App.groovy
@@ -0,0 +1,13 @@
+package org.ods.doc.gen
+
+import org.springframework.boot.SpringApplication
+import org.springframework.boot.autoconfigure.SpringBootApplication
+
+@SpringBootApplication
+class App {
+
+ static void main(String... args) {
+ SpringApplication.run(App.class, args);
+ }
+
+}
diff --git a/src/main/groovy/org/ods/doc/gen/AppConfiguration.groovy b/src/main/groovy/org/ods/doc/gen/AppConfiguration.groovy
new file mode 100644
index 00000000..f996d4d5
--- /dev/null
+++ b/src/main/groovy/org/ods/doc/gen/AppConfiguration.groovy
@@ -0,0 +1,34 @@
+package org.ods.doc.gen
+
+import com.github.benmanes.caffeine.cache.Caffeine
+import org.apache.commons.io.FileUtils
+import org.springframework.cache.CacheManager
+import org.springframework.cache.annotation.EnableCaching
+import org.springframework.cache.caffeine.CaffeineCacheManager
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.core.env.Environment
+
+import java.nio.file.Paths
+import java.time.Duration
+
+@EnableCaching
+@Configuration
+class AppConfiguration {
+ @Bean
+ Caffeine caffeineConfig(Environment environment) {
+ String basePath = environment.getProperty("application.documents.cache.basePath")
+ return Caffeine.newBuilder()
+ .expireAfterWrite(Duration.ofDays(1))
+ .removalListener({ version, graph, cause ->
+ FileUtils.deleteDirectory(Paths.get(basePath, version as String).toFile())
+ })
+ }
+
+ @Bean
+ CacheManager cacheManager(Caffeine caffeine) {
+ CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
+ caffeineCacheManager.setCaffeine(caffeine);
+ return caffeineCacheManager;
+ }
+}
diff --git a/src/main/groovy/org/ods/doc/gen/controllers/HealthController.groovy b/src/main/groovy/org/ods/doc/gen/controllers/HealthController.groovy
new file mode 100644
index 00000000..7c32db37
--- /dev/null
+++ b/src/main/groovy/org/ods/doc/gen/controllers/HealthController.groovy
@@ -0,0 +1,52 @@
+package org.ods.doc.gen.controllers
+
+
+import org.ods.doc.gen.pdf.conversor.HtmlToPDFService
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+import javax.inject.Inject
+import java.nio.file.Files
+
+@RestController
+class HealthController {
+
+ private HtmlToPDFService htmlToPDFService
+
+ @Inject
+ HealthController(HtmlToPDFService htmlToPDFService){
+ this.htmlToPDFService = htmlToPDFService
+ }
+
+ @GetMapping( "/health")
+ Map check( ) {
+ generatePdfData()
+ Map result = [
+ service: "docgen",
+ status: "passing",
+ time: new Date().toString()
+ ]
+
+ return result
+ }
+
+ private byte[] generatePdfData() {
+ def documentHtmlFile = Files.createTempFile("document", ".html") << "document"
+
+ def pdfBytesToString
+ try {
+ def documentPdf = htmlToPDFService.convert(documentHtmlFile)
+ def data = Files.readAllBytes(documentPdf)
+ if (!new String(data).startsWith("%PDF-1.4\n")) {
+ throw new RuntimeException( "Conversion form HTML to PDF failed, corrupt data.")
+ }
+ pdfBytesToString = data.encodeBase64().toString()
+ } catch (e) {
+ throw new RuntimeException( "Conversion form HTML to PDF failed, corrupt data.", e)
+ } finally {
+ Files.delete(documentHtmlFile)
+ }
+ return pdfBytesToString
+ }
+
+}
diff --git a/src/main/groovy/org/ods/doc/gen/controllers/LevaDocController.groovy b/src/main/groovy/org/ods/doc/gen/controllers/LevaDocController.groovy
new file mode 100644
index 00000000..c82c41f4
--- /dev/null
+++ b/src/main/groovy/org/ods/doc/gen/controllers/LevaDocController.groovy
@@ -0,0 +1,76 @@
+package org.ods.doc.gen.controllers
+
+
+import groovy.util.logging.Slf4j
+import org.apache.commons.io.FileUtils
+import org.ods.doc.gen.pdf.conversor.PdfGenerationService
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+import javax.inject.Inject
+import java.nio.file.Files
+import java.nio.file.Path
+
+import static groovy.json.JsonOutput.prettyPrint
+import static groovy.json.JsonOutput.toJson
+
+@Slf4j
+@RestController
+@RequestMapping("/document")
+class LevaDocController {
+
+ private PdfGenerationService pdfGeneration
+
+ @Inject
+ LevaDocController(PdfGenerationService pdfGenerationService){
+ this.pdfGeneration = pdfGenerationService
+ }
+
+ @PostMapping
+ Map convertDocument(@RequestBody Map body){
+ validateRequestParams(body)
+ logData(body)
+ return convertToPdf(body)
+ }
+
+ private Map convertToPdf(Map body) {
+ Path tmpDir
+ String pdfBytesToString
+ try {
+ tmpDir = Files.createTempDirectory("${body.metadata.type}-v${body.metadata.version}")
+ Path documentPdf = pdfGeneration.generatePdfFile(body.metadata as Map, body.data as Map, tmpDir)
+ pdfBytesToString = Files.readAllBytes(documentPdf).encodeBase64().toString()
+ } catch (Throwable e) {
+ throw new RuntimeException( "Conversion form HTML to PDF failed, corrupt data.", e)
+ } finally {
+ if (tmpDir) {
+ FileUtils.deleteDirectory(tmpDir.toFile())
+ }
+ }
+
+ return [data: pdfBytesToString]
+ }
+
+ private static void logData(Map body) {
+ if (log.isDebugEnabled()) {
+ log.debug("Input request body data before send it to convert it to a pdf: ")
+ log.debug(prettyPrint(toJson(body.data)))
+ }
+ }
+
+ private static void validateRequestParams(Map body) {
+ if (body?.metadata?.type == null) {
+ throw new IllegalArgumentException("missing argument 'metadata.type'")
+ }
+
+ if (body?.metadata?.version == null) {
+ throw new IllegalArgumentException("missing argument 'metadata.version'")
+ }
+
+ if (body?.data == null || 0 == body?.data.size()) {
+ throw new IllegalArgumentException("missing argument 'data'")
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/groovy/org/ods/doc/gen/controllers/RestResponseEntityExceptionHandler.groovy b/src/main/groovy/org/ods/doc/gen/controllers/RestResponseEntityExceptionHandler.groovy
new file mode 100644
index 00000000..50cc11e1
--- /dev/null
+++ b/src/main/groovy/org/ods/doc/gen/controllers/RestResponseEntityExceptionHandler.groovy
@@ -0,0 +1,27 @@
+package org.ods.doc.gen.controllers
+
+import groovy.util.logging.Slf4j
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.ControllerAdvice
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.context.request.WebRequest
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
+
+@Slf4j
+@ControllerAdvice
+class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
+
+ @ExceptionHandler(value = [ IllegalArgumentException.class ])
+ protected ResponseEntity