Skip to content

Commit

Permalink
Validate that REST API names do not contain keywords (#58452)
Browse files Browse the repository at this point in the history
If an API name (or components of a name) overlaps with a reserved word in
the programming language for an ES client, then it's possible that the code
that is generated from the API will not compile. This PR adds validation to
check for such overlaps.
  • Loading branch information
pugnascotia committed Jun 26, 2020
1 parent 4516691 commit afebbbf
Show file tree
Hide file tree
Showing 4 changed files with 567 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public class ValidateJsonAgainstSchemaTask extends DefaultTask {

private final ObjectMapper mapper = new ObjectMapper();
private File jsonSchema;
private File report;
private FileCollection inputFiles;

@Incremental
Expand All @@ -75,9 +76,13 @@ public void setJsonSchema(File jsonSchema) {
this.jsonSchema = jsonSchema;
}

public void setReport(File report) {
this.report = report;
}

@OutputFile
public File getReport() {
return new File(getProject().getBuildDir(), "reports/validateJson.txt");
return this.report;
}

@TaskAction
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* 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.elasticsearch.gradle.precommit;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.FileCollection;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.gradle.work.ChangeType;
import org.gradle.work.Incremental;
import org.gradle.work.InputChanges;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.StreamSupport;

/**
* Incremental task to validate that the API names in set of JSON files do not contain
* programming language keywords.
* <p>
* The keywords are defined in a JSON file, although it's worth noting that what is and isn't a
* keyword depends on the language and sometimes the context in which a keyword is used. For example,
* `delete` is an operator in JavaScript, but it isn't in the keywords list for JavaScript or
* TypeScript because it's OK to use `delete` as a method name.
*/
public class ValidateJsonNoKeywordsTask extends DefaultTask {

private final ObjectMapper mapper = new ObjectMapper().configure(JsonParser.Feature.ALLOW_COMMENTS, true);
private File jsonKeywords;
private File report;
private FileCollection inputFiles;

@Incremental
@InputFiles
public FileCollection getInputFiles() {
return inputFiles;
}

public void setInputFiles(FileCollection inputFiles) {
this.inputFiles = inputFiles;
}

@InputFile
public File getJsonKeywords() {
return jsonKeywords;
}

public void setJsonKeywords(File jsonKeywords) {
this.jsonKeywords = jsonKeywords;
}

public void setReport(File report) {
this.report = report;
}

@OutputFile
public File getReport() {
return report;
}

@TaskAction
public void validate(InputChanges inputChanges) {
final Map<File, Set<String>> errors = new LinkedHashMap<>();

getLogger().debug("Loading keywords from {}", jsonKeywords.getName());

final Map<String, Set<String>> languagesByKeyword = loadKeywords();

// incrementally evaluate input files
StreamSupport.stream(inputChanges.getFileChanges(getInputFiles()).spliterator(), false)
.filter(f -> f.getChangeType() != ChangeType.REMOVED)
.forEach(fileChange -> {
File file = fileChange.getFile();
if (file.isDirectory()) {
return;
}

getLogger().debug("Checking {}", file.getName());

try {
final JsonNode jsonNode = mapper.readTree(file);

if (jsonNode.isObject() == false) {
errors.put(file, Set.of("Expected an object, but found: " + jsonNode.getNodeType()));
return;
}

final ObjectNode rootNode = (ObjectNode) jsonNode;

if (rootNode.size() != 1) {
errors.put(file, Set.of("Expected an object with exactly 1 key, but found " + rootNode.size() + " keys"));
return;
}

final String apiName = rootNode.fieldNames().next();

for (String component : apiName.split("\\.")) {
if (languagesByKeyword.containsKey(component)) {
final Set<String> errorsForFile = errors.computeIfAbsent(file, _file -> new HashSet<>());
errorsForFile.add(
component + " is a reserved keyword in these languages: " + languagesByKeyword.get(component)
);
}
}
} catch (IOException e) {
errors.put(file, Set.of("Failed to load file: " + e.getMessage()));
}
});

if (errors.isEmpty()) {
return;
}

try {
try (PrintWriter pw = new PrintWriter(getReport())) {
pw.println("---------- Validation Report -----------");
pw.println("Some API names were found that, when client code is generated for these APIS,");
pw.println("could conflict with the reserved words in some programming languages. It may");
pw.println("still be possible to use these API names, but you will need to verify whether");
pw.println("the API name (and its components) can be used as method names, and update the");
pw.println("list of keywords below. The safest action is to rename the API to avoid conflicts.");
pw.println();
pw.printf("Keywords source: %s%n", getJsonKeywords());
pw.println();
pw.println("---------- Validation Errors -----------");
pw.println();
errors.forEach((file, errorsForFile) -> {
pw.printf("File: %s%n", file);
errorsForFile.forEach(err -> pw.printf("\t%s%n", err));
pw.println();
});
}
} catch (FileNotFoundException e) {
throw new GradleException("Failed to write keywords report", e);
}

String message = String.format(
Locale.ROOT,
"Error validating JSON. See the report at: %s%s%s",
getReport().toURI().toASCIIString(),
System.lineSeparator(),
String.format("JSON validation failed: %d files contained %d violations", errors.keySet().size(), errors.values().size())
);
throw new GradleException(message);
}

/**
* Loads the known keywords. Although the JSON on disk maps from language to keywords, this method
* inverts this to map from keyword to languages. This is because the same keywords are found in
* multiple languages, so it is easier and more useful to have a single map of keywords.
*
* @return a mapping from keyword to languages.
*/
private Map<String, Set<String>> loadKeywords() {
Map<String, Set<String>> languagesByKeyword = new HashMap<>();

try {
final ObjectNode keywordsNode = ((ObjectNode) mapper.readTree(this.jsonKeywords));

keywordsNode.fieldNames().forEachRemaining(eachLanguage -> {
keywordsNode.get(eachLanguage).elements().forEachRemaining(e -> {
final String eachKeyword = e.textValue();
final Set<String> languages = languagesByKeyword.computeIfAbsent(eachKeyword, _keyword -> new HashSet<>());
languages.add(eachLanguage);
});
});
} catch (IOException e) {
throw new GradleException("Failed to load keywords JSON from " + jsonKeywords.getName() + " - " + e.getMessage(), e);
}

return languagesByKeyword;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,24 @@ public void apply(Project project) {
filter.include(DOUBLE_STAR + "/rest-api-spec/api/" + DOUBLE_STAR + "/*.json");
filter.exclude(DOUBLE_STAR + "/_common.json");
}));
// This must always be specified precisely, so that
// projects other than `rest-api-spec` can use this task.
task.setJsonSchema(new File(project.getRootDir(), "rest-api-spec/src/main/resources/schema.json"));
task.setReport(new File(project.getBuildDir(), "reports/validateJson.txt"));
});
project.getTasks().named("precommit").configure(t -> t.dependsOn(validateRestSpecTask));

Provider<ValidateJsonNoKeywordsTask> validateNoKeywordsTask = project.getTasks()
.register("validateNoKeywords", ValidateJsonNoKeywordsTask.class, task -> {
task.setInputFiles(Util.getJavaTestAndMainSourceResources(project, filter -> {
filter.include(DOUBLE_STAR + "/rest-api-spec/api/" + DOUBLE_STAR + "/*.json");
filter.exclude(DOUBLE_STAR + "/_common.json");
}));
task.setJsonKeywords(new File(project.getRootDir(), "rest-api-spec/keywords.json"));
task.setReport(new File(project.getBuildDir(), "reports/validateKeywords.txt"));
// There's no point running this task if the schema validation fails
task.mustRunAfter(validateRestSpecTask);
});

project.getTasks().named("precommit").configure(t -> t.dependsOn(validateRestSpecTask, validateNoKeywordsTask));
}
}

0 comments on commit afebbbf

Please sign in to comment.