001package com.randomnoun.maven.plugin.yamlCombine; 002 003import java.io.File; 004import java.io.FileInputStream; 005import java.io.IOException; 006import java.io.InputStream; 007import java.io.InputStreamReader; 008import java.io.Reader; 009import java.io.UnsupportedEncodingException; 010import java.io.Writer; 011import java.net.URLDecoder; 012import java.nio.charset.Charset; 013import java.util.ArrayList; 014import java.util.Arrays; 015import java.util.Collections; 016import java.util.HashMap; 017import java.util.LinkedHashMap; 018import java.util.LinkedHashSet; 019import java.util.List; 020import java.util.Map; 021import java.util.Set; 022import java.util.function.IntConsumer; 023 024import org.apache.maven.plugin.logging.Log; 025import org.apache.maven.shared.filtering.FilterWrapper; 026import org.yaml.snakeyaml.DumperOptions; 027import org.yaml.snakeyaml.Yaml; 028import org.yaml.snakeyaml.representer.Representer; 029 030import com.fasterxml.jackson.databind.ObjectMapper; 031import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; 032import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; 033 034public class YamlCombiner { 035 036 public List<FilterWrapper> getFilterWrappers() { 037 return filterWrappers; 038 } 039 040 public void setFilterWrappers(List<FilterWrapper> filterWrappers2) { 041 this.filterWrappers = filterWrappers2; 042 } 043 044 File relativeDir; 045 String[] files; 046 boolean verbose; 047 Log log; 048 List<FilterWrapper> filterWrappers; 049 050 private Map<String, Map<String, Object>> yamlFiles = new HashMap<String, Map<String, Object>>(); 051 052 private Yaml newYaml() { 053 DumperOptions options = new DumperOptions(); 054 CustomPropertyUtils customPropertyUtils = new CustomPropertyUtils(); 055 Representer customRepresenter = new Representer(options); 056 customRepresenter.setPropertyUtils(customPropertyUtils); 057 Yaml yaml = new Yaml(customRepresenter, options); 058 return yaml; 059 } 060 061 @SuppressWarnings("unchecked") 062 public void combine(Writer w, Charset inputFilesetCharset) throws IOException { 063 064 List<String> fileList = new ArrayList<String>(Arrays.asList(files)); 065 Collections.sort(fileList); 066 067 Yaml yaml = newYaml(); 068 @SuppressWarnings("rawtypes") 069 Map mergedObj = null; 070 for (String f : fileList) { 071 InputStream inputStream = new FileInputStream(new File(relativeDir, f)); 072 Reader reader = new InputStreamReader(inputStream, inputFilesetCharset); 073 if (filterWrappers != null) { 074 for ( FilterWrapper wrapper : filterWrappers ) { 075 reader = wrapper.getReader( reader ); 076 } 077 } 078 079 @SuppressWarnings("rawtypes") 080 Map obj = yaml.load(reader); 081 if (mergedObj == null) { 082 mergedObj = obj; 083 } else { 084 merge(mergedObj, obj, f, ""); 085 } 086 } 087 // going to use $xref instead of $ref since my ref syntax is a bit different 088 // and to make it more obvious that this isn't a valid swagger file 089 090 // this is definitely a constructive use of my weekend 091 mergedObj = (Map<Object, Object>) replaceRefs((Map<Object, Object>) mergedObj, relativeDir, ""); 092 093 final ObjectMapper mapper = new ObjectMapper( 094 new YAMLFactory().configure(YAMLGenerator.Feature.MINIMIZE_QUOTES, true) 095 .configure(YAMLGenerator.Feature.SPLIT_LINES, false) 096 .configure(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS, true)); 097 configureMapper(mapper); 098 mapper.writeValue(w, mergedObj); 099 w.flush(); 100 } 101 102 @SuppressWarnings({ "unchecked" }) 103 private void merge(Map<Object, Object> mergedObj, Map<Object, Object> obj, String f, String prefix) 104 throws IllegalArgumentException { 105 Set<Object> cloneSet = new LinkedHashSet<Object>(obj.keySet()); 106 for (Object k : cloneSet) { 107 Object v = obj.get(k); 108 Object mv = mergedObj.get(k); 109 110 if (mv == null) { 111 // simple merge 112 mergedObj.put(k, v); 113 } else if (v == null) { 114 // nothing to merge 115 } else if (mv instanceof Map && v instanceof Map) { 116 // the only things we can merge are maps 117 merge((Map<Object, Object>) mv, (Map<Object, Object>) v, f, prefix + String.valueOf(k) + "/"); 118 } else if (mv.getClass().equals(v.getClass())) { 119 // replace if the types are the same 120 mergedObj.put(k, v); 121 } else { 122 throw new IllegalArgumentException("Could not merge " + f + "#" + prefix + String.valueOf(k) + 123 " (" + v.getClass().getName() + ") into merged object " + mv.getClass().getName()); 124 } 125 } 126 } 127 128 @SuppressWarnings("unchecked") 129 private Object replaceRefs(Map<Object, Object> obj, File relativeDir, String spacePrefix) 130 throws IllegalArgumentException, IOException { 131 // process $xref before other keys 132 Object result = null; 133 if (obj.containsKey("$xref")) { 134 Object xref = obj.get("$xref"); 135 if (verbose) { 136 getLog().info(spacePrefix + "$xref to " + xref); 137 } 138 result = getXref(relativeDir, (String) xref); 139 if (result instanceof Map) { 140 // shallow clone, but replaceRefs will perform shallow clones at deeper levels 141 result = new LinkedHashMap<Object, Object>((Map<Object, Object>) result); 142 result = replaceRefs((Map<Object, Object>) result, relativeDir, spacePrefix + " "); 143 } 144 145 // override values in xref object 146 for (Object k : obj.keySet()) { 147 Object v = obj.get(k); 148 if (k.equals("$xref")) { 149 // ignore 150 } else { 151 if (verbose) { 152 getLog().info(spacePrefix + String.valueOf(k)); 153 } 154 if (result instanceof Map) { 155 Map<Object, Object> r = (Map<Object, Object>) result; 156 if (r.get(k) == null) { 157 // add new property to xref'ed Map 158 r.put(k, v); 159 } else if (r.get(k) instanceof Map && v instanceof Map) { 160 // the xref'ed object is a Map containing a Map 161 // don't think this is ever going to happen. but maybe it will 162 Map<Object, Object> rv = (Map<Object, Object>) r.get(k); 163 merge(rv, (Map<Object, Object>) v, "", String.valueOf(k) + "/"); 164 } else { 165 // replace existing key/value pair 166 r.put(k, v); 167 } 168 } else { 169 throw new IllegalArgumentException( 170 "Could not override " + String.valueOf(k) + " (" + v.getClass().getName() + 171 ") from xref '" + xref + "' " + result.getClass().getName()); 172 } 173 } 174 } 175 176 } else { 177 // descend into obj 178 // clone to prevent ConcurrentModificationException 179 Set<Object> cloneSet = new LinkedHashSet<Object>(obj.keySet()); 180 for (Object k : cloneSet) { 181 Object v = obj.get(k); 182 if (k.equals("$xref")) { 183 throw new IllegalStateException("$xref found in Map that didn't contain $xref"); 184 } else if (verbose) { 185 getLog().info(spacePrefix + String.valueOf(k)); 186 } 187 if (v instanceof Map) { 188 // shallow clone, but replaceRefs will perform shallow clones at deeper levels 189 Map<Object, Object> clone = new LinkedHashMap<Object, Object>((Map<Object, Object>) v); 190 Object newObject = replaceRefs(clone, relativeDir, spacePrefix + " "); 191 obj.put(k, newObject); 192 } 193 } 194 result = obj; 195 } 196 197 return result; 198 } 199 200 private Object getXref(File relativeDir, String ref) throws IllegalArgumentException, IOException { 201 // myproject-v1-object.yaml#/definitions/InvalidResponse existing 202 // myproject-v1-swagger-api.yaml#/paths/~1authenticate the JSON-Pointer way 203 // myproject-v1-swagger-api.yaml#/paths/%2Fauthenticate the url escape way 204 // myproject-v1-swagger-api.yaml#/paths/#/authenticate the randomnoun way. 205 206 // can't have '#' as the start of a key as that's a comment. but you probably can. somehow. 207 int pos = ref.indexOf('#'); 208 if (pos == -1) { 209 // local refs still start with a '#' 210 // $ref: '#/paths/~1blogs~1{blog_id}~1new~0posts' 211 throw new IllegalArgumentException("Unparseable $xref '" + ref + "'"); 212 } else { 213 String f = ref.substring(0, pos); 214 String p = ref.substring(pos + 1); 215 // any remaining '#' chars switch between ~1-style escaping and no escaping 216 // see ResolverCache or the json-pointer escaping rules in swagger 217 final StringBuilder result = new StringBuilder(); 218 p.chars().forEachOrdered(new IntConsumer() { 219 boolean asJsonPath = true; 220 221 @Override 222 public void accept(int ch) { 223 if (ch == '#') { 224 asJsonPath = !asJsonPath; 225 } else if (asJsonPath) { 226 result.append((char) ch); 227 } else { 228 // escape this jsonpath-ly 229 if (ch == '/') { 230 result.append("~1"); 231 } else if (ch == '~') { 232 result.append("~0"); 233 } else { 234 result.append((char) ch); 235 } 236 } 237 } 238 }); 239 ref = f + "#" + result.toString(); 240 241 // as per ResolverCache 242 // although ResolverCache parses an entire json doc every time there's a ref, 243 // even if we've already parsed it. 244 final String[] refParts = ref.split("#/"); 245 final String file = refParts[0]; 246 final String definitionPath = refParts.length == 2 ? refParts[1] : null; 247 Map<String, Object> contents = yamlFiles.get(file); 248 if (contents == null) { 249 Yaml yaml = newYaml(); 250 File inputFile = new File(relativeDir, file); 251 InputStream inputStream = new FileInputStream(inputFile); 252 try { 253 contents = yaml.load(inputStream); 254 } catch (Exception e) { 255 throw new IOException("Invalid YAML file '" + inputFile.getCanonicalPath() + "'", e); 256 } 257 inputStream.close(); 258 yamlFiles.put(file, contents); 259 } 260 if (verbose) { 261 getLog().info("definitionPath '" + definitionPath + "' in '" + file + "'"); 262 } 263 if (definitionPath == null) { 264 return contents; 265 } else { 266 String[] jsonPathElements = definitionPath.split("/"); 267 Object node = contents; 268 for (String jsonPathElement : jsonPathElements) { 269 try { 270 node = ((Map<?, ?>) node).get(unescapePointer(jsonPathElement)); 271 if (node == null) { 272 throw new IllegalArgumentException( 273 "Could not find " + definitionPath + " in contents of " + file); 274 } 275 } catch (ClassCastException cce) { 276 throw new IllegalArgumentException("Could not descend into " + jsonPathElement + " of " 277 + definitionPath + " in contents of " + file); 278 } 279 // getLog().info("jsonPathElement is " + node); 280 } 281 return node; 282 } 283 } 284 } 285 286 // ResolverCache.unescapePointer() 287 private String unescapePointer(String jsonPathElement) { 288 // URL decode the fragment 289 try { 290 jsonPathElement = URLDecoder.decode(jsonPathElement, "UTF-8"); 291 } catch (UnsupportedEncodingException e) { 292 // 293 } 294 // Unescape the JSON Pointer segment using the algorithm described in RFC 6901, 295 // section 4: 296 // https://tools.ietf.org/html/rfc6901#section-4 297 // First transform any occurrence of the sequence '~1' to '/' 298 jsonPathElement = jsonPathElement.replaceAll("~1", "/"); 299 // Then transforming any occurrence of the sequence '~0' to '~'. 300 return jsonPathElement.replaceAll("~0", "~"); 301 } 302 303 // from io.swagger.codegen.languages.SwaggerYamlGenerator 304 // but hopefully not requires as we're only dealing with structured lists/maps, 305 // not Swagger objects 306 private void configureMapper(ObjectMapper mapper) { 307 /* 308 * Module deserializerModule = new DeserializationModule(true, true); 309 * mapper.registerModule(deserializerModule); 310 * mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); 311 * mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); 312 * mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); 313 * mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 314 * mapper.addMixIn(Response.class, ResponseSchemaMixin.class); 315 * ReferenceSerializationConfigurer.serializeAsComputedRef(mapper); 316 */ 317 } 318 319 public File getRelativeDir() { 320 return relativeDir; 321 } 322 323 public void setRelativeDir(File relativeDir) { 324 this.relativeDir = relativeDir; 325 } 326 327 public String[] getFiles() { 328 return files; 329 } 330 331 public void setFiles(String[] files) { 332 this.files = files; 333 } 334 335 public boolean isVerbose() { 336 return verbose; 337 } 338 339 public void setVerbose(boolean verbose) { 340 this.verbose = verbose; 341 } 342 343 public Log getLog() { 344 return log; 345 } 346 347 public void setLog(Log log) { 348 this.log = log; 349 } 350 351 352}