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 handleArgumentError(RuntimeException ex, WebRequest request) { + log.error("ExceptionHandler, handleArgumentError:${ex.message}", ex) + return handleExceptionInternal(ex, ex.message, new HttpHeaders(), HttpStatus.PRECONDITION_FAILED, request) + } + + @ExceptionHandler(value = [ RuntimeException.class ]) + protected ResponseEntity runtimeError(RuntimeException ex, WebRequest request) { + log.error("ExceptionHandler, runtimeError:${ex.message}", ex) + return handleExceptionInternal(ex, ex.message, new HttpHeaders(), HttpStatus.CONFLICT, request) + } +} \ No newline at end of file diff --git a/src/main/groovy/org/ods/doc/gen/pdf/conversor/HtmlToPDFService.groovy b/src/main/groovy/org/ods/doc/gen/pdf/conversor/HtmlToPDFService.groovy new file mode 100644 index 00000000..a3b173bb --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/pdf/conversor/HtmlToPDFService.groovy @@ -0,0 +1,179 @@ +package org.ods.doc.gen.pdf.conversor + +import com.github.jknack.handlebars.Handlebars +import com.github.jknack.handlebars.io.FileTemplateLoader +import groovy.util.logging.Slf4j +import org.apache.commons.io.output.TeeOutputStream +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination +import org.springframework.stereotype.Service + +import java.nio.file.Files +import java.nio.file.Path + +@Slf4j +@Service +class HtmlToPDFService { + + String executeTemplate(Path path, Object data) { + def loader = new FileTemplateLoader("", "") + return new Handlebars(loader).compile(path.toString()).apply(data) + } + + Path convert(Path documentHtmlFile, Map data = null) { + Path documentPDFFile = Files.createTempFile("document", ".pdf") + List cmd = generateCmd(data, documentHtmlFile, documentPDFFile) + executeCmd(documentHtmlFile, cmd) + fixDestinations(documentPDFFile.toFile()) + return documentPDFFile + } + + private List generateCmd(Map data, Path documentHtmlFile, Path documentPDFFile) { + 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"]) + addHeader(data, cmd) + cmd.addAll(["--footer-center", "'Page [page] of [topage]'", "--footer-font-size", "10"]) + setOrientation(data, cmd) + cmd << documentHtmlFile.toFile().absolutePath + cmd << documentPDFFile.toFile().absolutePath + return cmd + } + + private void setOrientation(Map data, ArrayList cmd) { + if (data?.metadata?.orientation) { + cmd.addAll(["--orientation", data.metadata.orientation]) + } + } + + private void addHeader(Map data, ArrayList cmd) { + 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"]) + } + } + + private void executeCmd(documentHtmlFile, List cmd) { + log.info "executing cmd: ${cmd}" + def proc = cmd.execute() + Path tempFilePath = Files.createTempFile("shell", ".bin") + File tempFile = tempFilePath.toFile() + FileOutputStream tempFileOutputStream = new FileOutputStream(tempFile) + def errOutputStream = new TeeOutputStream(tempFileOutputStream, System.err) + try { + proc.waitForProcessOutput(System.out, errOutputStream) + } finally { + tempFileOutputStream.close() + } + + if (proc.exitValue() != 0) { + String errorDesc = "${documentHtmlFile} failed: code:${proc.exitValue()}\r Description:${tempFile.text}" + log.error errorDesc + throw new IllegalStateException(errorDesc) + } + } + + /** + * 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 file a PDF file. + */ + private void fixDestinations(File file) { + def doc = PDDocument.load(file) + fixDestinations(doc) + doc.save(file) + doc.close() + } + + /** + * 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 void fixDestinations(PDDocument doc) { + def pages = doc.pages as List // Accessing pages by index is slow. This will make it fast. + def catalog = doc.documentCatalog + fixNamedDestinations(catalog, pages) + fixOutline(catalog, pages) + fixExplicitDestinations(pages) + } + + private fixNamedDestinations(catalog, pages) { + fixStringDestinations(catalog.names?.dests, pages) + fixNameDestinations(catalog.dests, pages) + } + + private fixStringDestinations(node, pages) { + if (node) { + node.names?.each { name, dest -> fixDestination(dest, pages) } + node.kids?.each { fixStringDestinations(it, pages) } + } + } + + private fixNameDestinations(dests, pages) { + dests?.COSObject?.keySet()*.name.each { name -> + def dest = dests.getDestination(name) + if (dest in PDPageDestination) { + fixDestination(dest, pages) + } + } + } + + private fixOutline(catalog, pages) { + def outline = catalog.documentOutline + if (outline != null) { + fixOutlineNode(outline, pages) + } + } + + private fixOutlineNode(node, pages) { + node.children().each { item -> + fixDestinationOrAction(item, pages) + fixOutlineNode(item, pages) + } + } + + private fixExplicitDestinations(pages) { + pages.each { page -> + page.getAnnotations { it.subtype == PDAnnotationLink.SUB_TYPE }.each { link -> + fixDestinationOrAction(link, pages) + } + } + } + + private fixDestinationOrAction(item, pages) { + def dest = item.destination + if (dest == null && item.action?.subType == PDActionGoTo.SUB_TYPE) { + dest = item.action.destination + } + if (dest in PDPageDestination) { + fixDestination(dest, pages) + } + } + + private fixDestination(dest, pages) { + def pageNum = dest.pageNumber + if (pageNum != -1) { + dest.setPage(pages[pageNum]) + } + } +} diff --git a/src/main/groovy/org/ods/doc/gen/pdf/conversor/PdfGenerationService.groovy b/src/main/groovy/org/ods/doc/gen/pdf/conversor/PdfGenerationService.groovy new file mode 100644 index 00000000..7c6ef766 --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/pdf/conversor/PdfGenerationService.groovy @@ -0,0 +1,89 @@ +package org.ods.doc.gen.pdf.conversor + +import com.github.benmanes.caffeine.cache.Cache +import org.apache.commons.io.FileUtils +import org.apache.commons.io.FilenameUtils +import org.ods.doc.gen.templates.repository.DocumentTemplateFactory +import org.springframework.core.env.Environment +import org.springframework.stereotype.Service + +import javax.inject.Inject +import java.nio.file.Path +import java.nio.file.Paths + +@Service +class PdfGenerationService { + + private final Cache templatesCache + private final HtmlToPDFService htmlToPDFService + private final String basePath + private final DocumentTemplateFactory documentTemplateFactory + + @Inject + PdfGenerationService(HtmlToPDFService htmlToPDFService, + DocumentTemplateFactory documentTemplateFactory, + Environment environment) { + this.htmlToPDFService = htmlToPDFService + this.documentTemplateFactory = documentTemplateFactory + this.basePath = environment.getProperty("application.documents.cache.basePath") + } + + Path generatePdfFile(Map metadata, Map data, Path tmpDir) { + copyTemplatesToTempFolder(metadata.version as String, tmpDir) + Map partials = getPartialTemplates(metadata.type as String, tmpDir) + Map partialsWithPathOk = generateHtmlFromTemplates(partials, data) + return htmlToPDFService.convert(partialsWithPathOk.document, data) + } + + private copyTemplatesToTempFolder(String version, Path tmpDir) { + FileUtils.copyDirectory( + documentTemplateFactory.get().getTemplatesForVersion(version).toFile(), + tmpDir.toFile() + ) + } + + private Map generateHtmlFromTemplates(Map partials, data) { + return partials.collectEntries { name, path -> + def htmlFile = new File(FilenameUtils.removeExtension(path.toString())) + htmlFile.setText(htmlToPDFService.executeTemplate(path, data)) + return [name, htmlFile.toPath()] + } + } + + private Map getPartialTemplates(String type, Path tmpDir) { + return getPartialTemplates(type, tmpDir) { name, template -> + return template.replaceAll(System.getProperty("line.separator"), "").replaceAll("\t", "") + } + } + + private Map getPartialTemplates(String type, Path tmpDir, Closure visitor) { + def partials = [ + document: Paths.get(tmpDir.toString(), "templates", "${type}.html.tmpl"), + header: Paths.get(tmpDir.toString(), "templates", "header.inc.html.tmpl"), + footer: Paths.get(tmpDir.toString(), "templates", "footer.inc.html.tmpl") + ] + + partials.each { name, path -> + File file = getPartialTemplate(path, name) + def template = file.text + def templateNew = visitor(name, template) + if (isTemplateModified(template, templateNew)) { + file.text = templateNew + } + } + return partials + } + + private boolean isTemplateModified(String template, templateNew) { + template != templateNew + } + + private File getPartialTemplate(path, name) { + def file = path.toFile() + if (!file.exists()) { + throw new FileNotFoundException("could not find required template part '${name}' at '${path}'") + } + return file + } + +} diff --git a/src/main/groovy/org/ods/doc/gen/templates/repository/BitBucketDocumentTemplatesRepository.groovy b/src/main/groovy/org/ods/doc/gen/templates/repository/BitBucketDocumentTemplatesRepository.groovy new file mode 100644 index 00000000..ef94b347 --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/templates/repository/BitBucketDocumentTemplatesRepository.groovy @@ -0,0 +1,115 @@ +package org.ods.doc.gen.templates.repository + +import feign.Feign +import feign.FeignException +import feign.Headers +import feign.Param +import feign.RequestLine +import feign.auth.BasicAuthRequestInterceptor +import groovy.util.logging.Slf4j +import org.apache.http.client.utils.URIBuilder +import org.springframework.core.annotation.Order +import org.springframework.core.env.Environment +import org.springframework.stereotype.Repository + +import javax.inject.Inject +import java.nio.file.Path +import java.nio.file.Paths + +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") + byte[] getTemplatesZipArchiveForVersion(@Param("documentTemplatesProject") String documentTemplatesProject, @Param("documentTemplatesRepo") String documentTemplatesRepo, @Param("version") String version) +} + +@Slf4j +@Order(0) +@Repository +class BitBucketDocumentTemplatesRepository implements DocumentTemplatesRepository { + + private ZipFacade zipFacade + private String basePath + + @Inject + BitBucketDocumentTemplatesRepository(ZipFacade zipFacade, Environment environment){ + this.basePath = environment.getProperty("application.documents.cache.basePath") + this.zipFacade = zipFacade + } + + Path getTemplatesForVersion(String version) { + def targetDir = Paths.get(basePath, version) + URI uri = getURItoDownloadTemplates(version) + def bitbucketUserName = System.getenv("BITBUCKET_USERNAME") + def bitbucketPassword = System.getenv("BITBUCKET_PASSWORD") + log.info ("Using templates @${uri}") + + BitBucketDocumentTemplatesStoreHttpAPI store = createStorageClient(bitbucketUserName, bitbucketPassword, uri) + byte[] zipArchiveContent = getZipArchive(store, version, uri, bitbucketUserName) + return zipFacade.extractZipArchive(zipArchiveContent, targetDir) + } + + 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) { + log.error "Bitbucket adapter not applicable - missing config '${missingEnvs}'" + return false + } + + return true + } + + URI getURItoDownloadTemplates(String version) { + def project = System.getenv('BITBUCKET_DOCUMENT_TEMPLATES_PROJECT') + def repo = System.getenv('BITBUCKET_DOCUMENT_TEMPLATES_REPO') + return new URIBuilder(System.getenv("BITBUCKET_URL") as String) + .setPath("/rest/api/latest/projects/${project}/repos/${ repo}/archive") + .addParameter("at", "refs/heads/release/v${version}") + .addParameter("format", "zip") + .build() + } + + private byte[] getZipArchive(BitBucketDocumentTemplatesStoreHttpAPI store, String version, uri, String bitbucketUserName) { + def bitbucketRepo = System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_REPO") + def bitbucketProject = System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT") + try { + return store.getTemplatesZipArchiveForVersion(bitbucketProject, bitbucketRepo, version) + } 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} \rDoes '${bbUserNameError}' have access?") + } else if (callException instanceof FeignException.NotFound) { + throw new RuntimeException("${baseErrMessage}" + + "\rDoes repository '${bitbucketRepo}' in project: '${bitbucketProject}' exist?") + } else { + throw callException + } + } + } + + private BitBucketDocumentTemplatesStoreHttpAPI createStorageClient(String bitbucketUserName, String bitbucketPassword, URI uri) { + Feign.Builder builder = Feign.builder() + if (bitbucketUserName && bitbucketPassword) { + builder.requestInterceptor(new BasicAuthRequestInterceptor(bitbucketUserName, bitbucketPassword)) + } + + return builder.target(BitBucketDocumentTemplatesStoreHttpAPI.class, uri.getScheme() + "://" + uri.getAuthority()) + } + +} diff --git a/src/main/groovy/org/ods/doc/gen/templates/repository/DocumentTemplateFactory.groovy b/src/main/groovy/org/ods/doc/gen/templates/repository/DocumentTemplateFactory.groovy new file mode 100644 index 00000000..ce60f041 --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/templates/repository/DocumentTemplateFactory.groovy @@ -0,0 +1,20 @@ +package org.ods.doc.gen.templates.repository + +import org.springframework.stereotype.Repository + +import javax.inject.Inject + +@Repository +class DocumentTemplateFactory { + + private final List docTemplates + + @Inject + DocumentTemplateFactory(List docTempls){ + this.docTemplates = docTempls + } + + DocumentTemplatesRepository get(){ + return docTemplates.get(0).isApplicableToSystemConfig() ? docTemplates.get(0) : docTemplates.get(1) + } +} diff --git a/src/main/groovy/org/ods/doc/gen/templates/repository/DocumentTemplatesRepository.groovy b/src/main/groovy/org/ods/doc/gen/templates/repository/DocumentTemplatesRepository.groovy new file mode 100644 index 00000000..d5059b45 --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/templates/repository/DocumentTemplatesRepository.groovy @@ -0,0 +1,19 @@ +package org.ods.doc.gen.templates.repository + + +import org.springframework.stereotype.Repository + +import javax.cache.annotation.CacheKey +import javax.cache.annotation.CacheResult +import java.nio.file.Path + +@Repository +interface DocumentTemplatesRepository { + + @CacheResult(cacheName = "templates") + Path getTemplatesForVersion(@CacheKey String version) + + boolean isApplicableToSystemConfig() + + URI getURItoDownloadTemplates(String version) +} diff --git a/src/main/groovy/org/ods/doc/gen/templates/repository/GithubDocumentTemplatesRepository.groovy b/src/main/groovy/org/ods/doc/gen/templates/repository/GithubDocumentTemplatesRepository.groovy new file mode 100644 index 00000000..7cb62a55 --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/templates/repository/GithubDocumentTemplatesRepository.groovy @@ -0,0 +1,84 @@ +package org.ods.doc.gen.templates.repository + +import feign.Feign +import feign.Headers +import feign.Param +import feign.RequestLine +import groovy.util.logging.Slf4j +import okhttp3.OkHttpClient +import org.apache.http.client.utils.URIBuilder +import org.springframework.core.annotation.Order +import org.springframework.core.env.Environment +import org.springframework.stereotype.Repository + +import javax.inject.Inject +import java.nio.file.Path +import java.nio.file.Paths + +interface GithubDocumentTemplatesStoreHttpAPI { + @Headers("Accept: application/octet-stream") + @RequestLine("GET /opendevstack/ods-document-generation-templates/archive/v{version}.zip") + byte[] getTemplatesZipArchiveForVersion(@Param("version") String version) +} + +@Slf4j +@Order(1) +@Repository +class GithubDocumentTemplatesRepository implements DocumentTemplatesRepository { + + private ZipFacade zipFacade + private String basePath + + @Inject + GithubDocumentTemplatesRepository(ZipFacade zipFacade, Environment environment){ + this.zipFacade = zipFacade + this.basePath = environment.getProperty("application.documents.cache.basePath") + } + + Path getTemplatesForVersion(String version) { + def targetDir = Paths.get(basePath, version) + def uri = getURItoDownloadTemplates(version) + log.info ("Using templates @${uri}") + + GithubDocumentTemplatesStoreHttpAPI store = createStorageClient(uri) + def zipContent = store.getTemplatesZipArchiveForVersion(version) + return zipFacade.extractZipArchive(zipContent, targetDir, "ods-document-generation-templates-${version}") + } + + boolean isApplicableToSystemConfig () { + return true + } + + URI getURItoDownloadTemplates(String version) { + 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() + } + + private GithubDocumentTemplatesStoreHttpAPI createStorageClient(URI uri) { + Feign.Builder builder = createFeignBuilder() + return builder.target( + GithubDocumentTemplatesStoreHttpAPI.class, + uri.getScheme() + "://" + uri.getAuthority() + ) + } + + private Feign.Builder createFeignBuilder() { + String[] httpProxyHost = System.getenv('HTTP_PROXY')?.trim()?.replace('http://','')?.split(':') + log.debug ("Proxy setup: ${httpProxyHost ?: 'not found' }") + if (httpProxyHost && !System.getenv("GITHUB_HOST")) { + return Feign.builder().client(new feign.okhttp.OkHttpClient(buildHttpClient(httpProxyHost))) + } else { + return Feign.builder() + } + } + + private OkHttpClient buildHttpClient(String[] httpProxyHost) { + int httpProxyPort = httpProxyHost.size() == 2 ? Integer.parseInt(httpProxyHost[1]) : 80 + Proxy proxy = new Proxy(Proxy.Type.HTTP, + new InetSocketAddress(httpProxyHost[0], httpProxyPort)); + return new OkHttpClient().newBuilder().proxy(proxy).build(); + } + +} diff --git a/src/main/groovy/org/ods/doc/gen/templates/repository/ZipFacade.groovy b/src/main/groovy/org/ods/doc/gen/templates/repository/ZipFacade.groovy new file mode 100644 index 00000000..84446518 --- /dev/null +++ b/src/main/groovy/org/ods/doc/gen/templates/repository/ZipFacade.groovy @@ -0,0 +1,39 @@ +package org.ods.doc.gen.templates.repository + +import net.lingala.zip4j.ZipFile +import org.apache.commons.io.FileUtils +import org.springframework.stereotype.Service + +import java.nio.file.Files +import java.nio.file.Path + +@Service +class ZipFacade { + + // Extract some Zip archive content into a target directory + Path extractZipArchive(byte[] zipArchiveContent, Path targetDir, String startAtDir = null) { + def tmpFile = Files.createTempFile("archive-", ".zip") + + try { + // Write content to a temp file + Files.write(tmpFile, zipArchiveContent) + + // Create a ZipFile from the temp file + ZipFile zipFile = new ZipFile(tmpFile.toFile()) + + // Extract the ZipFile into targetDir - either from its root, or from a given subDir + zipFile.extractAll(targetDir.toString()) + + /*if (startAtDir) { + FileUtils.copyDirectory(new File(targetDir.toFile(), startAtDir), targetDir.toFile()) + FileUtils.deleteDirectory(new File(targetDir.toFile(), startAtDir)) + }*/ + } catch (Throwable e) { + throw e + } finally { + Files.delete(tmpFile) + } + + return targetDir + } +} diff --git a/src/main/groovy/util/DocUtils.groovy b/src/main/groovy/util/DocUtils.groovy deleted file mode 100644 index ee827f1d..00000000 --- a/src/main/groovy/util/DocUtils.groovy +++ /dev/null @@ -1,71 +0,0 @@ -package util - -import net.lingala.zip4j.core.ZipFile -import org.apache.commons.io.FilenameUtils -import org.apache.commons.io.IOUtils -import org.apache.commons.io.file.PathUtils - -import java.nio.file.DirectoryNotEmptyException -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardCopyOption - -class DocUtils { - - // Extract some Zip archive into a target directory - static Path extractZipArchive(Path zipArchive, Path targetDir, String startAtDir = null) { - // Create a ZipFile from the archive Path - ZipFile zipFile = new ZipFile(zipArchive.toFile()) - boolean targetExists = Files.exists(targetDir) - if (targetExists) { - PathUtils.cleanDirectory(targetDir) - } - - if (startAtDir) { - // Extract the ZipFile into targetDir from a given subDir - def zipArchiveName = zipArchive.getFileName().toString() - FileTools.withTempDir(FilenameUtils.removeExtension(zipArchiveName)) { tmpDir -> - zipFile.extractAll(tmpDir.toString()) - def sourceDir = tmpDir.resolve(startAtDir) - targetDir.initDir(targetExists) { target -> - try { - Files.move(sourceDir, target, StandardCopyOption.REPLACE_EXISTING) - } catch (DirectoryNotEmptyException ignore) { - // Fallback in case targetDir is not in the same file system as sourceDir. - PathUtils.copyDirectory(sourceDir, target, StandardCopyOption.REPLACE_EXISTING) - } - } - } - } else { - // Extract the ZipFile into targetDir from its root - targetDir.initDir(targetExists) { target -> - zipFile.extractAll(target.toString()) - } - } - return targetDir - } - - static String getPDFHeader(Path pdf) { - int len = -1 - def bytes = new byte[8] - pdf.withInputStream { is -> - len = IOUtils.read(is, bytes) - if (len == bytes.length) { - def b = is.read() - // CR == 13, LF == 10. Both are valid here. - if (b != 13 && b != 10) { - len = -len - } - } - } - if (len < bytes.length) { - return null - } - def header = new String(bytes, 'ISO-8859-1') - if (!header ==~ /%PDF-\d\.\d/) { - return null - } - return header - } - -} diff --git a/src/main/groovy/util/FileTools.groovy b/src/main/groovy/util/FileTools.groovy deleted file mode 100644 index 10e6227c..00000000 --- a/src/main/groovy/util/FileTools.groovy +++ /dev/null @@ -1,420 +0,0 @@ -package util - -import groovy.transform.stc.ClosureParams -import groovy.transform.stc.FirstParam -import groovy.transform.stc.SimpleType - -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.FileAttribute - -/** - * This is a set of tools for file creation, mainly temporary files or directories - * that must be deleted in case of error or when their processing completes. - */ -class FileTools { - - private FileTools() {} - - /** - * Creates a new temporary file by invoking the method {@code Files.createTempFile} with the given parameters - * and passes the newly created {@code Path} instance to the given closure. - * The attrs are converted to an array in order to invoke {@code createTempFile} - * The file will be created in the given directory. - * - * It is warranted that the file will be deleted upon exit from the closure, - * whether it succeeds or throws an exception. - * - * Sample usage: - * - * {@Code - * Path parent = ... - * FileTools.withTempFile(parent, 'tst') { tmpFile -> - * // Do something with your temp file. - * // If any exception is thrown, the file will be deleted - * } - * // The file has been deleted - * } - * - * @param dir the directory to create the temporary file in. - * @param prefix the prefix for the new temporary file name. - * @param suffix the suffix for the new temporary file name. If null or not present, '.tmp' will be used. - * @param attrs an optional list of file attributes to set atomically when creating the file. - * @param block a closure to execute with the newly created temporary file. - * @return the value returned by the closure. - * @throws IllegalArgumentException - * if the prefix or suffix parameters cannot be used to generate - * a candidate file name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the file. - * @throws IOException - * if an I/O error occurs or {@code dir} does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access to the file - * and the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the file. - * @throws Exception - * if thrown by the closure. - */ - static T withTempFile(Path dir, - String prefix, - String suffix = null, - List> attrs = [], - @ClosureParams(value = FirstParam.class) Closure block) throws IOException { - return Files.createTempFile(dir, prefix, suffix, attrs as FileAttribute[]).withFile { tmpFile -> - return block(tmpFile) - } - } - - /** - * Creates a new temporary file by invoking the method {@code Files.createTempFile} with the given parameters - * and passes the newly created {@code Path} instance to the given closure. - * The attrs are converted to an array in order to invoke {@code createTempFile} - * The file will be created in the default temporary-file directory. - * - * It is warranted that the file will be deleted upon exit from the closure, - * whether it succeeds or throws an exception. - * - * Sample usage: - * - * {@Code - * FileTools.withTempFile('tst') { tmpFile -> - * // Do something with your temp file. - * // If any exception is thrown, the file will be deleted - * } - * // The file has been deleted - * } - * - * @param prefix the prefix for the new temporary file name. - * @param suffix the suffix for the new temporary file name. If null or not present, '.tmp' will be used. - * @param attrs an optional list of file attributes to set atomically when creating the file. - * @param block a closure to execute with the newly created temporary file. - * @return the value returned by the closure. - * @throws IllegalArgumentException - * if the prefix or suffix parameters cannot be used to generate - * a candidate file name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the file. - * @throws IOException - * if an I/O error occurs or the temporary-file directory does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access to the file - * and the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the file. - * @throws Exception - * if thrown by the closure. - */ - static T withTempFile(String prefix, - String suffix = null, - List> attrs = [], - @ClosureParams(value = SimpleType.class, options = ['java.nio.file.Path']) - Closure block) throws IOException { - return Files.createTempFile(prefix, suffix, attrs as FileAttribute[]).withFile { tmpFile -> - return block(tmpFile) - } - } - - /** - * Creates a new temporary file by invoking the method {@code Files.createTempFile} with the given parameters - * and passes the newly created {@code Path} instance to the given closure for initialisation. - * The attrs are converted to an array in order to invoke {@code createTempFile} - * The file will be created in the given directory. - * - * It is warranted that the file will be deleted in case the closure throws an exception. - * - * Sample usage: - * - * {@Code - * Path parent = ... - * def file = FileTools.newTempFile(parent, 'tst') { tmpFile -> - * // Initialize your temp file, possibly adding contents to it. - * // If any exception is thrown, the file will be deleted - * } - * // The file has been successfully initialized and is available for usage. - * } - * - * @param dir the directory to create the temporary file in. - * @param prefix the prefix for the new temporary file name. - * @param suffix the suffix for the new temporary file name. If null or not present, '.tmp' will be used. - * @param attrs an optional list of file attributes to set atomically when creating the file. - * @param init a closure to initialise the temporary file. - * @return the newly created {@code Path} instance. - * @throws IllegalArgumentException - * if the prefix or suffix parameters cannot be used to generate - * a candidate file name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the file. - * @throws IOException - * if an I/O error occurs or {@code dir} does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access to the file. - * @throws Exception - * if thrown by the closure. - */ - static Path newTempFile(Path dir, - String prefix, - String suffix = null, - List> attrs = [], - @ClosureParams(value = FirstParam.class) Closure init) throws IOException { - return Files.createTempFile(dir, prefix, suffix, attrs as FileAttribute[]).initFile { tmpFile -> - init(tmpFile) - return tmpFile - } - } - - /** - * Creates a new temporary file by invoking the method {@code Files.createTempFile} with the given parameters - * and passes the newly created {@code Path} instance to the given closure for initialisation. - * The attrs are converted to an array in order to invoke {@code createTempFile} - * The file will be created in the default temporary-file directory. - * - * It is warranted that the file will be deleted in case the closure throws an exception. - * - * Sample usage: - * - * {@Code - * def file = FileTools.newTempFile('tst') { tmpFile -> - * // Initialize your temp file, possibly adding contents to it. - * // If any exception is thrown, the file will be deleted - * } - * // The file has been successfully initialized and is available for usage. - * } - * - * @param prefix the prefix for the new temporary file name. - * @param suffix the suffix for the new temporary file name. If null or not present, '.tmp' will be used. - * @param attrs an optional list of file attributes to set atomically when creating the file. - * @param init a closure to initialise the temporary file. - * @return the newly created {@code Path} instance. - * @throws IllegalArgumentException - * if the prefix or suffix parameters cannot be used to generate - * a candidate file name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the file. - * @throws IOException - * if an I/O error occurs or the temporary-file directory does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access to the file. - * @throws Exception - * if thrown by the closure. - */ - static Path newTempFile(String prefix, - String suffix = null, - List> attrs = [], - @ClosureParams(value = SimpleType.class, options = ['java.nio.file.Path']) - Closure init) throws IOException { - return Files.createTempFile(prefix, suffix, attrs as FileAttribute[]).initFile { tmpFile -> - init(tmpFile) - return tmpFile - } - } - - /** - * Creates a new temporary directory by invoking the method {@code Files.createTempDirectory} - * with the given parameters and passes the newly created {@code Path} instance to the given closure. - * The attrs are converted to an array in order to invoke {@code createTempDirectory} - * The new directory will be created in the given parent directory. - * - * It is warranted that the directory and all its contents will be deleted upon exit from the closure, - * whether it succeeds or throws an exception. - * - * Sample usage: - * - * {@Code - * Path parent = ... - * FileTools.withTempDir(parent, 'tst') { tmpDir -> - * // Do something with your temp directory. - * // If any exception is thrown, the directory and its contents will be deleted. - * } - * // The directory and its contents have been deleted. - * } - * - * @param dir the parent directory to create the temporary file in. - * @param prefix the prefix for the new temporary directory name. - * @param attrs an optional list of file attributes to set atomically when creating the directory. - * @param block a closure to execute with the newly created temporary directory. - * @return the value returned by the closure. - * @throws IllegalArgumentException - * if the prefix cannot be used to generate - * a candidate directory name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the directory. - * @throws IOException - * if an I/O error occurs or {@code dir} does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access when creating the directory - * and the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the directory and its contents. - * @throws Exception - * if thrown by the closure. - */ - static T withTempDir(Path dir, - String prefix, - List> attrs = [], - @ClosureParams(value = FirstParam.class) Closure block) throws IOException { - return Files.createTempDirectory(dir, prefix, attrs as FileAttribute[]).withDir { tmpDir -> - return block(tmpDir) - } - } - - /** - * Creates a new temporary directory by invoking the method {@code Files.createTempDirectory} - * with the given parameters and passes the newly created {@code Path} instance to the given closure. - * The attrs are converted to an array in order to invoke {@code createTempDirectory} - * The new directory will be created in the default temporary-file directory. - * - * It is warranted that the directory and all its contents will be deleted upon exit from the closure, - * whether it succeeds or throws an exception. - * - * Sample usage: - * - * {@Code - * FileTools.withTempDir('tst') { tmpDir -> - * // Do something with your temp directory. - * // If any exception is thrown, the directory and its contents will be deleted. - * } - * // The directory and its contents have been deleted. - * } - * - * @param prefix the prefix for the new temporary directory name. - * @param attrs an optional list of file attributes to set atomically when creating the directory. - * @param block a closure to execute with the newly created temporary directory. - * @return the value returned by the closure. - * @throws IllegalArgumentException - * if the prefix cannot be used to generate - * a candidate directory name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the directory. - * @throws IOException - * if an I/O error occurs or the temporary-file directory does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access when creating the directory - * and the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the directory and its contents. - * @throws Exception - * if thrown by the closure. - */ - static T withTempDir(String prefix, - List> attrs = [], - @ClosureParams(value = SimpleType.class, options = ['java.nio.file.Path']) - Closure block) throws IOException { - return Files.createTempDirectory(prefix, attrs as FileAttribute[]).withDir { tmpDir -> - return block(tmpDir) - } - } - - /** - * Creates a new temporary directory by invoking the method {@code Files.createTempDirectory} - * with the given parameters and passes the newly created {@code Path} instance to the given closure. - * The attrs are converted to an array in order to invoke {@code createTempDirectory} - * The new directory will be created in the given parent directory. - * - * It is warranted that the directory and all its contents will be deleted if the closure throws an exception. - * - * Sample usage: - * - * {@Code - * Path parent = ... - * def directory = FileTools.newTempDir(parent, 'tst') { tmpDir -> - * // Initialise your temp directory, possibly adding contents to it. - * // If any exception is thrown, the directory and its contents will be deleted. - * } - * // The directory has been successfully initialized and is available for usage. - * } - * - * @param dir the parent directory to create the temporary file in. - * @param prefix the prefix for the new temporary directory name. - * @param attrs an optional list of file attributes to set atomically when creating the directory. - * @param init a closure to execute with the newly created temporary directory. - * @return the value returned by the closure. - * @throws IllegalArgumentException - * if the prefix cannot be used to generate - * a candidate directory name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the directory. - * @throws IOException - * if an I/O error occurs or {@code dir} does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access when creating the directory. - * @throws Exception - * if thrown by the closure. - */ - static Path newTempDir(Path dir, - String prefix, - List> attrs = [], - @ClosureParams(value = FirstParam.class) Closure init) throws IOException { - return Files.createTempDirectory(dir, prefix, attrs as FileAttribute[]).initDir { tmpDir -> - init(tmpDir) - return tmpDir - } - } - - /** - * Creates a new temporary directory by invoking the method {@code Files.createTempDirectory} - * with the given parameters and passes the newly created {@code Path} instance to the given closure. - * The attrs are converted to an array in order to invoke {@code createTempDirectory} - * The new directory will be created in the default temporary-file directory. - * - * It is warranted that the directory and all its contents will be deleted if the closure throws an exception. - * - * Sample usage: - * - * {@Code - * def directory = FileTools.newTempDir('tst') { tmpDir -> - * // Initialise your temp directory, possibly adding contents to it. - * // If any exception is thrown, the directory and its contents will be deleted. - * } - * // The directory has been successfully initialized and is available for usage. - * } - * - * @param dir the parent directory to create the temporary file in. - * @param prefix the prefix for the new temporary directory name. - * @param attrs an optional list of file attributes to set atomically when creating the directory. - * @param init a closure to execute with the newly created temporary directory. - * @return the value returned by the closure. - * @throws IllegalArgumentException - * if the prefix cannot be used to generate - * a candidate directory name. - * @throws UnsupportedOperationException - * if the {@code attrs} list contains an attribute that cannot be set atomically - * when creating the directory. - * @throws IOException - * if an I/O error occurs or the temporary-file directory does not exist. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkWrite(String)} - * method is invoked to check write access when creating the directory. - * @throws Exception - * if thrown by the closure. - */ - static Path newTempDir(String prefix, - List> attrs = [], - @ClosureParams(value = SimpleType.class, options = ['java.nio.file.Path']) - Closure init) throws IOException { - return Files.createTempDirectory(prefix, attrs as FileAttribute[]).initDir { tmpDir -> - init(tmpDir) - return tmpDir - } - } - -} diff --git a/src/main/groovy/util/Try.groovy b/src/main/groovy/util/Try.groovy deleted file mode 100644 index 0fe6bc72..00000000 --- a/src/main/groovy/util/Try.groovy +++ /dev/null @@ -1,427 +0,0 @@ -package util - -import groovy.transform.stc.ClosureParams -import groovy.transform.stc.FirstParam -import org.apache.commons.io.FileUtils -import org.apache.commons.io.file.PathUtils - -import java.nio.file.Files -import java.nio.file.Path - -/** - * Extension class implementing functionality equivalent to withCloseable, but generalised to resources - * that do not implement {@code AutoCloseable} by providing an optional cleanup closure. - * If no cleanup closure is given and no specialised method exists for the given resource class, - * by default it will try to invoke the {@code close()} method. - * - * An additional functionality is also supported to initialise new resources and to only run the cleanup - * code in case of exception while performing the construction. - * - * Specialised convenience methods are also included to work with {@code File} or {@code Path} - * and have them deleted when the cleanup is invoked. - */ -class Try { - - private static final Closure deleteFile = { File file -> Files.deleteIfExists(file.toPath()) } - private static final Closure deletePath = { Path path -> Files.deleteIfExists(path) } - private static final Closure deleteDir = { File dir -> - dir.toPath() // Validate path. - FileUtils.deleteDirectory(dir) - } - private static final Closure deleteDirContents = { File dir -> - if (Files.exists(dir.toPath())) { - FileUtils.cleanDirectory(dir) - } - return null - } - private static final Closure deleteDirPath = { Path dir -> - if (Files.notExists(dir)) { - return null - } - if (!Files.isDirectory(dir)) { - throw new IllegalArgumentException("Not a directory: ${dir}") - } - PathUtils.deleteDirectory(dir) - } - private static final Closure deleteDirContentsPath = { Path dir -> - if (Files.notExists(dir)) { - return null - } - if (!Files.isDirectory(dir)) { - throw new IllegalArgumentException("Not a directory: ${dir}") - } - PathUtils.cleanDirectory(dir) - } - - private Try() {} - - /** - * Passes this Path to the closure, ensuring that the file is deleted after the closure returns, - * regardless of errors. - * - * As with the try-with-resources statement, if multiple exceptions are thrown, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * Files.createTempFile('tst', null).withFile { tmpFile -> - * // Do something with your temp file. - * // If any exception is thrown, the file will be deleted. - * } - * // The file has been deleted - * } - * - * @param self the {@code Path}. - * @param block the closure taking the {@code Path} as parameter. - * @return the value returned by the closure. - * @throws java.nio.file.DirectoryNotEmptyException - * if the file is a directory and could not otherwise be deleted - * because the directory is not empty (optional specific - * exception) - * @throws IOException - * if an I/O error occurs while deleting the file. - * @throws SecurityException - * In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the file. - * @throws Exception - * if thrown by the closure. - */ - static T withFile(Path self, @ClosureParams(value = FirstParam.class) Closure block) throws IOException { - return withResource(self, deletePath, block) - } - - /** - * Passes this File to the closure, ensuring that the file is deleted after the closure returns, - * regardless of errors. - * - * As with the try-with-resources statement, if multiple exceptions are thrown, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * File.createTempFile('tst', null).withFile { tmpFile -> - * // Do something with your temp file. - * // If any exception is thrown, the file will be deleted. - * } - * // The file has been deleted - * } - * - * @param self the {@code File}. - * @param block the closure taking the {@code File} as parameter. - * @return the value returned by the closure. - * @throws IllegalArgumentException if this abstract path is not a valid path. - * @throws IOException if an I/O error occurs while deleting the file. - * @throws SecurityException if a security manager is installed, - * the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the file. - * @throws Exception if thrown by the closure. - */ - static T withFile(File self, @ClosureParams(value = FirstParam.class) Closure block) throws IOException { - return withResource(self, deleteFile, block) - } - - /** - * Passes this Path to the closure, ensuring that the file is deleted - * whenever the closure throws an exception. - * - * This method is typically used to initialise a file for further use. - - * If the deletion throws an exception, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * def file = Files.createTempFile('tst', null) - * file.initFile { tmpFile -> - * // Initialize the file. - * // If any exception is thrown, the file will be deleted. - * } - * // The file has been successfully initialized and is available for usage. - * } - * - * @param self the {@code Path}. - * @param block the closure taking the {@code Path} as parameter. - * @return the value returned by the closure. - * @throws Exception if thrown by the closure. - */ - static T initFile(Path self, @ClosureParams(value = FirstParam.class) Closure block) { - return initResource(self, deletePath, block) - } - - /** - * Passes this File to the closure, ensuring that the file is deleted - * whenever the closure throws an exception. - * - * This method is typically used to initialise a file for further use. - - * If the deletion throws an exception, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * def file = File.createTempFile('tst', null) - * file.initFile { tmpFile -> - * // Initialize the file. - * // If any exception is thrown, the file will be deleted. - * } - * // The file has been successfully initialized and is available for usage. - * } - * - * @param self the {@code File}. - * @param block the closure taking the {@code File} as parameter. - * @return the value returned by the closure. - * @throws Exception if thrown by the closure. - */ - static T initFile(File self, @ClosureParams(value = FirstParam.class) Closure block) { - return initResource(self, deleteFile, block) - } - - /** - * Passes this Path to the closure, ensuring that the directory and all its contents - * are deleted after the closure returns, regardless of errors. - * - * The Path must represent a directory. - * - * As with the try-with-resources statement, if multiple exceptions are thrown, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * Files.createTempDirectory('tst').withDir { tmpDir -> - * // Do something in your temp directory. - * // If any exception is thrown, the directory and all of its contents will be deleted. - * } - * // The directory and all of its contents has been deleted - * } - * - * @param self the {@code Path}. - * @param cleanContentsOnly if true, the contents will be deleted, but not the directory itself. - * Default: false. - * @param block the closure taking the {@code Path} as parameter. - * @return the value returned by the closure. - * @throws IllegalArgumentException if the file is not a directory. - * @throws IOException if an I/O error occurs while deleting the directory and its contents. - * @throws SecurityException In the case of the default provider, and a security manager is - * installed, the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the directory and its contents. - * @throws Exception if thrown by the closure. - */ - static T withDir(Path self, - boolean cleanContentsOnly = false, - @ClosureParams(value = FirstParam.class) Closure block) throws IOException { - return withResource(self, cleanContentsOnly ? deleteDirContentsPath : deleteDirPath, block) - } - - /** - * Passes this File to the closure, ensuring that the directory and all its contents - * are deleted after the closure returns, regardless of errors. - * - * The File must represent a directory. - * - * As with the try-with-resources statement, if multiple exceptions are thrown, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * File.createTempDir().withDir { tmpDir -> - * // Do something in your temp directory. - * // If any exception is thrown, the directory and all of its contents will be deleted. - * } - * // The directory and all of its contents has been deleted - * } - * - * @param self the {@code File}. - * @param cleanContentsOnly if true, the contents will be deleted, but not the directory itself. - * Default: false. - * @param block the closure taking the {@code File} as parameter. - * @return the value returned by the closure. - * @throws IllegalArgumentException if this abstract path is not a valid path - * or is not a directory. - * @throws IOException if an I/O error occurs while deleting the directory and its contents. - * @throws SecurityException if a security manager is installed, - * the {@code SecurityManager.checkDelete(String)} method - * is invoked to check delete access to the directory and its contents. - * @throws Exception if thrown by the closure. - */ - static T withDir(File self, - boolean cleanContentsOnly = false, - @ClosureParams(value = FirstParam.class) Closure block) throws IOException { - return withResource(self, cleanContentsOnly ? deleteDirContents : deleteDir, block) - } - - /** - * Passes this Path to the closure, ensuring that the directory and all its contents are deleted - * whenever the closure throws an exception. - * - * The Path must represent a directory. - * - * This method is typically used to initialise a directory for further use. - * - * If the deletion throws an exception, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * def directory = Files.createTempDirectory('tst') - * directory.initDir { tmpDir -> - * // Initialize the directory and its contents. - * // If any exception is thrown, the directory and all of its contents will be deleted. - * } - * // The directory has been successfully initialized and is available for usage. - * } - * - * @param self the {@code Path}. - * @param cleanContentsOnly if true, in case of exception, the contents will be deleted, - * but not the directory itself. Default: false. - * @param block the closure taking the {@code Path} as parameter. - * @return the value returned by the closure. - * @throws Exception if thrown by the closure. - */ - static T initDir(Path self, - boolean cleanContentsOnly = false, - @ClosureParams(value = FirstParam.class) Closure block) { - return initResource(self, cleanContentsOnly ? deleteDirContentsPath : deleteDirPath, block) - } - - /** - * Passes this File to the closure, ensuring that the directory and all its contents are deleted - * whenever the closure throws an exception. - * - * The File must represent a directory. - * - * This method is typically used to initialise a directory for further use. - * - * If the deletion throws an exception, the exception from the closure - * will be returned and the exception from deleting will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * def directory = File.createTempDir() - * directory.initDir { tmpDir -> - * // Initialize the directory and its contents. - * // If any exception is thrown, the directory and all of its contents will be deleted. - * } - * // The directory has been successfully initialized and is available for usage. - * } - * - * @param self the {@code File}. - * @param cleanContentsOnly if true, in case of exception, the contents will be deleted, - * but not the directory itself. Default: false. - * @param block the closure taking the {@code File} as parameter. - * @return the value returned by the closure. - * @throws Exception if thrown by the closure. - */ - static T initDir(File self, - boolean cleanContentsOnly = false, - @ClosureParams(value = FirstParam.class) Closure block) { - return initResource(self, cleanContentsOnly ? deleteDirContents : deleteDir, block) - } - - /** - * Allows this resource to be used within the {@code body} closure, ensuring that {@code cleanup} - * has been invoked once the closure has been executed and before this method returns. - * - * If no cleanup closure is provided, by default it will invoke the close method of the resource. - * - * As with the try-with-resources statement, if multiple exceptions are thrown - * the exception from the closure will be returned and the exception from the cleanup - * will be added as a suppressed exception. - * - * Usage example: - * - * {@Code - * Files.createTempDirectory('tst').withResource({ it.deleteDir() }) { tmpDir -> - * // Do something in your temp directory. - * // If any exception is thrown, the directory and all of its contents will be deleted. - * } - * // The directory and all of its contents has been deleted - * } - * - * @param identifier the resource for which to run the provided {@code block} closure. - * @param cleanup the code to perform the desired cleanup on the resource, - * or {@code { it.close() }}, if not provided. - * @param block the code that will make use of the resource. - * @return the value returned by {@code block}. - * @throws Exception if thrown by either {@code block} or {@code cleanup}. - */ - static T withResource(R identifier, - @ClosureParams(value = FirstParam.class) Closure cleanup = { it.close() }, - @ClosureParams(value = FirstParam.class) Closure block) { - Throwable primaryExc = null - try { - return block(identifier) - } catch (Throwable t) { - primaryExc = t - throw t - } finally { - if (identifier != null) { - if (primaryExc != null) { - try { - cleanup(identifier) - } catch (Throwable suppressedExc) { - primaryExc.addSuppressed(suppressedExc) - } - } else { - cleanup(identifier) - } - } - } - } - - /** - * Allows this resource to be used within the {@code body} closure, ensuring that {@code cleanup} - * has been invoked whenever the closure has thrown an exception. - * - * If no cleanup closure is provided, by default it will invoke the close method of the resource. - * - * If the cleanup throws an exception, - * the exception from the closure will be returned and the exception from the cleanup - * will be added as a suppressed exception. - * - * This method is typically used to initialize some resource for later usage. - * - * Usage example: - * - * {@Code - * def directory = Files.createTempDirectory('tst') - * directory.withResource({ it.deleteDir() }) { tmpDir -> - * // Initialize the directory and its contents. - * // If any exception is thrown, the directory and all of its contents will be deleted. - * } - * // The directory has been successfully initialized and is available for usage. - * } - * - * @param identifier the resource for which to run the provided {@code block} closure. - * @param cleanup the code to perform the desired cleanup on the resource, - * or {@code { it.close() }}, if not provided. - * @param block the code that will make use of the resource. - * @return the value returned by {@code block}. - * @throws Exception if thrown by {@code block}. - */ - static T initResource(R identifier, - @ClosureParams(value = FirstParam.class) Closure cleanup = { it.close() }, - @ClosureParams(value = FirstParam.class) Closure block) { - try { - return block(identifier) - } catch (Throwable t) { - if (identifier != null) { - try { - cleanup(identifier) - } catch (Throwable suppressedExc) { - t.addSuppressed(suppressedExc) - } - } - throw t - } - } - -} diff --git a/src/main/resources/Dockerfile b/src/main/resources/Dockerfile new file mode 100644 index 00000000..df77ead2 --- /dev/null +++ b/src/main/resources/Dockerfile @@ -0,0 +1,10 @@ +FROM vindevoy/centos8-openjdk11 + +# 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/packaging/releases/download/0.12.6-1/wkhtmltox-0.12.6-1.centos8.x86_64.rpm && \ + rpm -Uvh wkhtmltox-0.12.6-1.centos8.x86_64.rpm + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..818d3275 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,8 @@ +application.port=111 +application.documents.cache.basePath=/tmp/doc-gen-templates + +server.maxRequestSize=200m +server.http.MaxRequestSize=200m + +spring.mvc.async.request-timeout=750 +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 00000000..a71690a0 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,19 @@ + + + + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable + + + + + + + + + + + + diff --git a/src/test/groovy/app/AppSpec.groovy b/src/test/groovy/app/AppSpec.groovy deleted file mode 100644 index 0fe5f258..00000000 --- a/src/test/groovy/app/AppSpec.groovy +++ /dev/null @@ -1,117 +0,0 @@ -package app - -import groovy.json.JsonOutput - -import io.restassured.http.ContentType - -import static io.restassured.RestAssured.* -import static org.hamcrest.Matchers.startsWith - -class AppSpec extends SpecHelper { - - def setup() { - env.set("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT", "myProject") - env.set("BITBUCKET_DOCUMENT_TEMPLATES_REPO", "myRepo") - env.set("BITBUCKET_URL", "http://localhost:9001") - env.set("BITBUCKET_USERNAME", "user") - env.set("BITBUCKET_PASSWORD", "pass") - } - - def "POST /document"() { - expect: - def version = "1.0" - - mockTemplatesZipArchiveDownload( - new BitBucketDocumentTemplatesStore() - .getZipArchiveDownloadURI(version) - ) - - given() - .accept(ContentType.JSON) - .contentType(ContentType.JSON) - .body(JsonOutput.toJson([ - metadata: [ type: "InstallationReport", version: version ], - data: [ - name: "Project Phoenix", - metadata: [ - header: "header" - ] - ] - ])) - .when() - .port(this.appConfig.getInt("application.port")) - .post("/document") - .then() - .statusCode(200) - .body("data", startsWith("%PDF-1.4\n".bytes.encodeBase64().toString())) - } - - def "POST /document without parameter 'metadata.type'"() { - expect: - def version = "1.0" - - mockTemplatesZipArchiveDownload( - new BitBucketDocumentTemplatesStore() - .getZipArchiveDownloadURI(version) - ) - - given() - .accept(ContentType.JSON) - .contentType(ContentType.JSON) - .body(JsonOutput.toJson([ - metadata: [ type: null, version: version ], - data: [ name: "Project Phoenix" ] - ])) - .when() - .port(this.appConfig.getInt("application.port")) - .post("/document") - .then() - .statusCode(400) - } - - def "POST /document without parameter 'metadata.version'"() { - expect: - def version = "1.0" - - mockTemplatesZipArchiveDownload( - new BitBucketDocumentTemplatesStore() - .getZipArchiveDownloadURI(version) - ) - - given() - .accept(ContentType.JSON) - .contentType(ContentType.JSON) - .body(JsonOutput.toJson([ - metadata: [ type: "InstallationReport", version: null ], - data: [ name: "Project Phoenix" ] - ])) - .when() - .port(this.appConfig.getInt("application.port")) - .post("/document") - .then() - .statusCode(400) - } - - def "POST /document without parameter 'data'"() { - expect: - def version = "1.0" - - mockTemplatesZipArchiveDownload( - new BitBucketDocumentTemplatesStore() - .getZipArchiveDownloadURI(version) - ) - - given() - .accept(ContentType.JSON) - .contentType(ContentType.JSON) - .body(JsonOutput.toJson([ - metadata: [ type: "InstallationReport", version: version ], - data: null - ])) - .when() - .port(this.appConfig.getInt("application.port")) - .post("/document") - .then() - .statusCode(400) - } -} diff --git a/src/test/groovy/app/BitBucketDocumentTemplatesStoreSpec.groovy b/src/test/groovy/app/BitBucketDocumentTemplatesStoreSpec.groovy deleted file mode 100644 index 1228c6d2..00000000 --- a/src/test/groovy/app/BitBucketDocumentTemplatesStoreSpec.groovy +++ /dev/null @@ -1,97 +0,0 @@ -package app - -import com.github.tomakehurst.wiremock.client.WireMock - -import java.nio.file.Files -import java.nio.file.Paths -import feign.FeignException - -import static com.github.tomakehurst.wiremock.client.WireMock.* - -class BitBucketDocumentTemplatesStoreSpec extends SpecHelper { - - def setup() { - env.set("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT", "myProject") - env.set("BITBUCKET_DOCUMENT_TEMPLATES_REPO", "myRepo") - env.set("BITBUCKET_URL", "http://localhost:9001") - } - - def "getTemplatesForVersion"() { - given: - def store = new BitBucketDocumentTemplatesStore() - def targetDir = Files.createTempDirectory("doc-gen-templates-") - def version = "1.0" - - mockTemplatesZipArchiveDownload(store.getZipArchiveDownloadURI(version)) - - when: - def path = store.getTemplatesForVersion(version, targetDir) - - then: - Paths.get(path.toString(), "templates").toFile().exists() - Paths.get(path.toString(), "templates", "footer.inc.html.tmpl").toFile().exists() - Paths.get(path.toString(), "templates", "header.inc.html.tmpl").toFile().exists() - Paths.get(path.toString(), "templates", "InstallationReport.html.tmpl").toFile().exists() - - cleanup: - targetDir.toFile().deleteDir() - } - - def "getTemplatesForVersionNonExistantBranch400"() { - given: - def store = new BitBucketDocumentTemplatesStore() - def targetDir = Files.createTempDirectory("doc-gen-templates-") - def version = "1.0" - - mockTemplatesZipArchiveDownload(store.getZipArchiveDownloadURI(version), 400) - - when: - store.getTemplatesForVersion(version, targetDir) - - then: - def e = thrown(RuntimeException) - e.message.contains("is there a correct release branch configured, called 'release/v${version}'") - - cleanup: - targetDir.toFile().deleteDir() - } - - def "getTemplatesForVersionNonExistantBranch401"() { - given: - def store = new BitBucketDocumentTemplatesStore() - def targetDir = Files.createTempDirectory("doc-gen-templates-") - def version = "1.0" - - mockTemplatesZipArchiveDownload(store.getZipArchiveDownloadURI(version), 401) - - when: - store.getTemplatesForVersion(version, targetDir) - - then: - def e = thrown(RuntimeException) - def bbrepo = System.getenv("BITBUCKET_DOCUMENT_TEMPLATES_REPO") - e.message.contains("In repository '${bbrepo}' - does 'Anyone' have access?") - - cleanup: - targetDir.toFile().deleteDir() - } - - def "getTemplatesForVersionNonExistantBranch500"() { - given: - def store = new BitBucketDocumentTemplatesStore() - def targetDir = Files.createTempDirectory("doc-gen-templates-") - def version = "1.0" - - mockTemplatesZipArchiveDownload(store.getZipArchiveDownloadURI(version), 500) - - when: - store.getTemplatesForVersion(version, targetDir) - - then: - def e = thrown(FeignException.InternalServerError) - - cleanup: - targetDir.toFile().deleteDir() - } - -} diff --git a/src/test/groovy/app/DocGenSpec.groovy b/src/test/groovy/app/DocGenSpec.groovy deleted file mode 100644 index c93f6d82..00000000 --- a/src/test/groovy/app/DocGenSpec.groovy +++ /dev/null @@ -1,193 +0,0 @@ -package app - - -import groovy.xml.XmlUtil -import org.apache.pdfbox.pdmodel.PDDocument -import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink -import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination -import util.DocUtils - -import java.nio.file.Files - -import static org.junit.Assert.assertEquals - -class DocGenSpec extends SpecHelper { - - def setup() { - env.set("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT", "myProject") - env.set("BITBUCKET_DOCUMENT_TEMPLATES_REPO", "myRepo") - env.set("BITBUCKET_URL", "http://localhost:9001") - } - - def "Util.executeTemplate"() { - given: - def templateFile = Files.createTempFile("document", ".html.tmpl") << "{{name}}" - def result = Files.createTempFile('document', '.html') - def data = [ name: "Hello, Handlebars!" ] - - when: - DocGen.Util.executeTemplate(templateFile, result, data) - - then: - result.text == "Hello, Handlebars!" - - cleanup: - Files.delete(templateFile) - Files.delete(result) - } - - def "Util.convertHtmlToPDF"() { - given: - def documentHtmlFile = Files.createTempFile("document", ".html") << "document" - - def data = [ - name: "Project Phoenix", - metadata: [ - header: "header" - ] - ] - - when: - def result = DocGen.Util.convertHtmlToPDF(documentHtmlFile, data) - - then: - def header = DocUtils.getPDFHeader(result) - assertEquals('%PDF-1.4', header) - def is = Files.newInputStream(result) - checkResult(is) - - cleanup: - if(is!=null)is.close() - Files.delete(documentHtmlFile) - if(result!=null)Files.deleteIfExists(result) - } - - def "generate"() { - given: - def version = "1.0" - - def data = [ - name: "Project Phoenix", - metadata: [ - header: "header" - ] - ] - - mockTemplatesZipArchiveDownload( - new BitBucketDocumentTemplatesStore() - .getZipArchiveDownloadURI(version) - ) - - when: - def resultFile = new DocGen().generate("InstallationReport", version, data) - - then: - def header = DocUtils.getPDFHeader(resultFile) - assertEquals('%PDF-1.4', header) - def is = Files.newInputStream(resultFile) - checkResult(is) - - cleanup: - if(is!=null)is.close() - if(resultFile!=null)Files.deleteIfExists(resultFile) - } - - def "generateFromXunit"() { - given: - def version = "1.0" - def xunitresults = new FileNameFinder().getFileNames('src/test/resources/data', '*.xml') - def xunits = [[:]] - xunitresults.each { xunit -> - println ("--< Using file: ${xunit}") - File xunitFile = new File (xunit) - xunits << [name: xunitFile.name, path: xunitFile.path, text: XmlUtil.serialize(xunitFile.text) ] - } - - def data = [ - name: "Project Phoenix", - metadata: [ - header: "header", - ], - data : [ - testFiles : xunits - ] - ] - - println ("downloading templates") - mockTemplatesZipArchiveDownload( - new BitBucketDocumentTemplatesStore() - .getZipArchiveDownloadURI(version) - ) - - when: - println ("generating doc") - def resultFile = new DocGen().generate("DTR", version, data) - - then: - println ("asserting generated file") - def header = DocUtils.getPDFHeader(resultFile) - assertEquals('%PDF-1.4', header) - - def is = Files.newInputStream(resultFile) - checkResult(is) - - - cleanup: - if(is!=null)is.close() - if(resultFile!=null)Files.deleteIfExists(resultFile) - } - - private static void checkResult(InputStream inputStream) { - def resultDoc = PDDocument.load(inputStream) - resultDoc.withCloseable { PDDocument doc -> - doc.pages?.each { page -> - page.getAnnotations { it instanceof PDAnnotationLink } - ?.each { link -> - def dest = link.destination - if (dest == null && link.action instanceof PDActionGoTo) { - dest = link.action.destination - } - if (dest instanceof PDPageDestination) { - assert dest.page != null - } - } - } - def catalog = doc.getDocumentCatalog() - def dests = catalog.dests - dests?.COSObject?.keySet()*.name.each { name -> - def dest = dests.getDestination(name) - if (dest instanceof PDPageDestination) { - assert dest.page != null - } - } - def checkStringDest - checkStringDest = { node -> - if (node) { - node.names?.each { name, dest -> assert dest.page != null } - node.kids?.each { checkStringDest(it) } - } - } - checkStringDest(catalog.names?.dests) - def checkOutlineNode - checkOutlineNode = { node -> - node.children().each { item -> - def dest = item.destination - if (dest == null && item.action?.subType == PDActionGoTo.SUB_TYPE) { - dest = item.action.destination - } - if (dest instanceof PDPageDestination) { - assert dest.page != null - } - checkOutlineNode(item) - } - } - def outline = catalog.documentOutline - if (outline != null) { - checkOutlineNode(outline) - } - return null - } - } - -} diff --git a/src/test/groovy/app/GithubDocumentTemplatesStoreSpec.groovy b/src/test/groovy/app/GithubDocumentTemplatesStoreSpec.groovy deleted file mode 100644 index 8f08d1ed..00000000 --- a/src/test/groovy/app/GithubDocumentTemplatesStoreSpec.groovy +++ /dev/null @@ -1,140 +0,0 @@ -package app - - -import org.junit.Rule -import org.junit.contrib.java.lang.system.EnvironmentVariables - -import java.nio.file.Files -import java.nio.file.Paths - -class GithubDocumentTemplatesStoreSpec extends SpecHelper { - - @Rule - public EnvironmentVariables envVars - - def "getTemplatesForVersionMock"() { - given: - - env.set("GITHUB_HOST", "http://localhost:9001") - def store = new GithubDocumentTemplatesStore() - def targetDir = Files.createTempDirectory("doc-gen-templates-") - def version = "1.0" - - mockGithubTemplatesZipArchiveDownload(store.getZipArchiveDownloadURI(version)) - - when: - def path = store.getTemplatesForVersion(version, targetDir) - - then: - Paths.get(path.toString(), "templates").toFile().exists() - Paths.get(path.toString(), "templates", "footer.inc.html.tmpl").toFile().exists() - Paths.get(path.toString(), "templates", "header.inc.html.tmpl").toFile().exists() - Paths.get(path.toString(), "templates", "TIP.html.tmpl").toFile().exists() - - cleanup: - targetDir.toFile().deleteDir() - env.clear("GITHUB_HOST") - } - - def "verifyProxyInBuilderWPort" () { - given: - envVars.set("HTTP_PROXY", "testproxy:443") - GithubDocumentTemplatesStore store = new GithubDocumentTemplatesStore() - - when: - Map builderMap = store.createBuilder() - - then: - builderMap.size() == 2 - Proxy testProxy = ((Proxy)builderMap['proxy']) - ((InetSocketAddress)testProxy.address()).hostName == 'testproxy' - ((InetSocketAddress)testProxy.address()).port == 443 - builderMap['builder'] instanceof feign.Feign.Builder - - cleanup: - envVars.clear("HTTP_PROXY") - } - - def "verifyProxyWithProtocolInBuilderWPort" () { - given: - envVars.set("HTTP_PROXY", "http://testproxy:443") - GithubDocumentTemplatesStore store = new GithubDocumentTemplatesStore() - - when: - Map builderMap = store.createBuilder() - - then: - builderMap.size() == 2 - Proxy testProxy = ((Proxy)builderMap['proxy']) - ((InetSocketAddress)testProxy.address()).hostName == 'testproxy' - ((InetSocketAddress)testProxy.address()).port == 443 - builderMap['builder'] instanceof feign.Feign.Builder - - cleanup: - envVars.clear("HTTP_PROXY") - } - - def "verifyProxyInBuilderNoPort" () { - given: - envVars.set("HTTP_PROXY", "testproxy") - GithubDocumentTemplatesStore store = new GithubDocumentTemplatesStore() - - when: - Map builderMap = store.createBuilder() - - then: - builderMap.size() == 2 - Proxy testProxy = ((Proxy)builderMap['proxy']) - ((InetSocketAddress)testProxy.address()).hostName == 'testproxy' - ((InetSocketAddress)testProxy.address()).port == 80 - builderMap['builder'] instanceof feign.Feign.Builder - - cleanup: - envVars.clear("HTTP_PROXY") - } - - def "verifyNOProxyInBuilder" () { - given: - envVars.clear("HTTP_PROXY") - GithubDocumentTemplatesStore store = new GithubDocumentTemplatesStore() - - when: - Map builderMap = store.createBuilder() - - then: - builderMap.size() == 1 - builderMap['builder'] instanceof feign.Feign.Builder - } - - def "getTemplatesForVersion"() { - given: - - def store = new GithubDocumentTemplatesStore() - def targetDir = Files.createTempDirectory("doc-gen-templates-") - def version = "1.0" - - when: - def path = store.getTemplatesForVersion(version, targetDir) - - then: - Paths.get(path.toString(), "templates").toFile().exists() - Paths.get(path.toString(), "templates", "footer.inc.html.tmpl").toFile().exists() - Paths.get(path.toString(), "templates", "header.inc.html.tmpl").toFile().exists() - Paths.get(path.toString(), "templates", "TIP.html.tmpl").toFile().exists() - - cleanup: - targetDir.toFile().deleteDir() - } - - def "getRightUrlForVersion"() { - given: - def store = new GithubDocumentTemplatesStore() - - when: - def url = store.getZipArchiveDownloadURI("1.0") - - then: - "https://www.github.com/opendevstack/ods-document-generation-templates/archive/v1.0.zip" == (url.toString()) - } - -} diff --git a/src/test/groovy/org/ods/doc/gen/AppConfig.groovy b/src/test/groovy/org/ods/doc/gen/AppConfig.groovy new file mode 100644 index 00000000..3ce8dabb --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/AppConfig.groovy @@ -0,0 +1,13 @@ +package org.ods.doc.gen + + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.PropertySource + +@TestConfiguration +@PropertySource("classpath:application.properties") +@ComponentScan("org.ods.doc.gen") +class AppConfig { + +} \ No newline at end of file diff --git a/src/test/groovy/org/ods/doc/gen/AppSpec.groovy b/src/test/groovy/org/ods/doc/gen/AppSpec.groovy new file mode 100644 index 00000000..288b06c9 --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/AppSpec.groovy @@ -0,0 +1,32 @@ +package org.ods.doc.gen + +import io.restassured.http.ContentType +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment +import org.springframework.boot.web.server.LocalServerPort +import spock.lang.Specification + +import static io.restassured.RestAssured.given +import static org.hamcrest.Matchers.equalTo + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AppSpec extends Specification { + + @LocalServerPort + private int port; + + def "Spring App is configured OK"() { + expect: "health check is OK" + given() + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .when() + .port(port) + .get("/health") + .then() + .log() + .all() + .statusCode(200) + .body("status", equalTo("passing")) + } +} diff --git a/src/test/groovy/org/ods/doc/gen/DocGenDockerIT.groovy b/src/test/groovy/org/ods/doc/gen/DocGenDockerIT.groovy new file mode 100644 index 00000000..d491ac8d --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/DocGenDockerIT.groovy @@ -0,0 +1,47 @@ +package org.ods.doc.gen + +import groovy.util.logging.Slf4j +import io.restassured.http.ContentType +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.output.Slf4jLogConsumer +import org.testcontainers.spock.Testcontainers +import org.testcontainers.utility.DockerImageName +import spock.lang.Shared +import spock.lang.Specification + +import static io.restassured.RestAssured.given +import static org.hamcrest.Matchers.equalTo + +@Slf4j +@Testcontainers +class DocGenDockerIT extends Specification { + + static final DockerImageName DOCGEN_IMAGE = DockerImageName.parse("ods-document-generation-svc:local"); + + @Shared + GenericContainer docGenContainer = new GenericContainer<>(DOCGEN_IMAGE) + .withExposedPorts(8080) + .withEnv("ROOT_LOG_LEVEL", "TRACE") + + def "docgen is running in docker"() { + given: + def port = docGenContainer.firstMappedPort + Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(log) + docGenContainer.followOutput(logConsumer) + + expect: + given() + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .when() + .port(port) + .get("/health") + .then() + .log() + .all() + .statusCode(200) + .body("status", equalTo("passing")) + } + + +} diff --git a/src/test/groovy/app/SpecHelper.groovy b/src/test/groovy/org/ods/doc/gen/SpecHelper.groovy similarity index 77% rename from src/test/groovy/app/SpecHelper.groovy rename to src/test/groovy/org/ods/doc/gen/SpecHelper.groovy index a9c6ff22..4f47e6a1 100644 --- a/src/test/groovy/app/SpecHelper.groovy +++ b/src/test/groovy/org/ods/doc/gen/SpecHelper.groovy @@ -1,39 +1,19 @@ -package app +package org.ods.doc.gen -import com.github.tomakehurst.wiremock.* -import com.github.tomakehurst.wiremock.client.* -import com.typesafe.config.Config -import com.typesafe.config.ConfigFactory -import java.net.URI - -import org.jooby.Jooby -import org.junit.Rule -import org.junit.contrib.java.lang.system.EnvironmentVariables - -import spock.lang.* +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import spock.lang.Shared +import spock.lang.Specification import static com.github.tomakehurst.wiremock.client.WireMock.* import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options class SpecHelper extends Specification { - @Shared App app - @Shared Config appConfig - @Shared WireMockServer wireMockServer - - @Rule - EnvironmentVariables env = new EnvironmentVariables() - def setupSpec() { - this.app = new App() - this.app.start("server.join=false") + @Shared WireMockServer wireMockServer - this.appConfig = ConfigFactory.load() - } - def cleanupSpec() { - this.app.stop() - } def cleanup() { stopWireMockServer() diff --git a/src/test/groovy/org/ods/doc/gen/controllers/LevaDocControllerSpec.groovy b/src/test/groovy/org/ods/doc/gen/controllers/LevaDocControllerSpec.groovy new file mode 100644 index 00000000..42b36ad9 --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/controllers/LevaDocControllerSpec.groovy @@ -0,0 +1,78 @@ +package org.ods.doc.gen.controllers + +import groovy.json.JsonOutput +import groovy.util.logging.Slf4j +import org.ods.doc.gen.pdf.conversor.PdfGenerationService +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import spock.lang.Specification +import spock.lang.TempDir + +import javax.inject.Inject +import java.nio.file.Files +import java.nio.file.Path + +import static org.mockito.Mockito.when +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(LevaDocController.class) +@Slf4j +class LevaDocControllerSpec extends Specification { + + @TempDir + public Path tempFolder + + @Inject + private MockMvc mockMvc + + @MockBean + private PdfGenerationService service; + + def "/document API is configured OK"() { + given: "A temporal folder " + GroovySpy(Files, global: true) + Files.createTempDirectory(_) >> tempFolder + + and: "PdfGenerationService is mock" + Path pdfFile = Path.of("src/test/resources","dtr_proof.pdf") + String pdfValue = Files.readAllBytes(pdfFile).encodeBase64().toString() + when(service.generatePdfFile(metadataValue, dataValue, tempFolder)).thenReturn(pdfFile) + + expect: "a client call to /document is OK and returned json has pdf data" + def postContent = JsonOutput.toJson([metadata: metadataValue, data: dataValue]) + def returnValue = this.mockMvc + .perform(post("/document").contentType(MediaType.APPLICATION_JSON).content(postContent)) + .andExpect(status().isOk()).andReturn() + // .andExpect(jsonPath("\$.data").value(pdfValue)) + returnValue != null + and: "the tmp folder is deleted" + !Files.exists(tempFolder) + + where: "use valid data to generate pdf" + metadataValue = [ type: "InstallationReport", version: "1.0" ] + dataValue = [ name: "Project Phoenix", metadata: [ header: "header" ]] + } + + def "/document API error msg"() { + expect: "/document return error code" + def postContent = JsonOutput.toJson([metadata: metadataValue, data: dataValue]) + def mvcResult = this.mockMvc + .perform(post("/document").contentType(MediaType.APPLICATION_JSON).content(postContent)) + .andExpect(status().isPreconditionFailed()) + .andReturn() + + and: "And msg error" + mvcResult.response.contentAsString.startsWith("missing argument") + + where: "not valid post content" + dataValue | metadataValue + [ name: "Project Phoenix", metadata: [ header: "header" ]] | [ version: "1.0" ] + [ name: "Project Phoenix", metadata: [ header: "header" ]] | [ type: "InstallationReport" ] + [ ] | [ type: "InstallationReport", version: "1.0" ] + null | [ type: "InstallationReport", version: "1.0" ] + } + +} diff --git a/src/test/groovy/org/ods/doc/gen/pdf/conversor/HtmlToPDFServiceSpec.groovy b/src/test/groovy/org/ods/doc/gen/pdf/conversor/HtmlToPDFServiceSpec.groovy new file mode 100644 index 00000000..0c5174fb --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/pdf/conversor/HtmlToPDFServiceSpec.groovy @@ -0,0 +1,21 @@ +package org.ods.doc.gen.pdf.conversor + +import spock.lang.Specification + +import java.nio.file.Path + +class HtmlToPDFServiceSpec extends Specification { + + def "execution throw error"(){ + given: + def service = new HtmlToPDFService() + def documentHtmlFile = Path.of("src/test/resources","InstallationReport.html.tmpl") + def cmd = ["wkhtmltopdf", "--encoding", "UTF-8", "--no-outline", "--print-media-type"] + + when: + service.executeCmd(documentHtmlFile, cmd) + + then: + def e = thrown(IllegalStateException) + } +} diff --git a/src/test/groovy/org/ods/doc/gen/pdf/converter/PdfGenerationServiceSpec.groovy b/src/test/groovy/org/ods/doc/gen/pdf/converter/PdfGenerationServiceSpec.groovy new file mode 100644 index 00000000..bc2b1cc8 --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/pdf/converter/PdfGenerationServiceSpec.groovy @@ -0,0 +1,165 @@ +package org.ods.doc.gen.pdf.converter + +import groovy.xml.XmlUtil +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination +import org.junit.Rule +import org.ods.doc.gen.AppConfig +import org.ods.doc.gen.SpecHelper +import org.ods.doc.gen.pdf.conversor.PdfGenerationService +import org.ods.doc.gen.templates.repository.BitBucketDocumentTemplatesRepository +import org.ods.doc.gen.templates.repository.GithubDocumentTemplatesRepository +import org.springframework.test.context.ContextConfiguration +import spock.lang.TempDir +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables + +import javax.inject.Inject +import java.nio.file.Files +import java.nio.file.Path + +import static org.hamcrest.MatcherAssert.assertThat +import static org.hamcrest.Matchers.startsWith + +@ContextConfiguration(classes= [AppConfig.class]) +class PdfGenerationServiceSpec extends SpecHelper { + + @Inject + PdfGenerationService pdfGenerationService + + @Inject + GithubDocumentTemplatesRepository githubDocumentTemplatesRepository + + @Inject + BitBucketDocumentTemplatesRepository bitBucketDocumentTemplatesRepository + + @TempDir + public Path tempFolder + + @Rule + EnvironmentVariables env = new EnvironmentVariables() + + def setup(){ + env.setup(); + } + + def cleanup(){ + env.teardown() + } + + def "generate pdf from repo: #repository"() { + given: + def data = [ + name: "Project Phoenix", + metadata: [ + header: "header", + ], + data : [ + testFiles : testFilesResults() + ] + ] + def metadata = [ + version: "1.0", + type: "InstallationReport" + ] + if (repository == "Github"){ + githubRepository(metadata.version as String) + } else { + bitbucketRepository(metadata.version as String) + } + + when: + def resultFile = pdfGenerationService.generatePdfFile(metadata, data, tempFolder) + def result = Files.readAllBytes(resultFile) + then: + assertThat(new String(result), startsWith("%PDF-1.4\n")) + checkResult(result) + + where: + repository << [ "Github", "BuiBucket"] + + } + + private void githubRepository(String version) { + setupGitHub() + mockTemplatesZipArchiveDownload(githubDocumentTemplatesRepository.getURItoDownloadTemplates(version)) + } + + private void bitbucketRepository(String version) { + setupBitBuckect() + mockTemplatesZipArchiveDownload(bitBucketDocumentTemplatesRepository.getURItoDownloadTemplates(version)) + } + + private setupBitBuckect() { + env.set("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT", "myProject") + env.set("BITBUCKET_DOCUMENT_TEMPLATES_REPO", "myRepo") + env.set("BITBUCKET_URL", "http://localhost:9001") + } + + private setupGitHub() { + env.set("GITHUB_HOST", "http://localhost:9001") + } + + private void testFilesResults() { + def xunitresults = new FileNameFinder().getFileNames('src/test/resources/data', '*.xml') + def xunits = [[:]] + xunitresults.each { xunit -> + println("--< Using file: ${xunit}") + File xunitFile = new File(xunit) + xunits << [name: xunitFile.name, path: xunitFile.path, text: XmlUtil.serialize(xunitFile.text)] + } + } + + private void checkResult(byte[] result) { + def resultDoc = PDDocument.load(result) + resultDoc.withCloseable { PDDocument doc -> + doc.pages?.each { page -> + page.getAnnotations { it.subtype == PDAnnotationLink.SUB_TYPE } + ?.each { PDAnnotationLink link -> + def dest = link.destination + if (dest == null && link.action?.subType == PDActionGoTo.SUB_TYPE) { + dest = link.action.destination + } + if (dest in PDPageDestination) { + assert dest.page != null + } + } + } + def catalog = doc.getDocumentCatalog() + def dests = catalog.dests + dests?.COSObject?.keySet()*.name.each { name -> + def dest = dests.getDestination(name) + if (dest in PDPageDestination) { + assert dest.page != null + } + } + def checkStringDest + checkStringDest = { node -> + if (node) { + node.names?.each { name, dest -> assert dest.page != null } + node.kids?.each { checkStringDest(it) } + } + } + checkStringDest(catalog.names?.dests) + def checkOutlineNode + checkOutlineNode = { node -> + node.children().each { item -> + def dest = item.destination + if (dest == null && item.action?.subType == PDActionGoTo.SUB_TYPE) { + dest = item.action.destination + } + if (dest in PDPageDestination) { + assert dest.page != null + } + checkOutlineNode(item) + } + } + def outline = catalog.documentOutline + if (outline != null) { + checkOutlineNode(outline) + } + } + } + +} diff --git a/src/test/groovy/org/ods/doc/gen/templates/repository/BitBucketDocumentTemplatesRepositorySpec.groovy b/src/test/groovy/org/ods/doc/gen/templates/repository/BitBucketDocumentTemplatesRepositorySpec.groovy new file mode 100644 index 00000000..f2c78330 --- /dev/null +++ b/src/test/groovy/org/ods/doc/gen/templates/repository/BitBucketDocumentTemplatesRepositorySpec.groovy @@ -0,0 +1,67 @@ +package org.ods.doc.gen.templates.repository + +import feign.Feign +import feign.FeignException +import feign.Request +import feign.RequestTemplate +import org.junit.Rule +import org.springframework.core.env.Environment +import spock.lang.Specification +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables + + +class BitBucketDocumentTemplatesRepositorySpec extends Specification { + + @Rule + EnvironmentVariables env = new EnvironmentVariables() + + def setup(){ + env.setup() + env.set("BITBUCKET_DOCUMENT_TEMPLATES_PROJECT", "myProject") + env.set("BITBUCKET_DOCUMENT_TEMPLATES_REPO", "myRepo") + env.set("BITBUCKET_URL", "http://localhost:9001") + } + + def cleanup(){ + env.teardown() + } + + def "error msg in request by #exceptionTypeName"(){ + given: + def version = "1.0" + def environment = Mock(Environment) + def repository = new BitBucketDocumentTemplatesRepository(null, environment) + def uri = repository.getURItoDownloadTemplates(version) + def store = Mock(BitBucketDocumentTemplatesStoreHttpAPI) + store.getTemplatesZipArchiveForVersion(_, _, _) >> { throw createException(exceptionTypeName)} + + when: + repository.getZipArchive(store, version, uri, "bitbucketUserName") + + then: + def e = thrown(RuntimeException) + + where: + exceptionTypeName << ["BadRequest", "Unauthorized", "NotFound", "Other"] + } + + private FeignException createException(String exceptionTypeName) { + def headers = ["a":"aa"] + def request = Request.create(Request.HttpMethod.GET, "url", headers, Request.Body.create("h"), new RequestTemplate()) + def feignException + switch(exceptionTypeName) { + case "BadRequest": + feignException = new FeignException.BadRequest("BadRequest", request, null, null) + break + case "Unauthorized": + feignException = new FeignException.Unauthorized("BadRequest", request, null, null) + break + case "NotFound": + feignException = new FeignException.NotFound("BadRequest", request, null, null) + break + default: + feignException = new FeignException(500, "") + } + return feignException + } +} diff --git a/src/test/groovy/util/DocUtilsSpec.groovy b/src/test/groovy/util/DocUtilsSpec.groovy deleted file mode 100644 index ca607080..00000000 --- a/src/test/groovy/util/DocUtilsSpec.groovy +++ /dev/null @@ -1,68 +0,0 @@ -package util - -import spock.lang.Specification - -import java.nio.file.Files -import java.nio.file.StandardCopyOption - -class DocUtilsSpec extends Specification { - - def "test extractZipArchive"() { - given: - def zipIs = getClass().getClassLoader().getResourceAsStream('templates.zip') - def zipFile = Files.createTempFile('templates', '.zip') - Files.copy(zipIs, zipFile, StandardCopyOption.REPLACE_EXISTING) - def targetDir = Files.createTempDirectory('tst') - def ret - - when: 'Target dir is empty and we extract the full archive' - ret = DocUtils.extractZipArchive(zipFile, targetDir) - - then: 'Correctly extracted' - ret == targetDir - Files.isDirectory(ret) - - when: 'Target dir has content and we extract the full archive' - ret = DocUtils.extractZipArchive(zipFile, targetDir) - - then: 'Correctly extracted' - ret == targetDir - Files.isDirectory(ret) - - when: 'Target dir has content and we extract part of the archive' - ret = DocUtils.extractZipArchive(zipFile, targetDir, 'templates') - - then: 'Correctly extracted' - ret == targetDir - Files.isDirectory(ret) - - when: 'Target dir is empty and we extract part of the archive' - targetDir.deleteDir() - Files.createDirectory(targetDir) - ret = DocUtils.extractZipArchive(zipFile, targetDir, 'templates') - - then: 'Correctly extracted' - ret == targetDir - Files.isDirectory(ret) - - when: 'Target dir does not exist and we extract part of the archive' - targetDir.deleteDir() - ret = DocUtils.extractZipArchive(zipFile, targetDir, 'templates') - - then: 'Correctly extracted' - ret == targetDir - Files.isDirectory(ret) - - when: 'Target dir does not exist and we extract the full archive' - targetDir.deleteDir() - ret = DocUtils.extractZipArchive(zipFile, targetDir, 'templates') - - then: 'Correctly extracted' - ret == targetDir - Files.isDirectory(ret) - - cleanup: - targetDir.deleteDir() - } - -} diff --git a/src/test/groovy/util/FileToolsSpec.groovy b/src/test/groovy/util/FileToolsSpec.groovy deleted file mode 100644 index d0f4e7a5..00000000 --- a/src/test/groovy/util/FileToolsSpec.groovy +++ /dev/null @@ -1,288 +0,0 @@ -package util - -import spock.lang.Specification - -import java.nio.file.Files -import java.nio.file.attribute.FileAttribute - -class FileToolsSpec extends Specification { - - def "test withTempFile with parent"() { - given: - def parent = Files.createTempDirectory('tst') - def prefix = 'tst' - def suffix = '.ext' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - - when: 'The closure succeeds' - ret = FileTools.withTempFile(parent, prefix, suffix, attrs) { return [it] } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempFile(parent, prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret[0].is(tempFile) - Files.notExists(tempFile) - - when: 'The closure throws an exception' - FileTools.withTempFile(parent, prefix, suffix, attrs) { throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempFile(parent, prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - Files.notExists(tempFile) - thrown(RuntimeException) - - cleanup: - parent.deleteDir() - Files.deleteIfExists(tempFile) - } - - def "test withTempFile without parent"() { - given: - def prefix = 'tst' - def suffix = '.ext' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - - when: 'The closure succeeds' - ret = FileTools.withTempFile(prefix, suffix, attrs) { return [it] } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempFile(prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret[0].is(tempFile) - Files.notExists(tempFile) - - when: 'The closure throws an exception' - FileTools.withTempFile(prefix, suffix, attrs) { throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempFile(prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - Files.notExists(tempFile) - thrown(RuntimeException) - - cleanup: - Files.deleteIfExists(tempFile) - } - - def "test newTempFile with parent"() { - given: - def parent = Files.createTempDirectory('tst') - def prefix = 'tst' - def suffix = '.ext' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - def closureArg = null - - when: 'The closure throws an exception' - FileTools.newTempFile(parent, prefix, suffix, attrs) { closureArg = it; throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempFile(parent, prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - closureArg.is(tempFile) - Files.notExists(tempFile) - thrown(RuntimeException) - - when: 'The closure succeeds' - ret = FileTools.newTempFile(parent, prefix, suffix, attrs) { closureArg = it; return null } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempFile(parent, prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret.is(tempFile) - closureArg.is(tempFile) - Files.exists(tempFile) - - cleanup: - parent.deleteDir() - Files.deleteIfExists(tempFile) - } - - def "test newTempFile without parent"() { - given: - def prefix = 'tst' - def suffix = '.ext' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - def closureArg = null - - when: 'The closure throws an exception' - FileTools.newTempFile(prefix, suffix, attrs) { closureArg = it; throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempFile(prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - closureArg.is(tempFile) - Files.notExists(tempFile) - thrown(RuntimeException) - - when: 'The closure succeeds' - ret = FileTools.newTempFile(prefix, suffix, attrs) { closureArg = it; return null } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempFile(prefix, suffix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret.is(tempFile) - closureArg.is(tempFile) - Files.exists(tempFile) - - cleanup: - Files.deleteIfExists(tempFile) - } - - def "test withTempDir with parent"() { - given: - def parent = Files.createTempDirectory('tst') - def prefix = 'tst' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - - when: 'The closure succeeds' - ret = FileTools.withTempDir(parent, prefix, attrs) { return [it] } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempDirectory(parent, prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret[0].is(tempFile) - Files.notExists(tempFile) - - when: 'The closure throws an exception' - FileTools.withTempDir(parent, prefix, attrs) { throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempDirectory(parent, prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - Files.notExists(tempFile) - thrown(RuntimeException) - - cleanup: - parent.deleteDir() - } - - def "test withTempDir without parent"() { - given: - def prefix = 'tst' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - - when: 'The closure succeeds' - ret = FileTools.withTempDir(prefix, attrs) { return [it] } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempDirectory(prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret[0].is(tempFile) - Files.notExists(tempFile) - - when: 'The closure throws an exception' - FileTools.withTempDir(prefix, attrs) { throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempDirectory(prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - Files.notExists(tempFile) - thrown(RuntimeException) - - cleanup: - Files.deleteIfExists(tempFile) - } - - def "test newTempDir with parent"() { - given: - def parent = Files.createTempDirectory('tst') - def prefix = 'tst' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - def closureArg = null - - when: 'The closure succeeds' - ret = FileTools.newTempDir(parent, prefix, attrs) { closureArg = it; return null } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempDirectory(parent, prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret.is(tempFile) - closureArg.is(tempFile) - Files.exists(tempFile) - - when: 'The closure throws an exception' - FileTools.newTempDir(parent, prefix, attrs) { closureArg = it; throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempDirectory(parent, prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - closureArg.is(tempFile) - Files.notExists(tempFile) - thrown(RuntimeException) - - cleanup: - parent.deleteDir() - } - - def "test newTempDir without parent"() { - given: - def prefix = 'tst' - def attrs = [] - GroovySpy(Files, global: true) - def ret - def tempFile = null - def closureArg = null - - when: 'The closure succeeds' - ret = FileTools.newTempDir(prefix, attrs) { closureArg = it; return null } - - then: 'The temp file was created with Files and has been deleted' - 1 * Files.createTempDirectory(prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - ret.is(tempFile) - closureArg.is(tempFile) - Files.exists(tempFile) - - when: 'The closure throws an exception' - FileTools.newTempDir(prefix, attrs) { closureArg = it; throw new RuntimeException() } - - then: 'A TempFile was created with Files and deleted afterwards. The exception is propagated.' - 1 * Files.createTempDirectory(prefix, { FileAttribute[] varArgs -> - Arrays.equals(varArgs, attrs as FileAttribute[]) - }) >> { tempFile = callRealMethod() } - closureArg.is(tempFile) - Files.notExists(tempFile) - thrown(RuntimeException) - - cleanup: - Files.deleteIfExists(tempFile) - } - -} diff --git a/src/test/groovy/util/TrySpec.groovy b/src/test/groovy/util/TrySpec.groovy deleted file mode 100644 index 5475731b..00000000 --- a/src/test/groovy/util/TrySpec.groovy +++ /dev/null @@ -1,673 +0,0 @@ -package util - -import spock.lang.Specification - -import java.nio.file.DirectoryNotEmptyException -import java.nio.file.Files - -class TrySpec extends Specification { - - def "test withFile_File"() { - given: - def file - def ret - def e - - when: 'The closure completes successfully' - file = File.createTempFile('tst', null) - ret = file.withFile { return 1 } - - then: 'The return value is that of the closure and the file has been deleted' - ret == 1 - !file.exists() - - when: 'The closure throws an exception' - file = File.createTempFile('tst', null) - file.withFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the file has been deleted' - thrown(IndexOutOfBoundsException) - !file.exists() - - when: 'The closure completes successfully, but the file does not exist' - file = File.createTempFile('tst', null) - file.delete() - ret = file.withFile { return 2 } - - then: 'The return value is that of the closure and no exception is thrown when trying to delete the file' - notThrown(IOException) - ret == 2 - - when: 'The closure throws an exception and the file does not exist' - file.withFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure completes successfully, but the file path is invalid' - file = new File('Q:\\\\\0') - file.withFile { return 3 } - - then: 'The IllegalArgumentException thrown when trying to delete the file is propagated' - thrown(IllegalArgumentException) - - when: 'The closure throws an exception and the file path is invalid' - file = new File('Q:\\\\\0') - file.withFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - when: 'The closure completes successfully, but the file cannot be deleted' - file = File.createTempDir() - File.createTempFile('tst', null, file) - file.withFile { return 3 } - - then: 'The IOException thrown when trying to delete the file is propagated' - thrown(IOException) - - cleanup: - file.deleteDir() - } - - def "test initFile_File"() { - given: - def file - def ret - def e - - when: 'The closure completes successfully' - file = File.createTempFile('tst', null) - ret = file.initFile { return 1 } - - then: 'The return value is that of the closure and the file has not been deleted' - ret == 1 - file.exists() - - when: 'The closure throws an exception' - file.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the file has been deleted' - thrown(IndexOutOfBoundsException) - !file.exists() - - when: 'The closure throws an exception and the file does not exist' - file.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure throws an exception and the file path is invalid' - file = new File('Q:\\\\\0') - file.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - when: 'The closure throws an exception, but the file cannot be deleted' - file = File.createTempDir() - File.createTempFile('tst', null, file) - file.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the IOException thrown when trying to delete the file is suppressed' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IOException - - cleanup: - file.deleteDir() - } - - def "test withFile_Path"() { - given: - def path - def ret - def e - - when: 'The closure completes successfully' - path = Files.createTempFile('tst', null) - ret = path.withFile { return 1 } - - then: 'The return value is that of the closure and the file has been deleted' - ret == 1 - Files.notExists(path) - - when: 'The closure throws an exception' - path = Files.createTempFile('tst', null) - path.withFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the file has been deleted' - thrown(IndexOutOfBoundsException) - Files.notExists(path) - - when: 'The closure completes successfully, but the file does not exist' - ret = path.withFile { return 2 } - - then: 'The return value is that of the closure and no exception is thrown when trying to delete the file' - notThrown(IOException) - ret == 2 - - when: 'The closure throws an exception and the file does not exist' - path.withFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure completes successfully, but the file is a non-empty directory' - path = Files.createTempDirectory('tst') - Files.createTempFile(path, 'tst', null) - path.withFile { return 3 } - - then: 'The DirectoryNotEmptyException thrown when trying to delete the file is propagated' - thrown(DirectoryNotEmptyException) - - when: 'The closure throws an exception and the file is a non-empty directory' - path.withFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof DirectoryNotEmptyException - - cleanup: - path.deleteDir() - } - - def "test initFile_Path"() { - given: - def path - def ret - def e - - when: 'The closure completes successfully' - path = Files.createTempFile('tst', null) - ret = path.initFile { return 1 } - - then: 'The return value is that of the closure and the file has not been deleted' - ret == 1 - Files.exists(path) - - when: 'The closure throws an exception' - path.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the file has been deleted' - thrown(IndexOutOfBoundsException) - Files.notExists(path) - - when: 'The closure throws an exception and the file does not exist' - path.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure throws an exception and the file is a non-empty directory' - path = Files.createTempDirectory('tst') - Files.createTempFile(path, 'tst', null) - path.initFile { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and DirectoryNotEmptyException is suppressed when trying to delete the file' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof DirectoryNotEmptyException - - cleanup: - path.deleteDir() - } - - def "test withDir_File"() { - given: - def dir - def contents - def ret - def e - - when: 'The closure completes successfully. Delete the contents only.' - dir = File.createTempDir() - contents = File.createTempFile('tst', null, dir) - ret = dir.withDir(true) { return 1 } - - then: 'The return value is that of the closure and the directory only has been deleted' - ret == 1 - dir.exists() - !contents.exists() - - when: 'The closure completes successfully' - File.createTempFile('tst', null, dir) - ret = dir.withDir { return 1 } - - then: 'The return value is that of the closure and the directory has been deleted' - ret == 1 - !dir.exists() - - when: 'The closure throws an exception. Delete the contents only.' - dir = File.createTempDir() - contents = File.createTempFile('tst', null, dir) - dir.withDir(true) { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory contents only has been deleted' - thrown(IndexOutOfBoundsException) - dir.exists() - !contents.exists() - - when: 'The closure throws an exception' - File.createTempFile('tst', null, dir) - dir.withDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory has been deleted' - thrown(IndexOutOfBoundsException) - !dir.exists() - - when: 'The closure completes successfully, but the directory does not exist' - ret = dir.withDir { return 2 } - - then: 'The return value is that of the closure and no exception is thrown when trying to delete the directory' - notThrown(IOException) - ret == 2 - - when: 'The closure throws an exception and the file does not exist' - dir.withDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure completes successfully, but the directory path is invalid' - dir = new File('Q:\\\\\0') - dir.withDir { return 3 } - - then: 'The IllegalArgumentException thrown when trying to delete the directory is propagated' - thrown(IllegalArgumentException) - - when: 'The closure throws an exception and the directory path is invalid' - dir = new File('Q:\\\\\0') - dir.withDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - when: 'The closure completes successfully, but the file is not a directory' - dir = File.createTempFile('tst', null) - dir.withDir { return 3 } - - then: 'The IllegalArgumentException thrown when trying to delete the directory is propagated' - thrown(IllegalArgumentException) - - cleanup: - dir?.delete() - contents?.delete() - } - - def "test initDir_File"() { - given: - def dir - def contents - def ret - def e - - when: 'The closure completes successfully' - dir = File.createTempDir() - contents = File.createTempFile('tst', null, dir) - ret = dir.initDir { return 1 } - - then: 'The return value is that of the closure and neither the directory nor its contents have been deleted' - ret == 1 - dir.exists() - contents.exists() - - when: 'The closure throws an exception. Delete the contents only' - dir.initDir(true) { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory contents only has been deleted' - thrown(IndexOutOfBoundsException) - dir.exists() - !contents.exists() - - when: 'The closure throws an exception' - File.createTempFile('tst', null, dir) - dir.initDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory has been deleted' - thrown(IndexOutOfBoundsException) - !dir.exists() - - when: 'The closure throws an exception and the directory does not exist' - dir.initDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure throws an exception and the directory path is invalid' - dir = new File('Q:\\\\\0') - dir.initDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - when: 'The closure throws an exception, but the file is not a directory' - dir = File.createTempFile('tst', null) - dir.initDir {throw new IndexOutOfBoundsException() } - - then: 'The IllegalArgumentException thrown when trying to delete the directory is propagated' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - cleanup: - dir?.delete() - contents?.delete() - } - - def "test withDir_Path"() { - given: - def path - def contents - def ret - def e - - when: 'The closure completes successfully. Delete contents only.' - path = Files.createTempDirectory('tst') - contents = Files.createTempFile(path, 'tst', null) - ret = path.withDir(true) { return 1 } - - then: 'The return value is that of the closure and the directory contents only has been deleted' - ret == 1 - Files.exists(path) - Files.notExists(contents) - - when: 'The closure completes successfully' - Files.createTempFile(path, 'tst', null) - ret = path.withDir { return 1 } - - then: 'The return value is that of the closure and the directory has been deleted' - ret == 1 - Files.notExists(path) - - when: 'The closure throws an exception. Delete contents only.' - path = Files.createTempDirectory('tst') - contents = Files.createTempFile(path, 'tst', null) - path.withDir(true) { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory contents only has been deleted' - thrown(IndexOutOfBoundsException) - Files.exists(path) - Files.notExists(contents) - - when: 'The closure throws an exception' - Files.createTempFile(path, 'tst', null) - path.withDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory has been deleted' - thrown(IndexOutOfBoundsException) - Files.notExists(path) - - when: 'The closure completes successfully, but the directory does not exist' - ret = path.withDir { return 2 } - - then: 'The return value is that of the closure and no exception is thrown when trying to delete the directory' - notThrown(IOException) - ret == 2 - - when: 'The closure throws an exception and the directory does not exist' - path.withDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure completes successfully, but the path is not a directory' - path = Files.createTempFile('tst', null) - path.withDir { return 3 } - - then: 'The IllegalArgumentException thrown when trying to delete the directory is propagated' - thrown(IllegalArgumentException) - - when: 'The closure throws an exception and the path is not a directory' - path.withDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - cleanup: - if (path) Files.deleteIfExists(path) - if (contents) Files.deleteIfExists(contents) - } - - def "test initDir_Path"() { - given: - def path - def contents - def ret - def e - - when: 'The closure completes successfully' - path = Files.createTempDirectory('tst') - contents = Files.createTempFile(path, 'tst', null) - ret = path.initDir { return 1 } - - then: 'The return value is that of the closure and neither the directory not its contents have been deleted' - ret == 1 - Files.exists(path) - Files.exists(contents) - - when: 'The closure throws an exception. Delete contents only.' - path.initDir(true) { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory contents only has been deleted' - thrown(IndexOutOfBoundsException) - Files.exists(path) - Files.notExists(contents) - - when: 'The closure throws an exception' - Files.createTempFile(path, 'tst', null) - path.initDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the directory has been deleted' - thrown(IndexOutOfBoundsException) - Files.notExists(path) - - when: 'The closure throws an exception and the directory does not exist' - path.initDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and no exception is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed.length == 0 - - when: 'The closure throws an exception and the path is not a directory' - path = Files.createTempFile('tst', null) - path.initDir { throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and IllegalArgumentException is suppressed when trying to delete the directory' - e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof IllegalArgumentException - - cleanup: - Files.deleteIfExists(path) - } - - def "test withResource"() { - given: - def closeable = new MyCloseable() - def aList - def ret - - when: 'The closure returns successfully and no explicit cleanup is specified' - closeable.reset() - ret = closeable.withResource {return 1 } - - then: 'The return value is that of the closure and the close method was called' - ret == 1 - closeable.isClosed() - - when: 'The closure throws an exception and no explicit cleanup is specified' - closeable.reset() - closeable.withResource {throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the close method was called' - thrown(IndexOutOfBoundsException) - closeable.isClosed() - - when: 'The closure returns successfully and a explicit cleanup is specified' - closeable.close() - ret = closeable.withResource({ it.reset() }) {return 2 } - - then: 'The return value is that of the closure and the given cleanup was executed' - ret == 2 - !closeable.isClosed() - - when: 'The closure throws an exception and a explicit cleanup is specified' - closeable.close() - closeable.withResource({ it.reset() }) {throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the given cleanup was executed' - thrown(IndexOutOfBoundsException) - !closeable.isClosed() - - when: 'The closure returns successfully, but the cleanup fails' - closeable.reset() - closeable.withResource({ it.fail() }) {return 3 } - - then: 'The exception thrown by the cleanup is propagated' - thrown(UnsupportedOperationException) - !closeable.isClosed() - - when: 'The closure throws an exception and the cleanup fails' - closeable.reset() - closeable.withResource({ it.fail() }) { - throw new IndexOutOfBoundsException() - } - - then: 'The exception is propagated and the cleanup exception is suppressed' - def e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof UnsupportedOperationException - !closeable.isClosed() - - when: 'Specific cleanup with a resource that has no close method. The closure succeeds.' - aList = ['test'] - ret = aList.withResource({ it.clear() }) { - return it.size() - } - - then: 'The returned value is that of the closure and the cleanup was run' - ret == 1 - aList.isEmpty() - - when: 'Specific cleanup with a resource that has no close method. The closure throws an exception.' - aList = ['test'] - aList.withResource({ it.clear() }) { - throw new IndexOutOfBoundsException() - } - - then: 'The exception is propagated and the cleanup was run' - thrown(IndexOutOfBoundsException) - aList.isEmpty() - } - - def "test initResource"() { - given: - def closeable = new MyCloseable() - def aList - def ret - - when: 'The closure returns successfully and no explicit cleanup is specified' - closeable.reset() - ret = closeable.initResource {return 1 } - - then: 'The return value is that of the closure and the close method was not called' - ret == 1 - !closeable.isClosed() - - when: 'The closure throws an exception and no explicit cleanup is specified' - closeable.reset() - closeable.initResource {throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the close method was called' - thrown(IndexOutOfBoundsException) - closeable.isClosed() - - when: 'The closure returns successfully and a explicit cleanup is specified' - closeable.close() - ret = closeable.initResource({ it.reset() }) {return 2 } - - then: 'The return value is that of the closure and the given cleanup was not executed' - ret == 2 - closeable.isClosed() - - when: 'The closure throws an exception and a explicit cleanup is specified' - closeable.close() - closeable.initResource({ it.reset() }) {throw new IndexOutOfBoundsException() } - - then: 'The exception is propagated and the given cleanup was executed' - thrown(IndexOutOfBoundsException) - !closeable.isClosed() - - when: 'The closure returns successfully, but the cleanup fails' - closeable.reset() - ret = closeable.initResource({ it.fail() }) {return 3 } - - then: 'No exception was thrown as the cleanup was not run and the returned value is that of the closure' - notThrown(UnsupportedOperationException) - ret == 3 - !closeable.isClosed() - - when: 'The closure throws an exception and the cleanup fails' - closeable.reset() - closeable.initResource ({ it.fail() }) { - throw new IndexOutOfBoundsException() - } - - then: 'The exception is propagated and the cleanup exception is suppressed' - def e = thrown(IndexOutOfBoundsException) - e.suppressed[0] instanceof UnsupportedOperationException - !closeable.isClosed() - - when: 'Specific cleanup with a resource that has no close method. The closure succeeds.' - aList = ['test'] - ret = aList.initResource({ it.clear() }) { - return it.size() - } - - then: 'The returned value is that of the closure and the cleanup was not run' - ret == 1 - aList[0] == 'test' - - when: 'Specific cleanup with a resource that has no close method. The closure throws an exception.' - aList = ['test'] - aList.initResource({ it.clear() }) { - throw new IndexOutOfBoundsException() - } - - then: 'The exception is propagated and the cleanup was run' - thrown(IndexOutOfBoundsException) - aList.isEmpty() - } - - private static class MyCloseable { - - private closed = false - - boolean isClosed() { - return closed - } - - void close() { - closed = true - } - - void reset() { - closed = false - } - - @SuppressWarnings('GrMethodMayBeStatic') - void fail() { - throw new UnsupportedOperationException() - } - - } - -} diff --git a/src/test/resources/InstallationReport.html.tmpl b/src/test/resources/InstallationReport.html.tmpl new file mode 100644 index 00000000..b06470cb --- /dev/null +++ b/src/test/resources/InstallationReport.html.tmpl @@ -0,0 +1,398 @@ + + + + + Installation Report for '{{metadata.name}}' + + + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ Stack Information +
Name:{{metadata.name}}
Description:{{metadata.description}}
Version:{{metadata.version}}
Date Created:{{metadata.date_created}}
+
+ +
 
+ +

Installation Report for '{{metadata.name}}'

+ +

Table of Contents

+
    +
  1. Introduction
  2. +
      +
    1. Inputs
    2. +
    3. Outputs
    4. +
    5. Modules
    6. +
    +
  3. Installation
  4. +
      +
    1. Input Values
    2. +
    3. Output Values
    4. +
    +
  5. Diagnostics and Testing
  6. +
      +
    1. Pre-Installation Tests
    2. +
        +
      1. Blueprints
      2. +
          + {{#each blueprint_test_reports}} +
        1. {{name}}
        2. + {{/each}} +
        +
      3. Stack
      4. +
      +
    3. Post-Installation Tests
    4. +
    +
  7. Conclusion Statement
  8. +
  9. Definitions and Abbreviations
  10. +
      +
    1. Definitions
    2. +
    3. Abbreviations
    4. +
    +
  11. Reference Documents
  12. +
+
+ +
+ +

1Introduction

+ +

A Blueprint is a configurable and reusable infrastructure-as-code artefact. Practical examples include foundational infrastructure services, such as compute, networking, and storage, but may as well be of arbitrary higher-level nature, such as an AWS Lambda function.

+

A Stack is a composition of reusable Blueprints (potentially in combination with other random infrastructure code) to provide ready-to-use infrastructure for your digital application.

+ +

This document describes the installation of the following stack:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{metadata.name}}
Description{{metadata.description}}
Version{{metadata.version}}
URL{{metadata.url}}
Git URL{{metadata.git_uri}}
Plan URLtbd.
Date Created{{metadata.date_created}}
+
+ +
+ +

1.1Inputs

+ + + + + + {{#each terraform_docs.Inputs}} + + + + + {{/each}} +
NameDescription
{{Name}}{{Description}}
+
+ +
+ +

1.2Outputs

+ + + + + + {{#each terraform_docs.Outputs}} + + + + + {{/each}} +
NameDescription
{{Name}}{{Description}}
+
+ +
+ +

1.3Modules

+ + + + + {{#each terraform_modules}} + + + + {{/each}} +
Source
{{Source}} {{Version}}
+
+ +
+ +

2Installation

+ + +

2.1Input Values

+ + + + + + {{#each terraform_install.inputs}} + + + + + {{/each}} +
NameValue
{{key}}{{value}}
+
+ +
+ +

2.2Output Values

+ + + + + + {{#each terraform_install.outputs}} + + + + + {{/each}} +
NameValue
{{key}}{{value.value}}
+
+ + +

3Diagnostics and Testing

+ +

This section documents the results of comparing the actual state of a live stack deployment against a desired state.

+ + +

3.1Pre-Installation Tests

+ + +

3.1.1Blueprints

+ +

The values used to test the following blueprints are for testing purposes only.

+ + {{#each blueprint_test_reports}} +

{{name}}

+ + {{#each data.inspec.profiles}} + {{#each controls}} +
+
{{title}}
+

{{desc}}

+ + + + + + + + {{#each results}} + + + + + + {{/each}} +
Description / Expected ResultStart TimeStatus
{{code_desc}}{{start_time}}{{status}}
+ + + + + + + + + + + +
Actual ResultsAll tests successful (no deviations or failures). +
+ +
Comments:n/a
+
+ {{/each}} + {{/each}} + {{/each}} + +
+ +

3.1.2Stack

+ +

The values used to test the stack are for testing purposes only.

+ + {{#each inspec.pre-install.profiles}} + {{#each controls}} +

{{title}}

+

{{desc}}

+ + + + + + + + {{#each results}} + + + + + + {{/each}} +
Description / Expected ResultStart TimeStatus
{{code_desc}}{{start_time}}{{status}}
+ + {{/each}} + {{/each}} +
+ +
+ +

3.2Post-Installation Tests

+ + {{#each inspec.post-install.profiles}} + {{#each controls}} +

{{title}}

+

{{desc}}

+ + + + + + + + {{#each results}} + + + + + + {{/each}} +
Description / Expected ResultStart TimeStatus
{{code_desc}}{{start_time}}{{status}}
+ + {{/each}} + {{/each}} + + + + + + + + + + + +
Actual ResultsAll tests successful (no deviations or failures). +
+ +
Comments:n/a
+
+ +
+ +

4Conclusion Statement

+ +

All deliverables of the Technical Installation Plan have been successfully executed and the signature of this Report verifies that +the system has been installed according to all requirements stated in the Installation Plan or, in the event the requirements +are not met, that the deviations and/or failures are properly documented and addressed.

+
+ +
+ +

5Definitions and Abbreviations

+ + +

5.1Definitions

+ + + + + + + + + + +
TermDefinition
GitA distributed version control system, e.g., Bitbucket, GitHub, and GitLab.
+ + +

5.2Abbreviations

+ + + + + + + + + + +
AbbreviationMeaning
URLA uniform resource locator (URL) is the address of a resource on the Internet.
+ +

See https://docs.aws.amazon.com/general/latest/gr/glos-chap.html for a list of AWS-specific definitions and abbreviations.

+ + +

6Reference Documents

+ + + + + + + + + + +
Document IDName
n/an/a
+
+ +