diff --git a/.gitignore b/.gitignore index b78af4db7..4af089d79 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .idea *.iml /target/ +/src/test/resources/xml_files /bin/ build diff --git a/README-M2.md b/README-M2.md new file mode 100644 index 000000000..6a0628e06 --- /dev/null +++ b/README-M2.md @@ -0,0 +1,16 @@ +# Milestone 2 + +For the Milestone 2 of SWE262P, 2 new functions were added: + +```java +static JSONObject toJSONObject(Reader reader, JSONPointer path) +static JSONObject toJSONObject(Reader reader, JSONPointer path, JSONObject replacement) +``` + +The first one takes in 2 parameters, a `Reader` object which contains some XML input and a `JSONPointer` object that includes a json path for querying, and returns a `JSONObject` object which has the corresponding path, or throw an error if that path does not exist. + +The first one takes in 3 parameters, a `Reader` object which contains some XML input and a `JSONPointer` object that includes a json path for querying, and a `JSONObject` for replacement. And returns a new `JSONObject` object which has the corresponding path replaced with the new given object, or throw an error if that path does not exist. + +Both new functions are placed in the `XML.java` file. + +The test cases of the functions are placed under the `org.json.junit.milestone2.tests` package, and to run the test case which deals with large file input, first create a folder named `xml_files` under the `/src/test/resources` path, and put the xml files for testing under this path. \ No newline at end of file diff --git a/README-M3.md b/README-M3.md new file mode 100644 index 000000000..ebc3876d4 --- /dev/null +++ b/README-M3.md @@ -0,0 +1,19 @@ +# Milestone 3 + +For the Milestone 3 of SWE262P, the following new functions was added: + +```java +static JSONObject toJSONObject(Reader reader, Function func) +``` + +This function takes in 3 parameters, a `Reader` object which contains some XML input and a `Function` object that includes a function for converting a `String` (expected type of input: a `String`, and is expected to return another `String`), and a `JSONObject` for replacement. And returns a new `JSONObject` object with tag names replaced, or throw an error if the `Reader` object gives an invalid XML. + +The new function is placed in the `XML.java` file. + +The test cases of the functions are placed under the `org.json.junit.milestone3.tests` package, and to run the test case, run the following command: + +`mvn -Dtest=XMLKeyTransformerTest test` + +By implementing the code in the original library code, the function is able to complete the task in one-pass, as the function is writing to a new `JSONObject` while parsing the input XML String. However, in milestone 1, the client code is only able to convert the whole XML String to a `JSONObject`, then convert the keys of this `JSONObject` object. + +Thus, implementing this function inside the library code is able to reduce the execution time in half (one-pass vs two-pass), and also resulting in optimization of memory usage. \ No newline at end of file diff --git a/README-M4.md b/README-M4.md new file mode 100644 index 000000000..a5cdb8b32 --- /dev/null +++ b/README-M4.md @@ -0,0 +1,18 @@ +# Milestone 4 – Stream support for **JSONObject** + +## What is Added + +| Item | Description | +|------|-------------| +| `JSONObject.JSONNode` | Immutable leaf wrapper containing an absolute `path` and its `value`. | +| `Stream JSONObject.toStream()` | Depth-first, **lazy** flattening of any `JSONObject/JSONArray` into a `Stream`. Only leaf nodes are emitted (low memory footprint). | + +Path conventions +* Object keys: `/parent/child` +* Array items: `/array[0]/child` + +--- + +## Run the test class +```bash +mvn -Dtest=org.json.junit.milestone4.tests.JSONObjectStreamTest test \ No newline at end of file diff --git a/README-M5.md b/README-M5.md new file mode 100644 index 000000000..c069036f0 --- /dev/null +++ b/README-M5.md @@ -0,0 +1,17 @@ +# Milestone 5 – Async support for **JSONObject** + +## What is Added + +| Item | Description | +|------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Future XMLUtils.toJSONObject(Reader reader, Consumer after, Consumer error)` | **Non-blocking** conversion. Parses the XML read from `reader` into a `JSONObject` on a background thread. When parsing finishes it invokes `after.accept(result)`; when failure it calls `error.accept(ex)` and throws the exception into the returned `Future`. | +| `AsyncRunner` | Tiny task aggregator. Call `add(Future task)` to collect jobs, then wait for them all (e.g. `forEach(Future::get)`). | +| `ExecutorService` | The default thread pool (size = available CPU cores). If you prefer a custom pool you can swap it out before calling the API (e.g. add `XMLUtils.setExecutor(...)`). | + +Input: XML of different sizes (where they will be parsed concurrently) + +--- + +## Run the test class +```bash +mvn -Dtest=org.json.junit.milestone5.tests.JSONObjectAsyncTest test \ No newline at end of file diff --git a/README.md b/README.md index 206afbb21..05de306fc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Bug fixes, code improvements, and unit test coverage changes are welcome! Becaus # Build Instructions The org.json package can be built from the command line, Maven, and Gradle. The unit tests can be executed from Maven, Gradle, or individually in an IDE e.g. Eclipse. - + **Building from the command line** *Build the class files from the package root directory src/main/java* @@ -83,7 +83,7 @@ java -cp .:json-java.jar Test (Unix Systems) {"abc":"def"} ``` - + **Tools to build the package and execute the unit tests** Execute the test suite with Maven: @@ -109,6 +109,23 @@ gradlew testWithStrictMode mvn test -P test-strict-mode ``` +# Milestone 2 + +For the Milestone 2 of SWE262P, 2 new functions were added: + +```java +static JSONObject toJSONObject(Reader reader, JSONPointer path) +static JSONObject toJSONObject(Reader reader, JSONPointer path, JSONObject replacement) +``` + +The first one takes in 2 parameters, a `Reader` object which contains some XML input and a `JSONPointer` object that includes a json path for querying, and returns a `JSONObject` object which has the corresponding path, or throw an error if that path does not exist. + +The first one takes in 3 parameters, a `Reader` object which contains some XML input and a `JSONPointer` object that includes a json path for querying, and a `JSONObject` for replacement. And returns a new `JSONObject` object which has the corresponding path replaced with the new given object, or throw an error if that path does not exist. + +Both new functions are placed in the `XML.java` file. + +The test cases of the functions are placed under the `org.json.junit.milestone2.tests` package, and to run the test case which deals with large file input, first create a folder named `xml_files` under the `/src/test/resources` path, and put the xml files for testing under this path. + # Notes For more information, please see [NOTES.md](https://github.com/stleary/JSON-java/blob/master/docs/NOTES.md) diff --git a/src/main/java/org/json/JSONObject.java b/src/main/java/org/json/JSONObject.java index a1664f708..2bce36024 100644 --- a/src/main/java/org/json/JSONObject.java +++ b/src/main/java/org/json/JSONObject.java @@ -17,6 +17,8 @@ import java.util.*; import java.util.Map.Entry; import java.util.regex.Pattern; +import java.util.stream.IntStream; +import java.util.stream.Stream; /** * A JSONObject is an unordered collection of name/value pairs. Its external @@ -3030,4 +3032,64 @@ private static String removeLeadingZerosOfNumber(String value){ if (negativeFirstChar) {return "-0";} return "0"; } + + // ---------------------------- Milestone 4 ------------------------------ + + /** + * Represents a node in the JSON object tree, with path and value. + */ + public static class JSONNode { + private final String path; + private final Object value; + + public JSONNode(String path, Object value) { + this.path = path; + this.value = value; + } + + public String getPath() { + return path; + } + + public Object getValue() { + return value; + } + + @Override + public String toString() { + return "JSONNode{path='" + path + "', value=" + value + '}'; + } + } + + /** + * Convert this JSONObject into a stream of JSONNode, allowing chained operations. + * @return Stream of JSONNode objects (each has a full path and a value) + */ + public Stream toStream() { + return toStream("", this); + } + + /** + * Recursive helper method to flatten JSONObject/JSONArray into JSONNode stream. + */ + private Stream toStream(String path, Object value) { + if (value instanceof JSONObject) { + JSONObject obj = (JSONObject) value; + return obj.keySet().stream() + .flatMap(key -> { + String newPath = path.isEmpty() ? "/" + key : path + "/" + key; + return toStream(newPath, obj.get(key)); + }); + } else if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + return IntStream.range(0, array.length()) + .boxed() + .flatMap(i -> { + String newPath = path + "[" + i + "]"; + return toStream(newPath, array.get(i)); + }); + } else { + return Stream.of(new JSONNode(path, value)); + } + } } diff --git a/src/main/java/org/json/XML.java b/src/main/java/org/json/XML.java index 4bf475935..53ab77a62 100644 --- a/src/main/java/org/json/XML.java +++ b/src/main/java/org/json/XML.java @@ -8,7 +8,12 @@ import java.io.StringReader; import java.math.BigDecimal; import java.math.BigInteger; -import java.util.Iterator; +import java.util.*; +import java.io.BufferedReader; +import java.util.concurrent.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; /** * This provides static methods to convert an XML text into a JSONObject, and to @@ -63,6 +68,10 @@ public XML() { */ public static final String TYPE_ATTR = "xsi:type"; + private static boolean replaced = false; + private static boolean skipCurrentKey = false; + + /** * Creates an iterator for navigating Code Points in a string instead of * characters. Once Java7 support is dropped, this can be replaced with @@ -475,6 +484,244 @@ private static boolean parse(XMLTokener x, JSONObject context, String name, XMLP } } } + + /** + * Compared to the original parse function, this function adds the function (String Convertor) as an input + * @param x + * @param context + * @param name + * @param config + * @param currentNestingDepth + * @param keyTransformer + * The function which takes in a single String parameter, and returns another converted String + * @return + * @throws JSONException + */ + private static boolean parseMilestone3(XMLTokener x, JSONObject context, String name, XMLParserConfiguration config, int currentNestingDepth,Function keyTransformer) + throws JSONException { + char c; + int i; + JSONObject jsonObject = null; + String string; + String tagName; + Object token; + XMLXsiTypeConverter xmlXsiTypeConverter; + + // Test for and skip past these forms: + // + // + // + // + // Report errors for these forms: + // <> + // <= + // << + token = x.nextToken(); + + // "); + return false; + } + x.back(); + } else if (c == '[') { + token = x.nextToken(); + if ("CDATA".equals(token)) { + if (x.next() == '[') { + string = x.nextCDATA(); + if (string.length() > 0) { + context.accumulate(config.getcDataTagName(), string); + } + return false; + } + } + throw x.syntaxError("Expected 'CDATA['"); + } + i = 1; + do { + token = x.nextMeta(); + if (token == null) { + throw x.syntaxError("Missing '>' after ' 0); + return false; + } else if (token == QUEST) { + + // "); + return false; + } else if (token == SLASH) { + + // Close tag + if (x.nextToken() != GT) { + throw x.syntaxError("Misshaped tag"); + } + if (config.getForceList().contains(tagName)) { + if (nilAttributeFound) { + context.append(transformedTagName, JSONObject.NULL); + } else if (jsonObject.length() > 0) { + context.append(transformedTagName, jsonObject); + } else { + context.put(transformedTagName, new JSONArray()); + } + } else { + if (nilAttributeFound) { + context.accumulate(transformedTagName, JSONObject.NULL); + } else if (jsonObject.length() > 0) { + context.accumulate(transformedTagName, jsonObject); + } else { + context.accumulate(transformedTagName, ""); + } + } + return false; + /* + if (config.getForceList().contains(prefix + tagName)) { + // Force the value to be an array + if (nilAttributeFound) { + context.append(prefix + tagName, JSONObject.NULL); + } else if (jsonObject.length() > 0) { + context.append(prefix + tagName, jsonObject); + } else { + context.put(prefix + tagName, new JSONArray()); + } + } else { + if (nilAttributeFound) { + context.accumulate(prefix + tagName, JSONObject.NULL); + } else if (jsonObject.length() > 0) { + context.accumulate(prefix + tagName, jsonObject); + } else { + context.accumulate(prefix + tagName, ""); + } + } + return false; + */ + } else if (token == GT) { + // Content, between <...> and + for (;;) { + token = x.nextContent(); + if (token == null) { + if (tagName != null) { + throw x.syntaxError("Unclosed tag " + tagName); + } + return false; + } else if (token instanceof String) { + string = (String) token; + if (string.length() > 0) { + if(xmlXsiTypeConverter != null) { + jsonObject.accumulate(config.getcDataTagName(), + stringToValue(string, xmlXsiTypeConverter)); + } else { + Object obj = stringToValue((String) token); + if (obj instanceof Boolean) { + jsonObject.accumulate(config.getcDataTagName(), + config.isKeepBooleanAsString() + ? ((String) token) + : obj); + } else if (obj instanceof Number) { + jsonObject.accumulate(config.getcDataTagName(), + config.isKeepNumberAsString() + ? ((String) token) + : obj); + } else { + jsonObject.accumulate(config.getcDataTagName(), stringToValue((String) token)); + } + } + } + + } else if (token == LT) { + if (parseMilestone3(x, jsonObject,tagName, config, currentNestingDepth + 1, keyTransformer)) { + if (config.getForceList().contains(tagName)) { + if (jsonObject.length() == 0) { + context.put(transformedTagName, new JSONArray()); + } else if (jsonObject.length() == 1 + && jsonObject.opt(config.getcDataTagName()) != null) { + context.append(transformedTagName, jsonObject.opt(config.getcDataTagName())); + } else { + context.append(transformedTagName, jsonObject); + } + } else { + if (jsonObject.length() == 0) { + context.accumulate(transformedTagName, ""); + } else if (jsonObject.length() == 1 + && jsonObject.opt(config.getcDataTagName()) != null) { + context.accumulate(transformedTagName, jsonObject.opt(config.getcDataTagName())); + } else { + context.accumulate(transformedTagName, jsonObject); + } + } + return false; + } + } + } + } else { + throw x.syntaxError("Misshaped tag"); + } + } + } + } /** * This method removes any JSON entry which has the key set by XMLParserConfiguration.cDataTagName * and contains whitespace as this is caused by whitespace between tags. See test XMLTest.testNestedWithWhitespaceTrimmingDisabled. @@ -781,46 +1028,909 @@ public static JSONObject toJSONObject(Reader reader, XMLParserConfiguration conf } /** - * Convert a well-formed (but not necessarily valid) XML string into a - * JSONObject. Some information may be lost in this transformation because - * JSON is a data format and XML is a document format. XML uses elements, - * attributes, and content text, while JSON uses unordered collections of - * name/value pairs and arrays of values. JSON does not does not like to - * distinguish between elements and attributes. Sequences of similar - * elements are represented as JSONArrays. Content text may be placed in a - * "content" member. Comments, prologs, DTDs, and
{@code
-     * <[ [ ]]>}
- * are ignored. * - * All values are converted as strings, for 1, 01, 29.0 will not be coerced to - * numbers but will instead be the exact value as seen in the XML document. - * - * @param string - * The source string. - * @param keepStrings If true, then values will not be coerced into boolean - * or numeric values and will instead be left as strings - * @return A JSONObject containing the structured data from the XML string. - * @throws JSONException Thrown if there is an errors while parsing the string + * @param reader, a reader with XML content inside + * @param path, a Json path for querying the inside object + * @return a Json object which matches the given JsonPointer path, or throw an error if not found + * @throws JSONException */ - public static JSONObject toJSONObject(String string, boolean keepStrings) throws JSONException { - return toJSONObject(new StringReader(string), keepStrings); - } + public static JSONObject toJSONObject(Reader reader, JSONPointer path) throws JSONException { + XMLTokener x = new XMLTokener(reader); + // parse the JSONPointer + String pointerExpr = path.toString(); + List tokens = new ArrayList<>(); + if (!pointerExpr.isEmpty()) { + String[] parts = pointerExpr.split("/", -1); + for (int i = (pointerExpr.startsWith("/") ? 1 : 0); i < parts.length; i++) { + String part = parts[i].replace("~1", "/").replace("~0", "~"); + if (part.isEmpty()) { + tokens.add(""); + } else if (part.matches("-?\\d+") && !(part.startsWith("0") && part.length() > 1)) { + try { + int index = Integer.parseInt(part); + if (index < 0) { + tokens.add(part); + } else { + tokens.add(index); + continue; + } + } catch (NumberFormatException e) { + } + } + tokens.add(part); + } + } + // pointer is empty, then parse the whole document + if (tokens.isEmpty()) { + return XML.toJSONObject(reader); + } - /** - * Convert a well-formed (but not necessarily valid) XML string into a - * JSONObject. Some information may be lost in this transformation because - * JSON is a data format and XML is a document format. XML uses elements, - * attributes, and content text, while JSON uses unordered collections of - * name/value pairs and arrays of values. JSON does not does not like to - * distinguish between elements and attributes. Sequences of similar - * elements are represented as JSONArrays. Content text may be placed in a - * "content" member. Comments, prologs, DTDs, and
{@code
-     * <[ [ ]]>}
- * are ignored. - * - * All numbers are converted as strings, for 1, 01, 29.0 will not be coerced to - * numbers but will instead be the exact value as seen in the XML document depending - * on how flag is set. + JSONObject result = null; + Object firstToken = tokens.get(0); + if (!(firstToken instanceof String)) { + throw new JSONException("Path not found: " + path); + } + String targetRoot = (String) firstToken; + Integer targetRootIndex = null; + int nextTokenIndex = 1; + if (nextTokenIndex < tokens.size() && tokens.get(nextTokenIndex) instanceof Integer) { + targetRootIndex = (Integer) tokens.get(nextTokenIndex); + nextTokenIndex++; + } + int currentIndexCount = 0; + + while (x.more()) { + x.skipPast("<"); + if (!x.more()) break; + char c = x.next(); + if (c == '?') { + // XML announcement + x.skipPast("?>"); + continue; + } + if (c == '!') { + if (x.more()) { + char c2 = x.next(); + if (c2 == '-' && x.more() && x.next() == '-') { + x.skipPast("-->"); + } else if (c2 == '[') { + x.skipPast("]]>"); + } else { + // skip "); + } + } + continue; + } + if (c == '/') { + // unexpected closing tag + continue; + } + x.back(); + Object token = x.nextToken(); + if (!(token instanceof String)) { + throw x.syntaxError("Misshaped element"); + } + String tagName = (String) token; + // if the top level matches + if (tagName.equals(targetRoot)) { + // get the index + if (targetRootIndex != null) { + if (currentIndexCount < targetRootIndex) { + // skip the whole tree if not reached yet + skipElement(x, tagName); + currentIndexCount++; + continue; + } else if (currentIndexCount > targetRootIndex) { + break; + } + } + currentIndexCount++; + // path only contains root element itself + if (nextTokenIndex >= tokens.size()) { + // parse the whole root element as JSONObject + result = parseElement(x, tagName); + break; + } + boolean selfClosing = false; + JSONObject currentObj = new JSONObject(); + while (true) { + token = x.nextToken(); + if (token == null) { + throw x.syntaxError("Misshaped tag"); + } + if (token instanceof Character) { + char ch = (Character) token; + if (ch == '>') { + break; + } + if (ch == '/') { + // end of element + if (x.next() != '>') { + throw x.syntaxError("Misshaped tag"); + } + selfClosing = true; + break; + } + } else { + String attrName = (String) token; + Object nextTok = x.nextToken(); + if (nextTok == XML.EQ) { + Object attrValueToken = x.nextToken(); + if (!(attrValueToken instanceof String)) { + throw x.syntaxError("Missing value for attribute " + attrName); + } + String attrValue = (String) attrValueToken; + currentObj.accumulate(attrName, XML.stringToValue(attrValue)); + } else { + currentObj.accumulate(attrName, ""); + token = nextTok; + if (token instanceof Character) { + x.back(); + } + } + } + } + if (selfClosing) { + // if tag is empty and path does not end here + result = null; + } else { + result = findInElement(x, tagName, nextTokenIndex, tokens); + } + // if result already found + if (result != null) { + break; + } else { + // continue searching for the element + continue; + } + } else { + // if the top level does not match + skipElement(x, tagName); + } + } + if (result == null) { + throw new JSONException("Path not found: " + path.toString()); + } + return result; + } + + /** + * Given a customized function, convert the keys in the Json Object + * @param reader the XML input + * @param keyTransformer a function that transforms each key name + * @return JSONObject with transformed keys + * @throws JSONException if any XML parsing or transformation fails + */ + public static JSONObject toJSONObject(Reader reader, Function keyTransformer) throws JSONException { + JSONObject result = new JSONObject(); + XMLTokener x = new XMLTokener(reader); + + while (x.more()) { + x.skipPast("<"); + if (x.more()) { + XML.parseMilestone3(x, result, null, XMLParserConfiguration.ORIGINAL, 0, keyTransformer); + } + } + return result; + } + /* + public static JSONObject toJSONObject(Reader reader, String prefix) throws JSONException { + JSONObject jo = new JSONObject(); + XMLParserConfiguration config = XMLParserConfiguration.ORIGINAL; + XMLTokener x = new XMLTokener(reader, config); + while (x.more()) { + x.skipPast("<"); + if(x.more()) { + parse(x, jo, null, prefix, config, 0); + } + } + return jo; + } +*/ + /** + * Helper method: skip the current element and its entire subtree without + * building any JSON output. + * + * Preconditions: + * The caller has already read the element name (we are positioned + * right after the `') { // normal end of start‑tag + break; + } + if (ch == '/') { // empty‑element tag `/>` + if (x.next() != '>') { + throw x.syntaxError("Misshaped tag"); + } + selfClosing = true; + break; + } + } + // Otherwise ‑– attribute name or value, ignore + } + if (!selfClosing) { + // Skip everything until we see the matching close tag + int depth = 0; + while (true) { + x.skipPast("<"); + if (!x.more()) { + throw x.syntaxError("Unclosed tag " + tagName); + } + char c = x.next(); + if (c == '/') { + // Found a closing tag + Object nameToken = x.nextToken(); + if (!(nameToken instanceof String)) { + throw x.syntaxError("Missing close name"); + } + String closeName = (String) nameToken; + if (x.next() != '>') { + throw x.syntaxError("Misshaped close tag"); + } + if (closeName.equals(tagName)) { + if (depth == 0) { + // Reached the matching close tag – done + break; + } else { + // Closing an inner tag with the same name + depth--; + continue; + } + } else { + // Closing some other tag – ignore + continue; + } + } else if (c == '!') { + // Comment / CDATA / DOCTYPE – skip + if (x.more()) { + char c2 = x.next(); + if (c2 == '-' && x.more() && x.next() == '-') { + x.skipPast("-->"); + } else if (c2 == '[') { + x.skipPast("]]>"); + } else { + x.skipPast(">"); + } + } + continue; + } else if (c == '?') { + // Processing instruction – skip + x.skipPast("?>"); + continue; + } else { + // New child element – recurse to skip it + x.back(); + Object newName = x.nextToken(); + if (!(newName instanceof String)) { + throw x.syntaxError("Misshaped tag"); + } + skipElement(x, (String) newName); + // If the child has the same tag name, track nesting depth + if (((String) newName).equals(tagName)) { + depth++; + } + } + } + } + } + + /** + * Search within the current element for the sub‑path specified by `tokens`, + * starting at `tokenIndex`. Stops as soon as the desired node is found. + * + * @param x XMLTokener – cursor is right after the parent start‑tag. + * @param parentName The name of the element we are currently inside. + * @param tokenIndex Index of the current JSONPointer token to match. + * @param tokens Full list of JSONPointer tokens. + * @return The matched JSONObject, or {@code null} if not found here. + */ + private static JSONObject findInElement(XMLTokener x, + String parentName, + int tokenIndex, + List tokens) throws JSONException { + String targetName = null; // child element name to look for + Integer targetIndex = null; // optional array index + int nextIndex = tokenIndex; + + if (tokenIndex < tokens.size()) { + Object tk = tokens.get(tokenIndex); + if (tk instanceof String) { + targetName = (String) tk; + if (tokenIndex + 1 < tokens.size() + && tokens.get(tokenIndex + 1) instanceof Integer) { + targetIndex = (Integer) tokens.get(tokenIndex + 1); + nextIndex = tokenIndex + 2; + } else { + nextIndex = tokenIndex + 1; + } + } else { // JSONPointer should not give a number at an object level + return null; + } + } + + int count = 0; // how many siblings seen + JSONObject result = null; + + while (true) { + Object contentToken = x.nextContent(); + if (contentToken == null) { + throw x.syntaxError("Unclosed tag " + parentName); + } + if (contentToken instanceof String) { + // Ignore plain text (unless pointer explicitly targets "content") + continue; + } + if (contentToken instanceof Character && (Character) contentToken == '<') { + char c = x.next(); + if (c == '/') { // end‑tag for parent + Object closeName = x.nextToken(); + if (!(closeName instanceof String) || !closeName.equals(parentName)) { + throw x.syntaxError("Mismatched close tag for " + parentName); + } + if (x.next() != '>') { + throw x.syntaxError("Misshaped close tag"); + } + break; // search in this parent finished + } + if (c == '?') { x.skipPast("?>"); continue; } + if (c == '!') { + // comment / CDATA – skip + if (x.more()) { + char c2 = x.next(); + if (c2 == '-' && x.more() && x.next() == '-') { + x.skipPast("-->"); + } else if (c2 == '[') { + x.skipPast("]]>"); + } else { + x.skipPast(">"); + } + } + continue; + } + // Child element start + x.back(); + Object childToken = x.nextToken(); + if (!(childToken instanceof String)) { + throw x.syntaxError("Bad tag syntax"); + } + String childName = (String) childToken; + + if (targetName != null && childName.equals(targetName)) { + // Found desired child name + if (targetIndex != null) { // need a specific index + if (count < targetIndex) { + skipElement(x, childName); // not yet reached – skip + count++; + continue; + } + count++; // now at the right sibling + } else { // first match is enough + count++; + if (count > 1) { // ambiguous path + skipElement(x, childName); + continue; + } + } + + // Dive into this child + if (nextIndex >= tokens.size()) { + result = parseElement(x, childName); // path ends here + } else { + result = findInElement(x, childName, nextIndex, tokens); + } + return result; // regardless of success, stop searching siblings + } else { + // Not the target child – skip whole subtree + skipElement(x, childName); + } + } + } + return null; // target not found in this element + } + + /** + * Parse the current element (including its subtree) into a JSONObject. + * + * Preconditions: + * – Caller has already consumed the element name; tokenizer cursor is + * positioned immediately after that name token. + */ + private static JSONObject parseElement(XMLTokener x, String tagName) throws JSONException { + JSONObject jo = new JSONObject(); + Object token; + boolean selfClosing = false; + + /* ---------- Parse attributes ---------- */ + while ((token = x.nextToken()) != null) { + if (token instanceof Character) { + char ch = (Character) token; + if (ch == '>') { break; } // end of start‑tag + if (ch == '/') { // empty element + if (x.next() != '>') { + throw x.syntaxError("Misshaped tag"); + } + selfClosing = true; + break; + } + } else { + String attrName = (String) token; + Object nextTok = x.nextToken(); + if (nextTok == XML.EQ) { + Object valTok = x.nextToken(); + if (!(valTok instanceof String)) { + throw x.syntaxError("Missing value for attribute " + attrName); + } + jo.accumulate(attrName, XML.stringToValue((String) valTok)); + } else { + // Attribute without value + jo.accumulate(attrName, ""); + if (nextTok instanceof Character) { + char ch2 = (Character) nextTok; + if (ch2 == '>') { break; } + if (ch2 == '/') { + if (x.next() != '>') throw x.syntaxError("Misshaped tag"); + selfClosing = true; + break; + } + } + token = nextTok; // nextTok could be another attribute name + continue; + } + } + } + if (selfClosing) { + return jo; // nothing more to parse + } + + /* ---------- Parse children / text ---------- */ + StringBuilder textBuf = null; + while (true) { + Object contentToken = x.nextContent(); + if (contentToken == null) { + throw x.syntaxError("Unclosed tag " + tagName); + } + if (contentToken instanceof String) { + String txt = (String) contentToken; + if (!txt.isEmpty()) { + if (textBuf == null) textBuf = new StringBuilder(); + textBuf.append(XML.stringToValue(txt)); + } + } else if (contentToken instanceof Character + && (Character) contentToken == '<') { + char c = x.next(); + if (c == '/') { // end‑tag + Object closeTok = x.nextToken(); + String closeName = (closeTok instanceof String) ? (String) closeTok : ""; + if (!closeName.equals(tagName)) { + throw x.syntaxError("Mismatched close tag for " + tagName); + } + if (x.next() != '>') { + throw x.syntaxError("Misshaped close tag"); + } + if (textBuf != null && textBuf.length() > 0) { + jo.accumulate("content", textBuf.toString()); + } + return jo; + } + if (c == '?') { x.skipPast("?>"); continue; } + if (c == '!') { + if (x.more()) { + char c2 = x.next(); + if (c2 == '-' && x.more() && x.next() == '-') { + x.skipPast("-->"); + } else if (c2 == '[') { + x.skipPast("]]>"); + } else { + x.skipPast(">"); + } + } + continue; + } + // Child element + x.back(); + Object childNameTok = x.nextToken(); + if (!(childNameTok instanceof String)) { + throw x.syntaxError("Bad tag syntax"); + } + String childName = (String) childNameTok; + JSONObject childObj = parseElement(x, childName); + + // Merge child into current object (array‑if‑needed semantics) + Object existing = jo.opt(childName); + if (existing == null) { + jo.accumulate(childName, childObj.length() > 0 ? childObj : ""); + } else if (existing instanceof JSONArray) { + ((JSONArray) existing).put(childObj.length() > 0 ? childObj : ""); + } else { + JSONArray arr = new JSONArray(); + arr.put(existing); + arr.put(childObj.length() > 0 ? childObj : ""); + jo.put(childName, arr); + } + + // Flush buffered text, if any + if (textBuf != null && textBuf.length() > 0) { + jo.accumulate("content", textBuf.toString()); + textBuf.setLength(0); + } + } + } + } + + /** SWE262P MileStone2 project, Task2 by Jiacheng Zhuo **/ + + /** Edit the parse method, add functions for the replacement implement **/ + private static boolean parseMilestone2(XMLTokener x, JSONObject context, String name, XMLParserConfiguration config, int currentNestingDepth, List targetPath, + int targetPathLength, + Map arrayKey, + boolean isReplace, + boolean mergeToParent, + JSONObject replacement) + throws JSONException { + char c; + int i; + JSONObject jsonObject = null; + String string; + String tagName; + Object token; + XMLXsiTypeConverter xmlXsiTypeConverter; + + + // Test for and skip past these forms: + // + // + // + // + // Report errors for these forms: + // <> + // <= + // << + + token = x.nextToken(); + + // "); + return false; + } + x.back(); + } else if (c == '[') { + token = x.nextToken(); + if ("CDATA".equals(token)) { + if (x.next() == '[') { + string = x.nextCDATA(); + if (string.length() > 0) { + context.accumulate(config.getcDataTagName(), string); + } + return false; + } + } + throw x.syntaxError("Expected 'CDATA['"); + } + i = 1; + do { + token = x.nextMeta(); + if (token == null) { + throw x.syntaxError("Missing '>' after ' 0); + return false; + } else if (token == QUEST) { + + // "); + return false; + } else if (token == SLASH) { + + // Close tag "); + return false; + } + + replaced = true; + x.skipPast(currentTag + ">"); + return false; + } + + if (isReplace && hasIndex && !indexMatches) { + arrayKey.put(currentTag, remainingIndex - 1); + } + + if (!isReplace) { + if (hasIndex) { + skipCurrentKey = (remainingIndex != 0); + arrayKey.put(currentTag, remainingIndex - 1); + } + + if (!targetPath.get(currentNestingDepth).equals(currentTag)) { + skipCurrentKey = true; + } + + } + } //--------add replacement logic ends-----------------------------------// + tagName = (String) token; + token = null; + jsonObject = new JSONObject(); + boolean nilAttributeFound = false; + xmlXsiTypeConverter = null; + for (;;) { + if (token == null) { + token = x.nextToken(); + } + // attribute = value + if (token instanceof String) { + string = (String) token; + token = x.nextToken(); + if (token == EQ) { + token = x.nextToken(); + if (!(token instanceof String)) { + throw x.syntaxError("Missing value"); + } + + if (config.isConvertNilAttributeToNull() + && NULL_ATTR.equals(string) + && Boolean.parseBoolean((String) token)) { + nilAttributeFound = true; + } else if(config.getXsiTypeMap() != null && !config.getXsiTypeMap().isEmpty() + && TYPE_ATTR.equals(string)) { + xmlXsiTypeConverter = config.getXsiTypeMap().get(token); + } else if (!nilAttributeFound) { + Object obj = stringToValue((String) token); + if (obj instanceof Boolean) { + jsonObject.accumulate(string, + config.isKeepBooleanAsString() + ? ((String) token) + : obj); + } else if (obj instanceof Number) { + jsonObject.accumulate(string, + config.isKeepNumberAsString() + ? ((String) token) + : obj); + } else { + jsonObject.accumulate(string, stringToValue((String) token)); + } + } + token = null; + } else { + jsonObject.accumulate(string, ""); + } + + + } else if (token == SLASH) { + // Empty tag <.../> + if (x.nextToken() != GT) { + throw x.syntaxError("Misshaped tag"); + } + if (config.getForceList().contains(tagName)) { + // Force the value to be an array + if (nilAttributeFound) { + context.append(tagName, JSONObject.NULL); + } else if (jsonObject.length() > 0) { + context.append(tagName, jsonObject); + } else { + context.put(tagName, new JSONArray()); + } + } else { + if (nilAttributeFound) { + context.accumulate(tagName, JSONObject.NULL); + } else if (jsonObject.length() > 0) { + context.accumulate(tagName, jsonObject); + } else { + context.accumulate(tagName, ""); + } + } + return false; + + } else if (token == GT) { + // Content, between <...> and + for (;;) { + token = x.nextContent(); + if (token == null) { + if (tagName != null) { + throw x.syntaxError("Unclosed tag " + tagName); + } + return false; + } else if (token instanceof String) { + string = (String) token; + if (string.length() > 0) { + if(xmlXsiTypeConverter != null) { + jsonObject.accumulate(config.getcDataTagName(), + stringToValue(string, xmlXsiTypeConverter)); + } else { + Object obj = stringToValue((String) token); + if (obj instanceof Boolean) { + jsonObject.accumulate(config.getcDataTagName(), + config.isKeepBooleanAsString() + ? ((String) token) + : obj); + } else if (obj instanceof Number) { + jsonObject.accumulate(config.getcDataTagName(), + config.isKeepNumberAsString() + ? ((String) token) + : obj); + } else { + jsonObject.accumulate(config.getcDataTagName(), stringToValue((String) token)); + } + } + } + + } else if (token == LT) { + + if (parseMilestone2(x, jsonObject, tagName, config, currentNestingDepth + 1, + targetPath, targetPathLength, arrayKey, isReplace, mergeToParent, replacement)) { + if (config.getForceList().contains(tagName)) { + if (jsonObject.length() == 0) { + context.put(tagName, new JSONArray()); + } else if (jsonObject.length() == 1 + && jsonObject.opt(config.getcDataTagName()) != null) { + context.append(tagName, jsonObject.opt(config.getcDataTagName())); + } else { + context.append(tagName, jsonObject); + } + } else { + if (jsonObject.length() == 0) { + context.accumulate(tagName, ""); + } else if (jsonObject.length() == 1 + && jsonObject.opt(config.getcDataTagName()) != null) { + context.accumulate(tagName, jsonObject.opt(config.getcDataTagName())); + } else { + if (!config.shouldTrimWhiteSpace()) { + removeEmpty(jsonObject, config); + } + context.accumulate(tagName, jsonObject); + } + } + return false; + } + } + } + } else { + throw x.syntaxError("Misshaped tag"); + } + } + } + } + /** + * Converts an XML input stream into a JSONObject, replacing a sub-object at a specified JSONPointer path. + * + *

This method is added as part of SWE262P Milestone2 Task2. It performs in-place replacement + * during parsing, avoiding the need to first build the entire JSON tree before modifying it. + * This offers performance benefits by allowing early exit from the parser once the target node is handled.

+ * + * @param reader The XML input + * @param path The JSONPointer path where replacement should occur + * @param replacement The JSONObject to insert at the given path + * @return A JSONObject with the sub-object at the given path replaced + * @throws JSONException if parsing or path manipulation fails + */ + public static JSONObject toJSONObject(Reader reader, JSONPointer path, JSONObject replacement) throws JSONException { + JSONObject jo = new JSONObject(); + XMLTokener x = new XMLTokener(reader); + + // Reset shared state + replaced = false; + skipCurrentKey = false; + + String[] segments = path.toString().split("/"); + List targetPath = Arrays.stream(segments) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + int targetPathLength = targetPath.size(); + + Map arrayKey = new HashMap<>(); + for (int i = 0; i < segments.length; i++) { + if (segments[i].matches("\\d+") && i > 0) { + arrayKey.put(segments[i - 1], Integer.parseInt(segments[i])); + } + } + + boolean mergeToParent = !path.toString().endsWith("/"); + + while (x.more()) { + x.skipPast("<"); + if (x.more()) { + parseMilestone2(x, jo, null, XMLParserConfiguration.ORIGINAL, 0, + targetPath, targetPathLength, arrayKey, true, mergeToParent, replacement); + } + } + + if (replaced) { + return jo; + } else { + throw new JSONException("Replacement failed or path not found: " + path); + } + } + + /** + * Convert a well-formed (but not necessarily valid) XML string into a + * JSONObject. Some information may be lost in this transformation because + * JSON is a data format and XML is a document format. XML uses elements, + * attributes, and content text, while JSON uses unordered collections of + * name/value pairs and arrays of values. JSON does not does not like to + * distinguish between elements and attributes. Sequences of similar + * elements are represented as JSONArrays. Content text may be placed in a + * "content" member. Comments, prologs, DTDs, and
{@code
+     * <[ [ ]]>}
+ * are ignored. + * + * All values are converted as strings, for 1, 01, 29.0 will not be coerced to + * numbers but will instead be the exact value as seen in the XML document. + * + * @param string + * The source string. + * @param keepStrings If true, then values will not be coerced into boolean + * or numeric values and will instead be left as strings + * @return A JSONObject containing the structured data from the XML string. + * @throws JSONException Thrown if there is an errors while parsing the string + */ + public static JSONObject toJSONObject(String string, boolean keepStrings) throws JSONException { + return toJSONObject(new StringReader(string), keepStrings); + } + + /** + * Convert a well-formed (but not necessarily valid) XML string into a + * JSONObject. Some information may be lost in this transformation because + * JSON is a data format and XML is a document format. XML uses elements, + * attributes, and content text, while JSON uses unordered collections of + * name/value pairs and arrays of values. JSON does not does not like to + * distinguish between elements and attributes. Sequences of similar + * elements are represented as JSONArrays. Content text may be placed in a + * "content" member. Comments, prologs, DTDs, and
{@code
+     * <[ [ ]]>}
+ * are ignored. + * + * All numbers are converted as strings, for 1, 01, 29.0 will not be coerced to + * numbers but will instead be the exact value as seen in the XML document depending + * on how flag is set. * All booleans are converted as strings, for true, false will not be coerced to * booleans but will instead be the exact value as seen in the XML document depending * on how flag is set. @@ -1129,4 +2239,66 @@ private static final String indent(int indent) { } return sb.toString(); } + + // milestone 5 + private static final ExecutorService executor = Executors.newFixedThreadPool( + Runtime.getRuntime().availableProcessors() + ); + + public static Future toJSONObject( + Reader reader, + Consumer after, + Consumer error + ) { + FutureTask task = new FutureTask<>(new FutureTaskCallable(reader, after, error)); + executor.execute(task); + return task; + } + + private static class FutureTaskCallable implements Callable { + private final Reader reader; + private final Consumer after; + private final Consumer error; + + public FutureTaskCallable( + Reader reader, + Consumer after, + Consumer error + ) { + this.reader = reader; + this.after = after; + this.error = error; + } + + @Override + public JSONObject call() throws Exception { + JSONObject jo = new JSONObject(); + try { + XMLTokener x = new XMLTokener(reader); + while (x.more()) { + x.skipPast("<"); + if (x.more()) { + parse(x, jo, null, XMLParserConfiguration.ORIGINAL, 0); + } + } + after.accept(jo); + return jo; + } catch (Exception e) { + error.accept(e); + throw e; + } + } + } + + public static class AsyncRunner { + private List> tasks = new ArrayList<>(); + + public AsyncRunner() { + } + + public void add(Future task) { + this.tasks.add(task); + } + + } } diff --git a/src/test/java/org/json/junit/milestone2/tests/XMLJsonPointerQueryingTest.java b/src/test/java/org/json/junit/milestone2/tests/XMLJsonPointerQueryingTest.java new file mode 100644 index 000000000..f50ffde4b --- /dev/null +++ b/src/test/java/org/json/junit/milestone2/tests/XMLJsonPointerQueryingTest.java @@ -0,0 +1,86 @@ +package org.json.junit.milestone2.tests; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONPointer; +import org.json.XML; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; + +public class XMLJsonPointerQueryingTest { + @Test + public void testXML() { + String xmlString = "\n"+ + "\n"+ + " Crista \n"+ + " Crista Lopes\n" + + "
\n" + + " Ave of Nowhere\n" + + " 92614\n" + + "
\n" + + "
"; + + try { + // notice that there cannot be a '/' at the end + JSONObject jobj = XML.toJSONObject(new StringReader(xmlString), new JSONPointer("/contact/address/street")); + System.out.println(jobj); + } catch (JSONException e) { + System.out.println(e); + } + } + + @Test + public void testXMLWithArray() { + String xml = + "" + + "XML Developer's Guide" + + "Midnight Rain" + + ""; + + try (Reader reader = new StringReader(xml)) { + JSONPointer ptr = new JSONPointer("/catalog/book/1"); + JSONObject node = XML.toJSONObject(reader, ptr); + System.out.println(node); + } catch (JSONException | IOException e) { + e.printStackTrace(); + } + } + + @Test + public void testPointerWithResourceFile() throws Exception { + // test performance on large xml files + // this program is expected to finish early due to early appearance of requested path + try (Reader reader = new InputStreamReader( + getClass().getResourceAsStream("/xml_files/large_file.xml"))) { + + JSONPointer ptr = new JSONPointer("/mediawiki/siteinfo/namespaces"); + JSONObject node = XML.toJSONObject(reader, ptr); + System.out.println(node); + } + } + + @Test + public void testXMLWithNonexistentPath() { + String xmlString = "\n"+ + "\n"+ + " Crista \n"+ + " Crista Lopes\n" + + "
\n" + + " Ave of Nowhere\n" + + " 92614\n" + + "
\n" + + "
"; + + try { + // zipcode is not a sub-path of /contact/address/street/, thus should throw an error + JSONObject jobj = XML.toJSONObject(new StringReader(xmlString), new JSONPointer("/contact/address/street/zipcode")); + System.out.println(jobj); + } catch (JSONException e) { + System.out.println(e); + } + } +} diff --git a/src/test/java/org/json/junit/milestone2/tests/XMLPointerReplaceTest.java b/src/test/java/org/json/junit/milestone2/tests/XMLPointerReplaceTest.java new file mode 100644 index 000000000..4a439022a --- /dev/null +++ b/src/test/java/org/json/junit/milestone2/tests/XMLPointerReplaceTest.java @@ -0,0 +1,52 @@ +package org.json.junit.milestone2.tests; + +import org.json.JSONObject; +import org.json.JSONPointer; +import org.json.XML; +import org.junit.Test; +import static org.junit.Assert.*; + + +import java.io.StringReader; + +public class XMLPointerReplaceTest { + + @Test + public void testReplaceSubObject_success() { + String xml = "<content>Old Title</content>John"; + StringReader reader = new StringReader(xml); + + JSONObject replacement = new JSONObject().put("content", "New Title"); + JSONPointer pointer = new JSONPointer("/book/title"); + + JSONObject result = XML.toJSONObject(reader, pointer, replacement); + + assertEquals("New Title", result.getJSONObject("book").getJSONObject("title").get("content")); + assertEquals("John", result.getJSONObject("book").get("author")); + } + + + + + + @Test(expected = RuntimeException.class) + public void testReplaceSubObject_invalidPath() { + String xml = "Old Title"; + StringReader reader = new StringReader(xml); + + JSONObject replacement = new JSONObject().put("name", "Unknown"); + JSONPointer pointer = new JSONPointer("/abcde/fghijk"); + + XML.toJSONObject(reader, pointer, replacement); + } + + @Test(expected = RuntimeException.class) + public void testReplaceSubObject_nullReader() { + JSONObject replacement = new JSONObject().put("title", "Anything"); + JSONPointer pointer = new JSONPointer("/book/title"); + + XML.toJSONObject(null, pointer, replacement); + } +} + + diff --git a/src/test/java/org/json/junit/milestone3/tests/XMLKeyTransformerTest.java b/src/test/java/org/json/junit/milestone3/tests/XMLKeyTransformerTest.java new file mode 100644 index 000000000..d96f9b591 --- /dev/null +++ b/src/test/java/org/json/junit/milestone3/tests/XMLKeyTransformerTest.java @@ -0,0 +1,41 @@ +package org.json.junit.milestone3.tests; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONPointer; +import org.json.XML; +import org.junit.Test; + +import java.io.StringReader; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; + +public class XMLKeyTransformerTest { + // define some customized functions for testing + @Test + public void keyTransformerAddPrefixTest() { + String xml = "TitleJohn"; + Function prefixer = key -> "swe262_" + key; + + JSONObject result = XML.toJSONObject(new StringReader(xml), prefixer); + + assertEquals("Title", result.getJSONObject("swe262_book").get("swe262_title")); + assertEquals("John", result.getJSONObject("swe262_book").get("swe262_author")); + } + + @Test + public void keyTransformerReverseTest() { + String xml = "value"; + Function reverser = key -> new StringBuilder(key).reverse().toString(); + + JSONObject result = XML.toJSONObject(new StringReader(xml), reverser); + + assertEquals("value", result.getJSONObject("atad").get("meti")); + } + + @Test(expected = NullPointerException.class) + public void nullReaderTest() { + XML.toJSONObject(null, key -> "x_" + key); + } +} diff --git a/src/test/java/org/json/junit/milestone4/tests/JSONObjectStreamTest.java b/src/test/java/org/json/junit/milestone4/tests/JSONObjectStreamTest.java new file mode 100644 index 000000000..7957fed9f --- /dev/null +++ b/src/test/java/org/json/junit/milestone4/tests/JSONObjectStreamTest.java @@ -0,0 +1,74 @@ +package org.json.junit.milestone4.tests; + +import org.json.JSONObject; +import org.json.JSONObject.JSONNode; +import org.json.XML; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.Scanner; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** + * Unit tests for the JSONObject.toStream() extension (Milestone 4) – JUnit 4 / Java 8. + */ +public class JSONObjectStreamTest { + + private static JSONObject catalog; + + @BeforeClass + public static void loadXml() throws Exception { + try (InputStream in = JSONObjectStreamTest.class.getResourceAsStream("/books.xml")) { + assertNotNull("books.xml must be on the class-path (src/test/resources)", in); + + String xml; + try (Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name())) { + scanner.useDelimiter("\\A"); + xml = scanner.hasNext() ? scanner.next() : ""; + } + + catalog = XML.toJSONObject(xml); + } + } + + @Test + public void extractTitles() { + List titles = catalog.toStream() + .filter(n -> n.getPath().endsWith("/title")) + .map(n -> n.getValue().toString()) + .collect(Collectors.toList()); + + assertEquals(12, titles.size()); + assertTrue(titles.contains("XML Developer's Guide")); + assertTrue(titles.contains("Visual Studio 7: A Comprehensive Guide")); + } + + @Test + public void findExactPath() { + String wantedPath = "/catalog/book[0]/author"; + Optional node = catalog.toStream() + .filter(n -> n.getPath().equals(wantedPath)) + .findFirst(); + + assertTrue(node.isPresent()); + assertEquals("Gambardella, Matthew", node.get().getValue()); + } + + @Test + public void filterCheapBooks() { + List cheapPrices = catalog.toStream() + .filter(n -> n.getPath().endsWith("/price")) + .filter(n -> Double.parseDouble(n.getValue().toString()) < 10.0) + .collect(Collectors.toList()); + + assertEquals(8, cheapPrices.size()); + assertTrue(cheapPrices.stream() + .allMatch(n -> Double.parseDouble(n.getValue().toString()) < 10.0)); + } +} diff --git a/src/test/java/org/json/junit/milestone5/JSONObjectAsyncTest.java b/src/test/java/org/json/junit/milestone5/JSONObjectAsyncTest.java new file mode 100644 index 000000000..89df578f8 --- /dev/null +++ b/src/test/java/org/json/junit/milestone5/JSONObjectAsyncTest.java @@ -0,0 +1,157 @@ +package org.json.junit.milestone5; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.XML; +import org.junit.Test; + +import java.io.Reader; +import java.io.StringReader; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; + +public class JSONObjectAsyncTest { + + private final XML.AsyncRunner runner = new XML.AsyncRunner(); + + public JSONObjectAsyncTest() { + // Default constructor + } + + private final Reader medReader = new StringReader( + "" + + " " + + " Gambardella, Matthew" + + " XML Developer's Guide" + + " Computer" + + " 44.95" + + " 2000-10-01" + + " An in-depth look at creating applications with XML." + + " " + + " " + + " Ralls, Kim" + + " Midnight Rain" + + " Fantasy" + + " 5.95" + + " 2000-12-16" + + " A former architect battles corporate zombies, an evil sorceress, and her own childhood to become queen of the world." + + " " + + "" + ); + + private final Reader smallReader = new StringReader( + "" + + " Hello" + + "" + ); + + @Test + public void testAsyncParsing() throws Exception { + CountDownLatch latch = new CountDownLatch(2); + JSONObject[] results = new JSONObject[2]; + Exception[] errors = new Exception[2]; + long[] timeElapsed = new long[2]; + + final long startTime = System.nanoTime(); + + Future task1 = XML.toJSONObject( + medReader, + jo -> { + results[0] = jo; + long endNano = System.nanoTime(); + timeElapsed[0] = TimeUnit.NANOSECONDS.toMillis(endNano - startTime); + latch.countDown(); + }, + e -> { + errors[0] = e; + latch.countDown(); + } + ); + + Future task2 = XML.toJSONObject( + smallReader, + jo -> { + results[1] = jo; + long endNano = System.nanoTime(); + timeElapsed[1] = TimeUnit.NANOSECONDS.toMillis(endNano - startTime); + latch.countDown(); + }, + e -> { + errors[1] = e; + latch.countDown(); + } + ); + + boolean completed = latch.await(10, TimeUnit.SECONDS); + assertTrue("Both callbacks should complete within 10 seconds", completed); + + assertNull("No error expected for medReader", errors[0]); + assertNull("No error expected for smallReader", errors[1]); + + JSONObject expectedMed = new JSONObject() + .put("catalog", new JSONObject() + .put("book", new JSONArray() + .put(new JSONObject() + .put("author", "Gambardella, Matthew") + .put("title", "XML Developer's Guide") + .put("genre", "Computer") + .put("price", 44.95) + .put("publish_date", "2000-10-01") + .put("description", "An in-depth look at creating applications with XML.") + .put("id", "bk101") + ) + .put(new JSONObject() + .put("author", "Ralls, Kim") + .put("title", "Midnight Rain") + .put("genre", "Fantasy") + .put("price", 5.95) + .put("publish_date", "2000-12-16") + .put("description", "A former architect battles corporate zombies, an evil sorceress, and her own childhood to become queen of the world.") + .put("id", "bk102") + ) + ) + ); + + JSONObject expectedSmall = new JSONObject() + .put("root", new JSONObject() + .put("message", "Hello") + ); + + assertTrue("medReader JSON should match expected", expectedMed.similar(results[0])); + assertTrue("smallReader JSON should match expected", expectedSmall.similar(results[1])); + + assertTrue("smallReader must be faster than medReader", timeElapsed[1] < timeElapsed[0]); + + // Optional debug output + System.out.println("medReader elapsed: " + timeElapsed[0] + " ms"); + System.out.println("smallReader elapsed: " + timeElapsed[1] + " ms"); + } + + @Test + public void testAsyncParsingWithInvalidXML() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Exception[] errors = new Exception[1]; + + Reader invalidReader = new StringReader( + "Unclosed tag" + ); + + Future task = org.json.XML.toJSONObject( + invalidReader, + jo -> fail("Should not succeed with invalid XML"), + e -> { + errors[0] = e; + latch.countDown(); + } + ); + + boolean completed = latch.await(5, TimeUnit.SECONDS); + assertTrue("Callback should complete within 5 seconds", completed); + assertNotNull("Error should be captured for invalid XML", errors[0]); + assertTrue("Error should be a JSONException", errors[0] instanceof org.json.JSONException); + } + +} diff --git a/src/test/resources/books.xml b/src/test/resources/books.xml new file mode 100644 index 000000000..06889bd6c --- /dev/null +++ b/src/test/resources/books.xml @@ -0,0 +1,120 @@ + + + + Gambardella, Matthew + XML Developer's Guide + Computer + 44.95 + 2000-10-01 + An in-depth look at creating applications + with XML. + + + Ralls, Kim + Midnight Rain + Fantasy + 5.95 + 2000-12-16 + A former architect battles corporate zombies, + an evil sorceress, and her own childhood to become queen + of the world. + + + Corets, Eva + Maeve Ascendant + Fantasy + 5.95 + 2000-11-17 + After the collapse of a nanotechnology + society in England, the young survivors lay the + foundation for a new society. + + + Corets, Eva + Oberon's Legacy + Fantasy + 5.95 + 2001-03-10 + In post-apocalypse England, the mysterious + agent known only as Oberon helps to create a new life + for the inhabitants of London. Sequel to Maeve + Ascendant. + + + Corets, Eva + The Sundered Grail + Fantasy + 5.95 + 2001-09-10 + The two daughters of Maeve, half-sisters, + battle one another for control of England. Sequel to + Oberon's Legacy. + + + Randall, Cynthia + Lover Birds + Romance + 4.95 + 2000-09-02 + When Carla meets Paul at an ornithology + conference, tempers fly as feathers get ruffled. + + + Thurman, Paula + Splish Splash + Romance + 4.95 + 2000-11-02 + A deep sea diver finds true love twenty + thousand leagues beneath the sea. + + + Knorr, Stefan + Creepy Crawlies + Horror + 4.95 + 2000-12-06 + An anthology of horror stories about roaches, + centipedes, scorpions and other insects. + + + Kress, Peter + Paradox Lost + Science Fiction + 6.95 + 2000-11-02 + After an inadvertant trip through a Heisenberg + Uncertainty Device, James Salway discovers the problems + of being quantum. + + + O'Brien, Tim + Microsoft .NET: The Programming Bible + Computer + 36.95 + 2000-12-09 + Microsoft's .NET initiative is explored in + detail in this deep programmer's reference. + + + O'Brien, Tim + MSXML3: A Comprehensive Guide + Computer + 36.95 + 2000-12-01 + The Microsoft MSXML3 parser is covered in + detail, with attention to XML DOM interfaces, XSLT processing, + SAX and more. + + + Galos, Mike + Visual Studio 7: A Comprehensive Guide + Computer + 49.95 + 2001-04-16 + Microsoft Visual Studio 7 is explored in depth, + looking at how Visual Basic, Visual C++, C#, and ASP+ are + integrated into a comprehensive development + environment. + + \ No newline at end of file