/* * 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); } } } }