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}