/* * Copyright (C) 2011 lightcouch.org * * 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 org.lightcouch; import static org.lightcouch.CouchDbUtil.assertNotEmpty; import static org.lightcouch.CouchDbUtil.assertNull; import static org.lightcouch.CouchDbUtil.close; import static org.lightcouch.CouchDbUtil.generateUUID; import static org.lightcouch.CouchDbUtil.getAsString; import static org.lightcouch.CouchDbUtil.getStream; import static org.lightcouch.CouchDbUtil.streamToString; import static org.lightcouch.URIBuilder.buildUri; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.io.UnsupportedEncodingException; import java.lang.reflect.Type; import java.net.URI; import java.util.ArrayList; import java.util.List; import org.apache.commons.codec.Charsets; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.http.HttpHost; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpHead; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.entity.InputStreamEntity; import org.apache.http.entity.StringEntity; import org.apache.http.protocol.HttpContext; import org.apache.http.util.EntityUtils; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; import com.google.gson.reflect.TypeToken; /** * Contains a client Public API implementation. * @see CouchDbClient * @see CouchDbClientAndroid * @author Ahmed Yehia */ public abstract class CouchDbClientBase { static final Log log = LogFactory.getLog(CouchDbClient.class); private URI baseURI; private URI dbURI; private Gson gson; private CouchDbContext context; private CouchDbDesign design; final HttpClient httpClient; final HttpHost host; CouchDbClientBase() { this(new CouchDbConfig()); } CouchDbClientBase(CouchDbConfig config) { final CouchDbProperties props = config.getProperties(); this.httpClient = createHttpClient(props); this.gson = initGson(new GsonBuilder()); this.host = new HttpHost(props.getHost(), props.getPort(), props.getProtocol()); final String path = props.getPath() != null ? props.getPath() : ""; this.baseURI = buildUri().scheme(props.getProtocol()).host(props.getHost()).port(props.getPort()).path("/").path(path).build(); this.dbURI = buildUri(baseURI).path(props.getDbName()).path("/").build(); this.context = new CouchDbContext(this, props); this.design = new CouchDbDesign(this); } // Client(s) provided implementation /** * @return {@link HttpClient} instance for HTTP request execution. */ abstract HttpClient createHttpClient(CouchDbProperties properties); /** * @return {@link HttpContext} instance for HTTP request execution. */ abstract HttpContext createContext(); /** * Shuts down the connection manager used by this client instance. */ abstract void shutdown(); // Public API /** * Provides access to DB server APIs. * @see CouchDbContext */ public CouchDbContext context() { return context; } /** * Provides access to CouchDB Design Documents. * @see CouchDbDesign */ public CouchDbDesign design() { return design; } /** * Provides access to CouchDB <tt>View</tt> APIs. * @see View */ public View view(String viewId) { return new View(this, viewId); } /** * Provides access to CouchDB <tt>replication</tt> APIs. * @see Replication */ public Replication replication() { return new Replication(this); } /** * Provides access to the <tt>replicator database</tt>. * @see Replicator */ public Replicator replicator() { return new Replicator(this); } /** * Provides access to <tt>Change Notifications</tt> API. * @see Changes */ public Changes changes() { return new Changes(this); } /** * Finds an Object of the specified type. * @param <T> Object type. * @param classType The class of type T. * @param id The document id. * @return An object of type T. * @throws NoDocumentException If the document is not found in the database. */ public <T> T find(Class<T> classType, String id) { assertNotEmpty(classType, "Class"); assertNotEmpty(id, "id"); final URI uri = buildUri(getDBUri()).path(id, true).build(); return get(uri, classType); } /** * Finds an Object of the specified type. * @param <T> Object type. * @param classType The class of type T. * @param id The document id. * @param params Extra parameters to append. * @return An object of type T. * @throws NoDocumentException If the document is not found in the database. */ public <T> T find(Class<T> classType, String id, Params params) { assertNotEmpty(classType, "Class"); assertNotEmpty(id, "id"); final URI uri = buildUri(getDBUri()).path(id, true).query(params).build(); return get(uri, classType); } /** * Finds an Object of the specified type. * @param <T> Object type. * @param classType The class of type T. * @param id The document _id field. * @param rev The document _rev field. * @return An object of type T. * @throws NoDocumentException If the document is not found in the database. */ public <T> T find(Class<T> classType, String id, String rev) { assertNotEmpty(classType, "Class"); assertNotEmpty(id, "id"); assertNotEmpty(id, "rev"); final URI uri = buildUri(getDBUri()).path(id, true).query("rev", rev).build(); return get(uri, classType); } /** * This method finds any document given a URI. * <p>The URI must be URI-encoded. * @param classType The class of type T. * @param uri The URI as string. * @return An object of type T. */ public <T> T findAny(Class<T> classType, String uri) { assertNotEmpty(classType, "Class"); assertNotEmpty(uri, "uri"); return get(URI.create(uri), classType); } /** * Finds a document and return the result as {@link InputStream}. * <p><b>Note</b>: The stream must be closed after use to release the connection. * @param id The document _id field. * @return The result as {@link InputStream} * @throws NoDocumentException If the document is not found in the database. * @see #find(String, String) */ public InputStream find(String id) { assertNotEmpty(id, "id"); return get(buildUri(getDBUri()).path(id).build()); } /** * Finds a document given id and revision and returns the result as {@link InputStream}. * <p><b>Note</b>: The stream must be closed after use to release the connection. * @param id The document _id field. * @param rev The document _rev field. * @return The result as {@link InputStream} * @throws NoDocumentException If the document is not found in the database. */ public InputStream find(String id, String rev) { assertNotEmpty(id, "id"); assertNotEmpty(rev, "rev"); final URI uri = buildUri(getDBUri()).path(id).query("rev", rev).build(); return get(uri); } /** * Find documents using a declarative JSON querying syntax. * @param jsonQuery The JSON query string. * @param classOfT The class of type T. * @return The result of the query as a {@code List<T> } * @throws CouchDbException If the query failed to execute or the request is invalid. */ public <T> List<T> findDocs(String jsonQuery, Class<T> classOfT) { assertNotEmpty(jsonQuery, "jsonQuery"); HttpResponse response = null; try { response = post(buildUri(getDBUri()).path("_find").build(), jsonQuery); Reader reader = new InputStreamReader(getStream(response), Charsets.UTF_8); JsonArray jsonArray = new JsonParser().parse(reader) .getAsJsonObject().getAsJsonArray("docs"); List<T> list = new ArrayList<T>(); for (JsonElement jsonElem : jsonArray) { JsonElement elem = jsonElem.getAsJsonObject(); T t = this.gson.fromJson(elem, classOfT); list.add(t); } return list; } finally { close(response); } } /** * Checks if a document exist in the database. * @param id The document _id field. * @return true If the document is found, false otherwise. */ public boolean contains(String id) { assertNotEmpty(id, "id"); HttpResponse response = null; try { response = head(buildUri(getDBUri()).path(id, true).build()); } catch (NoDocumentException e) { return false; } finally { close(response); } return true; } /** * Saves an object in the database, using HTTP <tt>PUT</tt> request. * <p>If the object doesn't have an <code>_id</code> value, the code will assign a <code>UUID</code> as the document id. * @param object The object to save * @throws DocumentConflictException If a conflict is detected during the save. * @return {@link Response} */ public Response save(Object object) { return put(getDBUri(), object, true); } /** * Saves an object in the database using HTTP <tt>POST</tt> request. * <p>The database will be responsible for generating the document id. * @param object The object to save * @return {@link Response} */ public Response post(Object object) { assertNotEmpty(object, "object"); HttpResponse response = null; try { URI uri = buildUri(getDBUri()).build(); response = post(uri, getGson().toJson(object)); return getResponse(response); } finally { close(response); } } /** * Saves a document with <tt>batch=ok</tt> query param. * @param object The object to save. */ public void batch(Object object) { assertNotEmpty(object, "object"); HttpResponse response = null; try { URI uri = buildUri(getDBUri()).query("batch", "ok").build(); response = post(uri, getGson().toJson(object)); } finally { close(response); } } /** * Updates an object in the database, the object must have the correct <code>_id</code> and <code>_rev</code> values. * @param object The object to update * @throws DocumentConflictException If a conflict is detected during the update. * @return {@link Response} */ public Response update(Object object) { return put(getDBUri(), object, false); } /** * Removes a document from the database. * <p>The object must have the correct <code>_id</code> and <code>_rev</code> values. * @param object The document to remove as object. * @throws NoDocumentException If the document is not found in the database. * @return {@link Response} */ public Response remove(Object object) { assertNotEmpty(object, "object"); JsonObject jsonObject = getGson().toJsonTree(object).getAsJsonObject(); final String id = getAsString(jsonObject, "_id"); final String rev = getAsString(jsonObject, "_rev"); return remove(id, rev); } /** * Removes a document from the database given both a document <code>_id</code> and <code>_rev</code> values. * @param id The document _id field. * @param rev The document _rev field. * @throws NoDocumentException If the document is not found in the database. * @return {@link Response} */ public Response remove(String id, String rev) { assertNotEmpty(id, "id"); assertNotEmpty(rev, "rev"); final URI uri = buildUri(getDBUri()).path(id, true).query("rev", rev).build(); return delete(uri); } /** * Performs a Bulk Documents insert request. * @param objects The {@link List} of objects. * @param allOrNothing Indicates whether the request has <tt>all-or-nothing</tt> semantics. * @return {@code List<Response>} Containing the resulted entries. */ public List<Response> bulk(List<?> objects, boolean allOrNothing) { assertNotEmpty(objects, "objects"); HttpResponse response = null; try { final String allOrNothingVal = allOrNothing ? "\"all_or_nothing\": true, " : ""; final URI uri = buildUri(getDBUri()).path("_bulk_docs").build(); final String json = String.format("{%s%s%s}", allOrNothingVal, "\"docs\": ", getGson().toJson(objects)); response = post(uri, json); return getResponseList(response); } finally { close(response); } } /** * Saves an attachment to a new document with a generated <tt>UUID</tt> as the document id. * <p>To retrieve an attachment, see {@link #find(String)}. * @param instream The {@link InputStream} holding the binary data. * @param name The attachment name. * @param contentType The attachment "Content-Type". * @return {@link Response} */ public Response saveAttachment(InputStream in, String name, String contentType) { assertNotEmpty(in, "in"); assertNotEmpty(name, "name"); assertNotEmpty(contentType, "ContentType"); final URI uri = buildUri(getDBUri()).path(generateUUID()).path("/").path(name).build(); return put(uri, in, contentType); } /** * Saves an attachment to an existing document given both a document id * and revision, or save to a new document given only the id, and rev as {@code null}. * <p>To retrieve an attachment, see {@link #find(String)}. * @param instream The {@link InputStream} holding the binary data. * @param name The attachment name. * @param contentType The attachment "Content-Type". * @param docId The document id to save the attachment under, or {@code null} to save under a new document. * @param docRev The document revision to save the attachment under, or {@code null} when saving to a new document. * @throws DocumentConflictException * @return {@link Response} */ public Response saveAttachment(InputStream in, String name, String contentType, String docId, String docRev) { assertNotEmpty(in, "in"); assertNotEmpty(name, "name"); assertNotEmpty(contentType, "ContentType"); assertNotEmpty(docId, "docId"); final URI uri = buildUri(getDBUri()).path(docId, true).path("/").path(name).query("rev", docRev).build(); return put(uri, in, contentType); } /** * Invokes an Update Handler. * <pre> * String query = "field=foo&value=bar"; * String output = dbClient.invokeUpdateHandler("designDoc/update1", "docId", query); * </pre> * @param updateHandlerUri The Update Handler URI, in the format: <code>designDoc/update1</code> * @param docId The document id to update. * @param query The query string parameters, e.g, <code>field=field1&value=value1</code> * @return The output of the request. */ public String invokeUpdateHandler(String updateHandlerUri, String docId, String query) { assertNotEmpty(updateHandlerUri, "uri"); assertNotEmpty(docId, "docId"); final String[] v = updateHandlerUri.split("/"); final String path = String.format("_design/%s/_update/%s/", v[0], v[1]); final URI uri = buildUri(getDBUri()).path(path).path(docId).query(query).build(); final HttpResponse response = executeRequest(new HttpPut(uri)); return streamToString(getStream(response)); } /** * Invokes an Update Handler. * <p>Use this method in particular when the docId contain special characters such as slashes (/). * <pre> * Params params = new Params() * .addParam("field", "foo") * .addParam("value", "bar"); * String output = dbClient.invokeUpdateHandler("designDoc/update1", "docId", params); * </pre> * @param updateHandlerUri The Update Handler URI, in the format: <code>designDoc/update1</code> * @param docId The document id to update. * @param query The query parameters as {@link Params}. * @return The output of the request. */ public String invokeUpdateHandler(String updateHandlerUri, String docId, Params params) { assertNotEmpty(updateHandlerUri, "uri"); assertNotEmpty(docId, "docId"); final String[] v = updateHandlerUri.split("/"); final String path = String.format("_design/%s/_update/%s/", v[0], v[1]); final URI uri = buildUri(getDBUri()).path(path).path(docId).query(params).build(); final HttpResponse response = executeRequest(new HttpPut(uri)); return streamToString(getStream(response)); } /** * Executes a HTTP request. * <p><b>Note</b>: The response must be closed after use to release the connection. * @param request The HTTP request to execute. * @return {@link HttpResponse} */ public HttpResponse executeRequest(HttpRequestBase request) { try { return httpClient.execute(host, request, createContext()); } catch (IOException e) { request.abort(); throw new CouchDbException("Error executing request. ", e); } } /** * Synchronize all design documents with the database. */ public void syncDesignDocsWithDb() { design().synchronizeAllWithDb(); } /** * Sets a {@link GsonBuilder} to create {@link Gson} instance. * <p>Useful for registering custom serializers/deserializers, such as JodaTime classes. */ public void setGsonBuilder(GsonBuilder gsonBuilder) { this.gson = initGson(gsonBuilder); } /** * @return The base URI. */ public URI getBaseUri() { return baseURI; } /** * @return The database URI. */ public URI getDBUri() { return dbURI; } /** * @return The Gson instance. */ public Gson getGson() { return gson; } // End - Public API /** * Performs a HTTP GET request. * @return {@link InputStream} */ InputStream get(HttpGet httpGet) { HttpResponse response = executeRequest(httpGet); return getStream(response); } /** * Performs a HTTP GET request. * @return {@link InputStream} */ InputStream get(URI uri) { HttpGet get = new HttpGet(uri); get.addHeader("Accept", "application/json"); return get(get); } /** * Performs a HTTP GET request. * @return An object of type T */ <T> T get(URI uri, Class<T> classType) { InputStream in = null; try { in = get(uri); return getGson().fromJson(new InputStreamReader(in, "UTF-8"), classType); } catch (UnsupportedEncodingException e) { throw new CouchDbException(e); } finally { close(in); } } /** * Performs a HTTP HEAD request. * @return {@link HttpResponse} */ HttpResponse head(URI uri) { return executeRequest(new HttpHead(uri)); } /** * Performs a HTTP PUT request, saves or updates a document. * @return {@link Response} */ Response put(URI uri, Object object, boolean newEntity) { assertNotEmpty(object, "object"); HttpResponse response = null; try { final JsonObject json = getGson().toJsonTree(object).getAsJsonObject(); String id = getAsString(json, "_id"); String rev = getAsString(json, "_rev"); if(newEntity) { // save assertNull(rev, "rev"); id = (id == null) ? generateUUID() : id; } else { // update assertNotEmpty(id, "id"); assertNotEmpty(rev, "rev"); } final HttpPut put = new HttpPut(buildUri(uri).path(id, true).build()); setEntity(put, json.toString()); response = executeRequest(put); return getResponse(response); } finally { close(response); } } /** * Performs a HTTP PUT request, saves an attachment. * @return {@link Response} */ Response put(URI uri, InputStream instream, String contentType) { HttpResponse response = null; try { final HttpPut httpPut = new HttpPut(uri); final InputStreamEntity entity = new InputStreamEntity(instream, -1); entity.setContentType(contentType); httpPut.setEntity(entity); response = executeRequest(httpPut); return getResponse(response); } finally { close(response); } } /** * Performs a HTTP POST request. * @return {@link HttpResponse} */ HttpResponse post(URI uri, String json) { HttpPost post = new HttpPost(uri); setEntity(post, json); return executeRequest(post); } /** * Performs a HTTP DELETE request. * @return {@link Response} */ Response delete(URI uri) { HttpResponse response = null; try { HttpDelete delete = new HttpDelete(uri); response = executeRequest(delete); return getResponse(response); } finally { close(response); } } // Helpers /** * Validates a HTTP response; on error cases logs status and throws relevant exceptions. * @param response The HTTP response. */ void validate(HttpResponse response) throws IOException { final int code = response.getStatusLine().getStatusCode(); if(code == 200 || code == 201 || code == 202) { // success (ok | created | accepted) return; } String reason = response.getStatusLine().getReasonPhrase(); switch (code) { case HttpStatus.SC_NOT_FOUND: { throw new NoDocumentException(reason); } case HttpStatus.SC_CONFLICT: { throw new DocumentConflictException(reason); } default: { // other errors: 400 | 401 | 500 etc. throw new CouchDbException(reason += EntityUtils.toString(response.getEntity())); } } } /** * @param response The {@link HttpResponse} * @return {@link Response} */ private Response getResponse(HttpResponse response) throws CouchDbException { InputStreamReader reader = new InputStreamReader(getStream(response), Charsets.UTF_8); return getGson().fromJson(reader, Response.class); } /** * @param response The {@link HttpResponse} * @return {@link Response} */ private List<Response> getResponseList(HttpResponse response) throws CouchDbException { InputStream instream = getStream(response); Reader reader = new InputStreamReader(instream, Charsets.UTF_8); return getGson().fromJson(reader, new TypeToken<List<Response>>(){}.getType()); } /** * Sets a JSON String as a request entity. * @param httpRequest The request to set entity. * @param json The JSON String to set. */ private void setEntity(HttpEntityEnclosingRequestBase httpRequest, String json) { StringEntity entity = new StringEntity(json, "UTF-8"); entity.setContentType("application/json"); httpRequest.setEntity(entity); } /** * Builds {@link Gson} and registers any required serializer/deserializer. * @return {@link Gson} instance */ private Gson initGson(GsonBuilder gsonBuilder) { gsonBuilder.registerTypeAdapter(JsonObject.class, new JsonDeserializer<JsonObject>() { public JsonObject deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { return json.getAsJsonObject(); } }); gsonBuilder.registerTypeAdapter(JsonObject.class, new JsonSerializer<JsonObject>() { public JsonElement serialize(JsonObject src, Type typeOfSrc, JsonSerializationContext context) { return src.getAsJsonObject(); } }); return gsonBuilder.create(); } }