org.opensearch.gradle.doc.RestTestsFromSnippetsTask.groovy Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of build-tools Show documentation
Show all versions of build-tools Show documentation
OpenSearch subproject :build-tools
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch 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 org.opensearch.gradle.doc
import groovy.transform.PackageScope
import org.opensearch.gradle.doc.SnippetsTask.Snippet
import org.gradle.api.InvalidUserDataException
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import java.nio.file.Files
import java.nio.file.Path
/**
* Generates REST tests for each snippet marked // TEST.
*/
class RestTestsFromSnippetsTask extends SnippetsTask {
/**
* These languages aren't supported by the syntax highlighter so we
* shouldn't use them.
*/
private static final List BAD_LANGUAGES = ['json', 'javascript']
@Input
Map setups = new HashMap()
/**
* A list of files that contain snippets that *probably* should be
* converted to `// CONSOLE` but have yet to be converted. If a file is in
* this list and doesn't contain unconverted snippets this task will fail.
* If there are unconverted snippets not in this list then this task will
* fail. All files are paths relative to the docs dir.
*/
@Input
List expectedUnconvertedCandidates = []
/**
* Root directory of the tests being generated. To make rest tests happy
* we generate them in a testRoot() which is contained in this directory.
*/
@OutputDirectory
File testRoot = project.file('build/rest')
@Internal
Set names = new HashSet<>()
RestTestsFromSnippetsTask() {
project.afterEvaluate {
// Wait to set this so testRoot can be customized
project.sourceSets.test.output.dir(testRoot, builtBy: this)
}
TestBuilder builder = new TestBuilder()
doFirst { outputRoot().delete() }
perSnippet builder.&handleSnippet
doLast builder.&checkUnconverted
doLast builder.&finishLastTest
}
/**
* Root directory containing all the files generated by this task. It is
* contained within testRoot.
*/
File outputRoot() {
return new File(testRoot, '/rest-api-spec/test')
}
/**
* Is this snippet a candidate for conversion to `// CONSOLE`?
*/
static isConsoleCandidate(Snippet snippet) {
/* Snippets that are responses or already marked as `// CONSOLE` or
* `// NOTCONSOLE` are not candidates. */
if (snippet.console != null || snippet.testResponse) {
return false
}
/* js snippets almost always should be marked with `// CONSOLE`. js
* snippets that shouldn't be marked `// CONSOLE`, like examples for
* js client, should always be marked with `// NOTCONSOLE`.
*
* `sh` snippets that contain `curl` almost always should be marked
* with `// CONSOLE`. In the exceptionally rare cases where they are
* not communicating with Elasticsearch, like the examples in the ec2
* and gce discovery plugins, the snippets should be marked
* `// NOTCONSOLE`. */
return snippet.language == 'js' || snippet.curl
}
/**
* Certain requests should not have the shard failure check because the
* format of the response is incompatible i.e. it is not a JSON object.
*/
static shouldAddShardFailureCheck(String path) {
return path.startsWith('_cat') == false
}
/**
* Converts Kibana's block quoted strings into standard JSON. These
* {@code """} delimited strings can be embedded in CONSOLE and can
* contain newlines and {@code "} without the normal JSON escaping.
* This has to add it.
*/
@PackageScope
static String replaceBlockQuote(String body) {
int start = body.indexOf('"""');
if (start < 0) {
return body
}
/*
* 1.3 is a fairly wild guess of the extra space needed to hold
* the escaped string.
*/
StringBuilder result = new StringBuilder((int) (body.length() * 1.3));
int startOfNormal = 0;
while (start >= 0) {
int end = body.indexOf('"""', start + 3);
if (end < 0) {
throw new InvalidUserDataException(
"Invalid block quote starting at $start in:\n$body")
}
result.append(body.substring(startOfNormal, start));
result.append('"');
result.append(body.substring(start + 3, end)
.replace('"', '\\"')
.replace("\n", "\\n"));
result.append('"');
startOfNormal = end + 3;
start = body.indexOf('"""', startOfNormal);
}
result.append(body.substring(startOfNormal));
return result.toString();
}
private class TestBuilder {
private static final String SYNTAX = {
String method = /(?GET|PUT|POST|HEAD|OPTIONS|DELETE)/
String pathAndQuery = /(?[^\n]+)/
String badBody = /GET|PUT|POST|HEAD|OPTIONS|DELETE|startyaml|#/
String body = /(?(?:\n(?!$badBody)[^\n]+)+)/
String rawRequest = /(?:$method\s+$pathAndQuery$body?)/
String yamlRequest = /(?:startyaml(?s)(?.+?)(?-s)endyaml)/
String nonComment = /(?:$rawRequest|$yamlRequest)/
String comment = /(?#.+)/
/(?:$comment|$nonComment)\n+/
}()
/**
* The file in which we saw the last snippet that made a test.
*/
Path lastDocsPath
/**
* The file we're building.
*/
PrintWriter current
/**
* Files containing all snippets that *probably* should be converted
* to `// CONSOLE` but have yet to be converted. All files are paths
* relative to the docs dir.
*/
Set unconvertedCandidates = new HashSet<>()
/**
* The last non-TESTRESPONSE snippet.
*/
Snippet previousTest
/**
* Called each time a snippet is encountered. Tracks the snippets and
* calls buildTest to actually build the test.
*/
void handleSnippet(Snippet snippet) {
if (RestTestsFromSnippetsTask.isConsoleCandidate(snippet)) {
unconvertedCandidates.add(snippet.path.toString()
.replace('\\', '/'))
}
if (BAD_LANGUAGES.contains(snippet.language)) {
throw new InvalidUserDataException(
"$snippet: Use `js` instead of `${snippet.language}`.")
}
if (snippet.testSetup) {
testSetup(snippet)
previousTest = snippet
return
}
if (snippet.testTearDown) {
testTearDown(snippet)
previousTest = snippet
return
}
if (snippet.testResponse || snippet.language == 'console-result') {
response(snippet)
return
}
if ((snippet.language == 'js') && (snippet.console)) {
throw new InvalidUserDataException(
"$snippet: Use `[source,console]` instead of `// CONSOLE`.")
}
if (snippet.test || snippet.language == 'console') {
test(snippet)
previousTest = snippet
return
}
// Must be an unmarked snippet....
}
private void test(Snippet test) {
setupCurrent(test)
if (test.continued) {
/* Catch some difficult to debug errors with // TEST[continued]
* and throw a helpful error message. */
if (previousTest == null || previousTest.path != test.path) {
throw new InvalidUserDataException("// TEST[continued] " +
"cannot be on first snippet in a file: $test")
}
if (previousTest != null && previousTest.testSetup) {
throw new InvalidUserDataException("// TEST[continued] " +
"cannot immediately follow // TESTSETUP: $test")
}
if (previousTest != null && previousTest.testTearDown) {
throw new InvalidUserDataException("// TEST[continued] " +
"cannot immediately follow // TEARDOWN: $test")
}
} else {
current.println('---')
current.println("\"line_$test.start\":")
/* The Elasticsearch test runner doesn't support quite a few
* constructs unless we output this skip. We don't know if
* we're going to use these constructs, but we might so we
* output the skip just in case. */
current.println(" - skip:")
current.println(" features: ")
current.println(" - default_shards")
current.println(" - stash_in_key")
current.println(" - stash_in_path")
current.println(" - stash_path_replace")
current.println(" - warnings")
if (test.testEnv != null) {
throw new InvalidUserDataException('Unsupported testEnv: ' + test.testEnv);
}
}
if (test.skip) {
if (test.continued) {
throw new InvalidUserDataException("Continued snippets "
+ "can't be skipped")
}
current.println(" - always_skip")
current.println(" reason: $test.skip")
}
if (test.setup != null) {
setup(test)
}
body(test, false)
}
private void setup(final Snippet snippet) {
// insert a setup defined outside of the docs
for (final String setupName : snippet.setup.split(',')) {
final String setup = setups[setupName]
if (setup == null) {
throw new InvalidUserDataException("Couldn't find setup for $snippet")
}
current.println(setup)
}
}
private void response(Snippet response) {
if (null == response.skip) {
current.println(" - match: ")
current.println(" \$body: ")
replaceBlockQuote(response.contents).eachLine {
current.println(" $it")
}
}
}
void emitDo(String method, String pathAndQuery, String body,
String catchPart, List warnings, boolean inSetup, boolean skipShardFailures) {
def (String path, String query) = pathAndQuery.tokenize('?')
if (path == null) {
path = '' // Catch requests to the root...
} else {
path = path.replace('<', '%3C').replace('>', '%3E')
}
current.println(" - do:")
if (catchPart != null) {
current.println(" catch: $catchPart")
}
if (false == warnings.isEmpty()) {
current.println(" warnings:")
for (String warning in warnings) {
// Escape " because we're going to quote the warning
String escaped = warning.replaceAll('"', '\\\\"')
/* Quote the warning in case it starts with [ which makes
* it look too much like an array. */
current.println(" - \"$escaped\"")
}
}
current.println(" raw:")
current.println(" method: $method")
current.println(" path: \"$path\"")
if (query != null) {
for (String param: query.tokenize('&')) {
def (String name, String value) = param.tokenize('=')
if (value == null) {
value = ''
}
current.println(" $name: \"$value\"")
}
}
if (body != null) {
// Throw out the leading newline we get from parsing the body
body = body.substring(1)
// Replace """ quoted strings with valid json ones
body = replaceBlockQuote(body)
current.println(" body: |")
body.eachLine { current.println(" $it") }
}
/* Catch any shard failures. These only cause a non-200 response if
* no shard succeeds. But we need to fail the tests on all of these
* because they mean invalid syntax or broken queries or something
* else that we don't want to teach people to do. The REST test
* framework doesn't allow us to have assertions in the setup
* section so we have to skip it there. We also omit the assertion
* from APIs that don't return a JSON object
*/
if (false == inSetup && skipShardFailures == false && shouldAddShardFailureCheck(path)) {
current.println(" - is_false: _shards.failures")
}
}
private void testSetup(Snippet snippet) {
if (lastDocsPath == snippet.path) {
throw new InvalidUserDataException("$snippet: wasn't first. TESTSETUP can only be used in the first snippet of a document.")
}
setupCurrent(snippet)
current.println('---')
current.println("setup:")
if (snippet.setup != null) {
setup(snippet)
}
body(snippet, true)
}
private void testTearDown(Snippet snippet) {
if (previousTest.testSetup == false && lastDocsPath == snippet.path) {
throw new InvalidUserDataException("$snippet must follow test setup or be first")
}
setupCurrent(snippet)
current.println('---')
current.println('teardown:')
body(snippet, true)
}
private void body(Snippet snippet, boolean inSetup) {
parse("$snippet", snippet.contents, SYNTAX) { matcher, last ->
if (matcher.group("comment") != null) {
// Comment
return
}
String yamlRequest = matcher.group("yaml");
if (yamlRequest != null) {
current.println(yamlRequest)
return
}
String method = matcher.group("method")
String pathAndQuery = matcher.group("pathAndQuery")
String body = matcher.group("body")
String catchPart = last ? snippet.catchPart : null
if (pathAndQuery.startsWith('/')) {
// Leading '/'s break the generated paths
pathAndQuery = pathAndQuery.substring(1)
}
emitDo(method, pathAndQuery, body, catchPart, snippet.warnings,
inSetup, snippet.skipShardsFailures)
}
}
private PrintWriter setupCurrent(Snippet test) {
if (lastDocsPath == test.path) {
return
}
finishLastTest()
lastDocsPath = test.path
// Make the destination file:
// Shift the path into the destination directory tree
Path dest = outputRoot().toPath().resolve(test.path)
// Replace the extension
String fileName = dest.getName(dest.nameCount - 1)
dest = dest.parent.resolve(fileName.replace('.asciidoc', '.yml'))
// Now setup the writer
Files.createDirectories(dest.parent)
current = dest.newPrintWriter('UTF-8')
}
void finishLastTest() {
if (current != null) {
current.close()
current = null
}
}
void checkUnconverted() {
List listedButNotFound = []
for (String listed : expectedUnconvertedCandidates) {
if (false == unconvertedCandidates.remove(listed)) {
listedButNotFound.add(listed)
}
}
String message = ""
if (false == listedButNotFound.isEmpty()) {
Collections.sort(listedButNotFound)
listedButNotFound = listedButNotFound.collect {' ' + it}
message += "Expected unconverted snippets but none found in:\n"
message += listedButNotFound.join("\n")
}
if (false == unconvertedCandidates.isEmpty()) {
List foundButNotListed =
new ArrayList<>(unconvertedCandidates)
Collections.sort(foundButNotListed)
foundButNotListed = foundButNotListed.collect {' ' + it}
if (false == "".equals(message)) {
message += "\n"
}
message += "Unexpected unconverted snippets:\n"
message += foundButNotListed.join("\n")
}
if (false == "".equals(message)) {
throw new InvalidUserDataException(message);
}
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy