All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.jayway.restassured.path.json.JsonPath Maven / Gradle / Ivy

There is a newer version: 2.9.0
Show newest version
/*
 * Copyright 2013 the original author or authors.
 *
 * Licensed 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 com.jayway.restassured.path.json;

import com.jayway.restassured.internal.assertion.AssertParameter;
import com.jayway.restassured.internal.path.ObjectConverter;
import com.jayway.restassured.internal.path.json.ConfigurableJsonSlurper;
import com.jayway.restassured.internal.path.json.JSONAssertion;
import com.jayway.restassured.internal.path.json.JsonPrettifier;
import com.jayway.restassured.internal.path.json.mapping.JsonObjectDeserializer;
import com.jayway.restassured.mapper.factory.GsonObjectMapperFactory;
import com.jayway.restassured.mapper.factory.Jackson1ObjectMapperFactory;
import com.jayway.restassured.mapper.factory.Jackson2ObjectMapperFactory;
import com.jayway.restassured.path.json.config.JsonParserType;
import com.jayway.restassured.path.json.config.JsonPathConfig;
import com.jayway.restassured.path.json.exception.JsonPathException;
import groovy.json.JsonBuilder;
import groovy.json.JsonOutput;

import java.io.*;
import java.net.URL;
import java.util.*;
import java.util.Map.Entry;

/**
 * JsonPath is an alternative to using XPath for easily getting values from a Object document. It follows the
 * Groovy GPath syntax when getting an object from the document. You can regard it as an alternative to XPath for JSON.
 * E.g. given the following Object document:
 * 
 * { "store": {
 *   "book": [
 *    { "category": "reference",
 *      "author": "Nigel Rees",
 *      "title": "Sayings of the Century",
 *      "price": 8.95
 *    },
 *    { "category": "fiction",
 *      "author": "Evelyn Waugh",
 *      "title": "Sword of Honour",
 *      "price": 12.99
 *    },
 *    { "category": "fiction",
 *      "author": "Herman Melville",
 *      "title": "Moby Dick",
 *      "isbn": "0-553-21311-3",
 *      "price": 8.99
 *    },
 *    { "category": "fiction",
 *      "author": "J. R. R. Tolkien",
 *      "title": "The Lord of the Rings",
 *      "isbn": "0-395-19395-8",
 *      "price": 22.99
 *    }
 *  ],
 *    "bicycle": {
 *      "color": "red",
 *      "price": 19.95
 *    }
 *  }
 * }
 * 
* To get a list of all book categories: *
 * List<String> categories = with(Object).get("store.book.category");
 * 
*

* Get the first book category: *

 * String category = with(Object).get("store.book[0].category");
 * 
*

* Get the last book category: *

 * String category = with(Object).get("store.book[-1].category");
 * 
*

* Get all books with price between 5 and 15: *

 * List<Map> books = with(Object).get("store.book.findAll { book -> book.price >= 5 && book.price <= 15 }");
 * 
*

* The JsonPath implementation of rest-assured uses a Groovy shell to evaluate expressions so be careful when injecting * user input into the expression. For example avoid doing this: *

 * String name = System.console().readLine();
 * List<Map> books = with(Object).get("store.book.findAll { book -> book.author == " + name + " }");
 * 
* Instead use the {@link #param(java.lang.String, java.lang.Object)} method like this: *
 * String name = System.console().readLine();
 * List<Map> books = with(Object).param("name", name).get("store.book.findAll { book -> book.author == name }");
 * 
*/ public class JsonPath { public static JsonPathConfig config = null; private final JsonParser jsonParser; private JsonPathConfig jsonPathConfig = null; private String rootPath = ""; /** * Parameters for groovy console (not initialized here to save memory for queries that don't use params) */ private Map params; /** * Instantiate a new JsonPath instance. * * @param text The text containing the Object document */ public JsonPath(String text) { jsonParser = parseText(text); } /** * Instantiate a new JsonPath instance. * * @param url The url containing the Object document */ public JsonPath(URL url) { jsonParser = parseURL(url); } /** * Instantiate a new JsonPath instance. * * @param stream The stream containing the Object document */ public JsonPath(InputStream stream) { jsonParser = parseInputStream(stream); } /** * Instantiate a new JsonPath instance. * * @param file The file containing the Object document */ public JsonPath(File file) { jsonParser = parseFile(file); } /** * Instantiate a new JsonPath instance. * * @param reader The reader containing the Object document */ public JsonPath(Reader reader) { jsonParser = parseReader(reader); } private JsonPath(JsonPath jsonPath, JsonPathConfig jsonPathConfig) { this.jsonPathConfig = jsonPathConfig; this.jsonParser = jsonPath.jsonParser; this.rootPath = jsonPath.rootPath; if(jsonPath.params!=null) { this.params = new HashMap(jsonPath.params); } } /** * Get a Object graph with no named root element as a Java object. This is just a short-cut for *

*

     *     get("");
     * 
* or *
     *     get("$");
     * 
* * @return The object matching the Object graph. This may be any primitive type, a List or a Map. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public T get() { return get(""); } /** * Get the result of an Object path expression as a boolean. * * @param path The Object path. * @return The object matching the Object path. This may be any primitive type, a List or a Map. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public T get(String path) { final JSONAssertion jsonAssertion = createJsonAssertion(path, params); final Object json = jsonParser.parseWith(createConfigurableJsonSlurper()); return (T) jsonAssertion.getResult(json, null); } /** * Get the result of an Object path expression as a boolean * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public boolean getBoolean(String path) { return ObjectConverter.convertObjectTo(get(path), Boolean.class); } /** * Get the result of an Object path expression as a char. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public char getChar(String path) { return ObjectConverter.convertObjectTo(get(path), Character.class); } /** * Get the result of an Object path expression as an int. * * @param path The Object path. * @return The int matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public int getInt(String path) { //The type returned from Groovy depends on the input, so we need to handle different numerical types. Object value = get(path); if (value instanceof Integer) { return (Integer) value; } else if (value instanceof Short) { return ((Short) value).intValue(); } else if (value instanceof Long) { return ((Long) value).intValue(); } else { return ObjectConverter.convertObjectTo(value, Integer.class); } } /** * Get the result of an Object path expression as a byte. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public byte getByte(String path) { //The type returned from Groovy depends on the input, so we need to handle different numerical types. Object value = get(path); if (value instanceof Byte) { return (Byte) value; } else if (value instanceof Long) { return ((Long) value).byteValue(); } else if (value instanceof Integer) { return ((Integer) value).byteValue(); } else { return ObjectConverter.convertObjectTo(value, Byte.class); } } /** * Get the result of an Object path expression as a short. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public short getShort(String path) { //The type returned from Groovy depends on the input, so we need to handle different numerical types. Object value = get(path); if (value instanceof Short) { return (Short) value; } else if (value instanceof Long) { return ((Long) value).shortValue(); } else if (value instanceof Integer) { return ((Integer) value).shortValue(); } else { return ObjectConverter.convertObjectTo(value, Short.class); } } /** * Get the result of an Object path expression as a float. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public float getFloat(String path) { final Object value = get(path); //Groovy will always return a Double for floating point values. if (value instanceof Double) { return ((Double) value).floatValue(); } else { return ObjectConverter.convertObjectTo(value, Float.class); } } /** * Get the result of an Object path expression as a double. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public double getDouble(String path) { final Object value = get(path); if (value instanceof Double) { return (Double) value; } return ObjectConverter.convertObjectTo(value, Double.class); } /** * Get the result of an Object path expression as a long. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public long getLong(String path) { //The type returned from Groovy depends on the input, so we need to handle different numerical types. Object value = get(path); if (value instanceof Long) { return (Long) value; } else if (value instanceof Short) { return ((Short) value).longValue(); } else if (value instanceof Integer) { return ((Integer) value).longValue(); } else { return ObjectConverter.convertObjectTo(value, Long.class); } } /** * Get the result of an Object path expression as a string. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public String getString(String path) { return ObjectConverter.convertObjectTo(get(path), String.class); } /** * Get the result of an Object path expression as a UUID. * * @param path The Object path. * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public UUID getUUID(String path) { return ObjectConverter.convertObjectTo(get(path), UUID.class); } /** * Get the result of an Object path expression as a list. * * @param path The Object path. * @param The list type * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public List getList(String path) { return get(path); } /** * Get the result of an Object path expression as a list. * * @param path The Object path. * @param genericType The generic list type * @param The type * @return The object matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public List getList(String path, Class genericType) { final List original = get(path); final List newList = new LinkedList(); if (original != null) { for (T t : original) { newList.add(ObjectConverter.convertObjectTo(t, genericType)); } } return Collections.unmodifiableList(newList); } /** * Get the result of an Object path expression as a map. * * @param path The Object path. * @param The type of the expected key * @param The type of the expected value * @return The map matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public Map getMap(String path) { return get(path); } /** * Get the result of an Object path expression as a map. * * @param path The Object path. * @param keyType The type of the expected key * @param valueType The type of the expected value * @param The type of the expected key * @param The type of the expected value * @return The map matching the Object path. A {@link java.lang.ClassCastException} will be thrown if the object * cannot be casted to the expected type. */ public Map getMap(String path, Class keyType, Class valueType) { final Map originalMap = get(path); final Map newMap = new HashMap(); for (Entry entry : originalMap.entrySet()) { final K key = entry.getKey() == null ? null : ObjectConverter.convertObjectTo(entry.getKey(), keyType); final V value = entry.getValue() == null ? null : ObjectConverter.convertObjectTo(entry.getValue(), valueType); newMap.put(key, value); } return Collections.unmodifiableMap(newMap); } /** * Get the result of a Object path expression as a java Object. * E.g. given the following Object document: *
     * { "store": {
     *   "book": [
     *    { "category": "reference",
     *      "author": "Nigel Rees",
     *      "title": "Sayings of the Century",
     *      "price": 8.95
     *    },
     *    { "category": "fiction",
     *      "author": "Evelyn Waugh",
     *      "title": "Sword of Honour",
     *      "price": 12.99
     *    },
     *    { "category": "fiction",
     *      "author": "Herman Melville",
     *      "title": "Moby Dick",
     *      "isbn": "0-553-21311-3",
     *      "price": 8.99
     *    },
     *    { "category": "fiction",
     *      "author": "J. R. R. Tolkien",
     *      "title": "The Lord of the Rings",
     *      "isbn": "0-395-19395-8",
     *      "price": 22.99
     *    }
     *  ],
     *    "bicycle": {
     *      "color": "red",
     *      "price": 19.95
     *    }
     *  }
     * }
     * 
* And a Java object like this: *

*

     * public class Book {
     *      private String category;
     *      private String author;
     *      private String title;
     *      private String isbn;
     *      private float price;
     *
     *      public String getCategory() {
     *         return category;
     *      }
     *
     *     public void setCategory(String category) {
     *         this.category = category;
     *     }
     *
     *    public String getAuthor() {
     *          return author;
     *     }
     *
     *    public void setAuthor(String author) {
     *         this.author = author;
     *    }
     *
     *    public String getTitle() {
     *         return title;
     *    }
     *
     *    public void setTitle(String title) {
     *        this.title = title;
     *    }
     *
     *    public String getIsbn() {
     *             return isbn;
     *    }
     *
     *    public void setIsbn(String isbn) {
     *          this.isbn = isbn;
     *    }
     *
     *    public float getPrice() {
     *        return price;
     *    }
     *
     *    public void setPrice(float price) {
     *             this.price = price;
     *   }
     * }
     * 
*

* Then *

     * Book book = from(Object).getObject("store.book[2]", Book.class);
     * 
*

* maps the second book to a Book instance. * * @param path The path to the object to map * @param objectType The class type of the expected object * @param The type of the expected object * @return The object */ public T getObject(String path, Class objectType) { Object object = getJsonObject(path); if (object == null) { return null; } else if (object instanceof List || object instanceof Map) { // TODO Avoid double parsing object = new JsonBuilder(object).toString(); } else { return ObjectConverter.convertObjectTo(object, objectType); } JsonPathConfig cfg = new JsonPathConfig(getJsonPathConfig()); if (cfg.hasCustomJackson10ObjectMapperFactory()) { cfg = cfg.defaultParserType(JsonParserType.JACKSON_1); } else if (cfg.hasCustomGsonObjectMapperFactory()) { cfg = cfg.defaultParserType(JsonParserType.GSON); } else if (cfg.hasCustomJackson20ObjectMapperFactory()) { cfg = cfg.defaultParserType(JsonParserType.JACKSON_2); } if (!(object instanceof String)) { throw new IllegalStateException("Internal error: Json object was not an instance of String, please report to the REST Assured mailing-list."); } return JsonObjectDeserializer.deserialize((String) object, objectType, cfg); } /** * Add a parameter for the expression. Example: *

     * String name = System.console().readLine();
     * List<Map> books = with(Object).param("name", name).get("store.book.findAll { book -> book.author == name }");
     * 
* * @param key The name of the parameter. Just use this name in your expression as a variable * @param value The value of the parameter * @return New JsonPath instance with the parameter set */ public JsonPath param(String key, Object value) { JsonPath newP=new JsonPath(this, config); if(newP.params==null) { newP.params=new HashMap(); } newP.params.put(key, value); return newP; } /** * Peeks into the JSON that JsonPath will parse by printing it to the console. You can * continue working with JsonPath afterwards. This is mainly for debug purposes. If you want to return a prettified version of the content * see {@link #prettify()}. If you want to return a prettified version of the content and also print it to the console use {@link #prettyPrint()}. *

*

* Note that the content is not guaranteed to be looking exactly like the it does at the source. This is because once you peek * the content has been downloaded and transformed into another data structure (used by JsonPath) and the JSON is rendered * from this data structure. *

* * @return The same JsonPath instance */ public JsonPath peek() { System.out.println(toJsonString()); return this; } /** * Peeks into the JSON that JsonPath will parse by printing it to the console in a prettified manner. You can * continue working with JsonPath afterwards. This is mainly for debug purposes. If you want to return a prettified version of the content * see {@link #prettify()}. If you want to return a prettified version of the content and also print it to the console use {@link #prettyPrint()}. *

*

* Note that the content is not guaranteed to be looking exactly like the it does at the source. This is because once you peek * the content has been downloaded and transformed into another data structure (used by JsonPath) and the JSON is rendered * from this data structure. *

* * @return The same JsonPath instance */ public JsonPath prettyPeek() { prettyPrint(); return this; } /** * Get the JSON as a prettified string. *

* Note that the content is not guaranteed to be looking exactly like the it does at the source. This is because once you peek * the content has been downloaded and transformed into another data structure (used by JsonPath) and the JSON is rendered * from this data structure. *

* * @return The JSON as a prettified String. */ public String prettify() { final String jsonString = toJsonString(); return JsonPrettifier.prettifyJson(jsonString); } /** * Get and print the JSON as a prettified string. *

* Note that the content is not guaranteed to be looking exactly like the it does at the source. This is because once you peek * the content has been downloaded and transformed into another data structure (used by JsonPath) and the JSON is rendered * from this data structure. *

* * @return The JSON as a prettified String. */ public String prettyPrint() { final String pretty = prettify(); System.out.println(pretty); return pretty; } /** * Configure JsonPath to use a specific Gson object mapper factory * * @param factory The gson object mapper factory instance * @return a new JsonPath instance */ public JsonPath using(GsonObjectMapperFactory factory) { return new JsonPath(this, jsonPathConfig.gsonObjectMapperFactory(factory)); } /** * Configure JsonPath to use a specific Jackson object mapper factory * * @param factory The Jackson object mapper factory instance * @return a new JsonPath instance */ public JsonPath using(Jackson1ObjectMapperFactory factory) { return new JsonPath(this, getJsonPathConfig().jackson1ObjectMapperFactory(factory)); } /** * Configure JsonPath to use a specific Jackson 2 object mapper factory * * @param factory The Jackson 2 object mapper factory instance * @return a new JsonPath instance */ public JsonPath using(Jackson2ObjectMapperFactory factory) { return new JsonPath(this, getJsonPathConfig().jackson2ObjectMapperFactory(factory)); } /** * Configure JsonPath to with a specific JsonPathConfig. * * @param config The JsonPath config * @return a new JsonPath instance */ public JsonPath using(JsonPathConfig config) { return new JsonPath(this, config); } /** * Syntactic sugar. * * @return The same JsonPath instance. */ public JsonPath and() { return this; } /** * Instantiate a new JsonPath instance. * * @param text The text containing the Object document */ public static JsonPath given(String text) { return new JsonPath(text); } /** * Instantiate a new JsonPath instance. * * @param stream The stream containing the Object document */ public static JsonPath given(InputStream stream) { return new JsonPath(stream); } /** * Instantiate a new JsonPath instance. * * @param file The file containing the Object document */ public static JsonPath given(File file) { return new JsonPath(file); } /** * Instantiate a new JsonPath instance. * * @param reader The reader containing the Object document */ public static JsonPath given(Reader reader) { return new JsonPath(reader); } /** * Instantiate a new JsonPath instance. * * @param url The URL containing the Object document */ public static JsonPath given(URL url) { return new JsonPath(url); } /** * Instantiate a new JsonPath instance. * * @param stream The stream containing the Object document */ public static JsonPath with(InputStream stream) { return new JsonPath(stream); } /** * Instantiate a new JsonPath instance. * * @param text The text containing the Object document */ public static JsonPath with(String text) { return new JsonPath(text); } /** * Instantiate a new JsonPath instance. * * @param file The file containing the Object document */ public static JsonPath with(File file) { return new JsonPath(file); } /** * Instantiate a new JsonPath instance. * * @param reader The reader containing the Object document */ public static JsonPath with(Reader reader) { return new JsonPath(reader); } /** * Instantiate a new JsonPath instance. * * @param url The URI containing the Object document */ public static JsonPath with(URL url) { return new JsonPath(url); } /** * Instantiate a new JsonPath instance. * * @param stream The stream containing the Object document */ public static JsonPath from(InputStream stream) { return new JsonPath(stream); } /** * Instantiate a new JsonPath instance. * * @param text The text containing the Object document */ public static JsonPath from(String text) { return new JsonPath(text); } /** * Instantiate a new JsonPath instance. * * @param file The file containing the Object document */ public static JsonPath from(File file) { return new JsonPath(file); } /** * Instantiate a new JsonPath instance. * * @param reader The reader containing the Object document */ public static JsonPath from(Reader reader) { return new JsonPath(reader); } /** * Instantiate a new JsonPath instance. * * @param url The URI containing the Object document */ public static JsonPath from(URL url) { return new JsonPath(url); } /** * Set the root path of the document so that you don't need to write the entire path. E.g. *
     * final JsonPath jsonPath = new JsonPath(Object).setRoot("store.book");
     * assertThat(jsonPath.getInt("size()"), equalTo(4));
     * assertThat(jsonPath.getList("author", String.class), hasItem("J. R. R. Tolkien"));
     * 
* * @param rootPath The root path to use. */ public JsonPath setRoot(String rootPath) { AssertParameter.notNull(rootPath, "Root path"); this.rootPath = rootPath; return this; } private JsonParser parseInputStream(final InputStream stream) { return new JsonParser() { @Override public Object doParseWith(final ConfigurableJsonSlurper slurper) { return new ExceptionCatcher() { protected Object method() throws Exception { return slurper.parse(toReader(stream)); } }.invoke(); } }; } private JsonParser parseReader(final Reader reader) { return new JsonParser() { @Override public Object doParseWith(final ConfigurableJsonSlurper slurper) { return new ExceptionCatcher() { protected Object method() throws Exception { return slurper.parse(reader); } }.invoke(); } }; } private JsonParser parseFile(final File file) { return new JsonParser() { @Override public Object doParseWith(final ConfigurableJsonSlurper slurper) { return new ExceptionCatcher() { protected Object method() throws Exception { return slurper.parse(new FileReader(file)); } }.invoke(); } }; } private JsonParser parseText(final String text) { return new JsonParser() { @Override public Object doParseWith(final ConfigurableJsonSlurper slurper) { return new ExceptionCatcher() { protected Object method() throws Exception { return slurper.parseText(text); } }.invoke(); } }; } private JsonParser parseURL(final URL url) { return new JsonParser() { @Override public Object doParseWith(final ConfigurableJsonSlurper slurper) { return new ExceptionCatcher() { protected Object method() throws Exception { return slurper.parse(toReader(url.openStream())); } }.invoke(); } }; } private BufferedReader toReader(InputStream in) { final JsonPathConfig cfg = getJsonPathConfig(); try { return new BufferedReader(new InputStreamReader(in, cfg.charset())); } catch (UnsupportedEncodingException e) { throw new IllegalArgumentException("Charset is invalid", e); } } private abstract class ExceptionCatcher { protected abstract Object method() throws Exception; public Object invoke() { try { return method(); } catch (Exception e) { throw new JsonPathException("Failed to parse the JSON document", e); } } } public T getJsonObject(String path) { final JSONAssertion jsonAssertion = createJsonAssertion(path, params); final Object json = jsonParser.parseWith(createConfigurableJsonSlurper()); return (T) jsonAssertion.getAsJsonObject(json); } private JSONAssertion createJsonAssertion(String path, Map params) { AssertParameter.notNull(path, "path"); final JSONAssertion jsonAssertion = new JSONAssertion(); final String root = rootPath.equals("") ? rootPath : rootPath.endsWith(".") ? rootPath : rootPath + "."; jsonAssertion.setKey(root + path); if(params != null) { jsonAssertion.setParams(params); } return jsonAssertion; } private ConfigurableJsonSlurper createConfigurableJsonSlurper() { JsonPathConfig cfg = getJsonPathConfig(); return new ConfigurableJsonSlurper(cfg.numberReturnType()); } private JsonPathConfig getJsonPathConfig() { JsonPathConfig cfg; if (config == null && jsonPathConfig == null) { cfg = new JsonPathConfig(); } else if (jsonPathConfig != null) { cfg = jsonPathConfig; } else { cfg = config; } return cfg; } private abstract class JsonParser { private Object json; public final Object parseWith(ConfigurableJsonSlurper slurper) { if (json == null) { json = doParseWith(slurper); } return json; } abstract Object doParseWith(ConfigurableJsonSlurper slurper); } /** * Resets static JsonPath configuration to default values */ public static void reset() { JsonPath.config = null; } private String toJsonString() { final Object json = jsonParser.parseWith(createConfigurableJsonSlurper()); final String jsonString; if (json instanceof Map) { jsonString = JsonOutput.toJson((Map) json); } else { jsonString = JsonOutput.toJson(json); } return jsonString; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy