/* * Copyright 2015 Trento Rise (trentorise.eu) * * 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 eu.trentorise.opendata.jackan; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static eu.trentorise.opendata.commons.TodUtils.removeTrailingSlash; import static eu.trentorise.opendata.commons.validation.Preconditions.checkNotEmpty; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; import org.apache.http.HttpHost; import org.apache.http.client.fluent.Request; import org.apache.http.client.fluent.Response; import org.apache.http.client.utils.URIUtils; import org.apache.http.entity.ContentType; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.base.Charsets; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.io.CharStreams; import eu.trentorise.opendata.commons.TodUtils; import eu.trentorise.opendata.jackan.exceptions.CkanException; import eu.trentorise.opendata.jackan.exceptions.CkanNotFoundException; import eu.trentorise.opendata.jackan.exceptions.CkanValidationException; import eu.trentorise.opendata.jackan.exceptions.JackanException; import eu.trentorise.opendata.jackan.model.CkanDataset; import eu.trentorise.opendata.jackan.model.CkanDatasetBase; import eu.trentorise.opendata.jackan.model.CkanDatasetRelationship; import eu.trentorise.opendata.jackan.model.CkanError; import eu.trentorise.opendata.jackan.model.CkanGroup; import eu.trentorise.opendata.jackan.model.CkanGroupOrgBase; import eu.trentorise.opendata.jackan.model.CkanLicense; import eu.trentorise.opendata.jackan.model.CkanOrganization; import eu.trentorise.opendata.jackan.model.CkanPair; import eu.trentorise.opendata.jackan.model.CkanResource; import eu.trentorise.opendata.jackan.model.CkanResourceBase; import eu.trentorise.opendata.jackan.model.CkanResponse; import eu.trentorise.opendata.jackan.model.CkanTag; import eu.trentorise.opendata.jackan.model.CkanTagBase; import eu.trentorise.opendata.jackan.model.CkanUser; import eu.trentorise.opendata.jackan.model.CkanUserBase; import eu.trentorise.opendata.jackan.model.CkanVocabulary; import eu.trentorise.opendata.jackan.model.CkanVocabularyBase; /** * Client to access a ckan instance. Threadsafe. * <p> * The client is a thin wrapper upon Ckan api, thus one method call should * correspond to only one web api call. This means sometimes to get a full * object from Ckan, you will need to do a second call. * </p> * <p> * You can create clients either with constructors or the * {@link CkanClient#builder() builder()} method if you need to set more * connection parameters (i.e. proxy, timeout, ..). * </p> * <p> * For writing to Ckan you might want to use {@link CheckedCkanClient} which * does additional checks to ensure written content is correct. * </p> * * @author David Leoni, Ivan Tankoyeu * */ public class CkanClient { /** * CKAN uses timestamps like '1970-01-01T01:00:00.000010' in UTC timezone, * has precision up to microsecond and doesn't append 'Z' to timestamps. The * format respects * <a href="https://en.wikipedia.org/wiki/ISO_8601" target="_blank">ISO 8601 * standard</a>. In Jackan we store it as {@link java.sql.Timestamp} or * {@code null} if parse is not successful. * * @see #parseTimestamp(java.lang.String) * @see #formatTimestamp(java.sql.Timestamp) * * @since 0.4.1 */ public static final String CKAN_TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"; /** * Found pattern "2013-12-17T00:00:00" in resource.date_modified in * dati.toscana: * http://dati.toscana.it/api/3/action/package_show?id=alluvioni_bacreg See * also <a href="https://github.com/ckan/ckan/issues/1874"> ckan issue * 874 </a> and <a href="https://github.com/ckan/ckan/pull/2519">ckan pull * 2519</a> * * @since 0.4.1 */ public static final String CKAN_NO_MILLISECS_PATTERN = "yyyy-MM-dd'T'HH:mm:ss"; /** * Notice that even for the same api version (at least for versions up to 3 * included) different CKAN instances can behave quite differently, either * for differences in software or custom server permissions. */ public static final ImmutableList<Integer> SUPPORTED_API_VERSIONS = ImmutableList.of(3); /** Default timeout in millisecs */ public static final int DEFAULT_TIMEOUT = 15000; /** * Sometimes we get back Python "None" as a string instead of proper JSON * null * * @since 0.4.1 */ public static final String NONE = "None"; private static final Logger LOG = Logger.getLogger(CkanClient.class.getName()); private static final String COULDNT_JSONIZE = "Couldn't jsonize the provided "; @Nullable private static ObjectMapper objectMapper; private static final Map<String, ObjectMapper> OBJECT_MAPPERS_FOR_POSTING = new HashMap(); private String catalogUrl; @Nullable private String ckanToken; @Nullable private HttpHost proxy; /** connection timeout in millisecs */ private int timeout; @JsonSerialize(as = CkanResourceBase.class) private abstract static class CkanResourceForPosting { } @JsonSerialize(as = CkanDatasetBase.class) private abstract static class CkanDatasetForPosting { } @JsonSerialize(as = CkanGroupOrgBase.class) private abstract static class CkanGroupOrgForPosting { } @JsonSerialize(as = GroupForDatasetPosting.class) abstract static class GroupForDatasetPosting extends CkanGroupOrgBase { @JsonIgnore @Override public List<CkanUser> getUsers() { return super.getUsers(); } } @JsonSerialize(as = CkanUserBase.class) private abstract static class CkanUserForPosting { } @JsonSerialize(as = CkanTagBase.class) private abstract static class CkanTagForPosting { } /** * Configures the provided Jackson ObjectMapper exactly as the internal JSON * mapper used for reading operations. If you want to perform * create/update/delete operations, use * {@link #configureObjectMapperForPosting(com.fasterxml.jackson.databind.ObjectMapper, java.lang.Class) } * instead. * * @param om * a Jackson object mapper * @since 0.4.1 */ public static void configureObjectMapper(ObjectMapper om) { om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); om.registerModule(new JackanModule()); } /** * Configures the provided Jackson ObjectMapper for create/update/delete * operations of Ckan objects. For reading and generic * serialization/deserialization of Ckan objects, use * {@link #configureObjectMapper(com.fasterxml.jackson.databind.ObjectMapper) } * instead. For future compatibility you will need a different object mapper * for each class you want to post to ckan. <b> DO NOT </b> call * {@link #configureObjectMapper(com.fasterxml.jackson.databind.ObjectMapper) } * on the mapper prior to this call. * * @param om * a Jackson object mapper * @param clazz * the class of the objects you wish to create/update/delete. * @since 0.4.1 */ public static void configureObjectMapperForPosting(ObjectMapper om, Class clazz) { configureObjectMapper(om); om.setSerializationInclusion(Include.NON_NULL); om.addMixInAnnotations(CkanResource.class, CkanResourceForPosting.class); om.addMixInAnnotations(CkanDataset.class, CkanDatasetForPosting.class); om.addMixInAnnotations(CkanOrganization.class, CkanGroupOrgForPosting.class); if (CkanDatasetBase.class.isAssignableFrom(clazz)) { // little fix for // https://github.com/opendatatrentino/jackan/issues/19 om.addMixInAnnotations(CkanGroup.class, GroupForDatasetPosting.class); } else { om.addMixInAnnotations(CkanGroup.class, CkanGroupOrgForPosting.class); } om.addMixInAnnotations(CkanUser.class, CkanUserForPosting.class); om.addMixInAnnotations(CkanTag.class, CkanTagForPosting.class); } /** * Retrieves the Jackson object mapper configured for creation/update * operations. Internally, Object mapper is initialized at first call. * * @param clazz * the class you want to post. For generic class, just put * Object.class * @since 0.4.1 */ static ObjectMapper getObjectMapperForPosting(Class clazz) { checkNotNull(clazz, "Invalid class! If you don't know the class just use Object.class"); if (OBJECT_MAPPERS_FOR_POSTING.get(clazz.getName()) == null) { LOG.log(Level.FINE, "Generating ObjectMapper for posting class {0}", clazz); ObjectMapper om = new ObjectMapper(); configureObjectMapperForPosting(om, clazz); OBJECT_MAPPERS_FOR_POSTING.put(clazz.getName(), om); } return OBJECT_MAPPERS_FOR_POSTING.get(clazz.getName()); } /** * Retrieves the Jackson object mapper for reading operations. Internally, * Object mapper is initialized at first call. */ static ObjectMapper getObjectMapper() { if (objectMapper == null) { objectMapper = new ObjectMapper(); configureObjectMapper(objectMapper); } return objectMapper; } /** * The timeout expressed in milliseconds. By default it is * {@link #DEFAULT_TIMEOUT}. */ public int getTimeout() { return timeout; } protected CkanClient() { this.timeout = DEFAULT_TIMEOUT; this.catalogUrl = ""; } /** * Creates a Ckan client with null token * * @param catalogUrl * the catalog url i.e. http://data.gov.uk. Internally, it will * be stored in a normalized format (to avoid i.e. trailing * slashes). */ public CkanClient(String catalogUrl) { this(); checkNotEmpty(catalogUrl, "invalid ckan catalog url"); this.catalogUrl = removeTrailingSlash(catalogUrl); } /** * Creates a Ckan client with null token * * @param catalogUrl * the catalog url i.e. http://data.gov.uk. Internally, it will * be stored in a normalized format (to avoid i.e. trailing * slashes). * @param ckanToken * the token used for authorization in ckan api * */ public CkanClient(String catalogUrl, @Nullable String ckanToken) { this(catalogUrl); this.ckanToken = ckanToken; } /** * Returns a new client builder. * * The builder is not threadsafe and you can use one builder instance to * build only one client instance. * */ public static CkanClient.Builder builder() { return new Builder(new CkanClient()); } /** * Builder for the client. The builder is not threadsafe and you can use one * builder instance to build only one client instance. * * @author David Leoni * */ public static class Builder { private CkanClient client; private boolean created; protected CkanClient getClient() { return client; } protected boolean getCreated() { return created; } protected void checkNotCreated() { if (created) { throw new IllegalStateException("Builder was already used to create a client!"); } } protected Builder(CkanClient client) { checkNotNull(client); this.client = client; this.created = false; } /** * Sets the catalog url i.e. http://data.gov.uk. * * Internally, it will be stored in a normalized format (to avoid i.e. * trailing slashes). */ public Builder setCatalogUrl(String catalogUrl) { checkNotCreated(); checkNotEmpty(catalogUrl, "invalid ckan catalog url"); this.client.catalogUrl = removeTrailingSlash(catalogUrl); return this; } /** * Sets the private token string for ckan repository */ public Builder setCkanToken(@Nullable String ckanToken) { checkNotCreated(); this.client.ckanToken = ckanToken; return this; } /** * Sets the proxy used to perform GET and POST calls */ public Builder setProxy(@Nullable String proxyUrl) { checkNotCreated(); if (proxyUrl == null) { this.client.proxy = null; } else { URI uri; try { uri = new URI(proxyUrl.trim()); } catch (URISyntaxException e) { throw new IllegalArgumentException("Invalid proxy url!", e); } if (!uri.getPath().isEmpty()){ throw new IllegalArgumentException("Proxy host shouldn't have context path! Found instead: " + uri.toString() + " with path " + uri.getPath()); } this.client.proxy = URIUtils.extractHost(uri); } return this; } /** * Sets the connection timeout expressed as number of milliseconds. Must * be greater than zero, otherwise IllegalArgumentException is thrown. * * @throws IllegalArgumentException * is value is less than 1. */ public Builder setTimeout(int timeout) { checkNotCreated(); checkArgument(timeout > 0, "Timeout must be > 0 ! Found instead %s", timeout); this.client.timeout = timeout; return this; } public CkanClient build() { checkNotEmpty(this.client.catalogUrl, "Invalid catalog url!"); this.created = true; return this.client; } } @Override public String toString() { String maskedToken = ckanToken == null ? null : "*****MASKED_TOKEN*******"; return "CkanClient{" + "catalogURL=" + catalogUrl + ", ckanToken=" + maskedToken + '}'; } /** * Calculates a full url out of the provided params * * @param path * something like /api/3/package_show * @param params * list of key, value parameters. They must be not be url * encoded. i.e. "id","laghi-monitorati-trento" * @return the full url to be called. * @throws JackanException * if there is any error building the url */ private String calcFullUrl(String path, Object[] params) { checkNotNull(path); try { StringBuilder sb = new StringBuilder().append(catalogUrl) .append(path); for (int i = 0; i < params.length; i += 2) { sb.append(i == 0 ? "?" : "&") .append(URLEncoder.encode(params[i].toString(), "UTF-8")) .append("=") .append(URLEncoder.encode(params[i + 1].toString(), "UTF-8")); } return sb.toString(); } catch (Exception ex) { throw new JackanException("Error while building url to perform GET! \n path: " + path + " \n params: " + Arrays.toString(params), ex); } } /** * Configures the request. Should work both for GETs and POSTs. */ protected Request configureRequest(Request request) { if (ckanToken != null) { request.addHeader("Authorization", ckanToken); } if (proxy != null) { request.viaProxy(proxy); } request.socketTimeout(this.timeout) .connectTimeout(this.timeout); return request; } /** * Performs HTTP GET on server. If {@link CkanResponse#isSuccess()} is false * throws {@link CkanException}. * * @param <T> * @param responseType * a descendant of CkanResponse * @param path * something like /api/3/package_show * @param params * list of key, value parameters. They must be not be url * encoded. i.e. "id","laghi-monitorati-trento" * @throws CkanException * on error */ private <T extends CkanResponse> T getHttp(Class<T> responseType, String path, Object... params) { checkNotNull(responseType); checkNotNull(path); String fullUrl = calcFullUrl(path, params); T ckanResponse; String returnedText; try { LOG.log(Level.FINE, "getting {0}", fullUrl); Request request = Request.Get(fullUrl); configureRequest(request); Response response = request.execute(); InputStream stream = response.returnResponse() .getEntity() .getContent(); try (InputStreamReader reader = new InputStreamReader(stream, Charsets.UTF_8)) { returnedText = CharStreams.toString(reader); } } catch (Exception ex) { throw new CkanException("Error while performing GET. Request url was: " + fullUrl, this, ex); } try { ckanResponse = getObjectMapper().readValue(returnedText, responseType); } catch (Exception ex) { throw new CkanException( "Couldn't interpret json returned by the server! Returned text was: " + returnedText, this, ex); } if (!ckanResponse.isSuccess()) { throwCkanException("Error while performing GET. Request url was: " + fullUrl, ckanResponse); } return ckanResponse; } /** * Throws CkanException or a subclass of it according to CkanError#getType() * * @throws CkanException * @since 0.4.1 */ protected <T extends CkanResponse> void throwCkanException(String msg, T ckanResponse) { if (ckanResponse.getError() != null && ckanResponse.getError() .getType() != null) { switch (ckanResponse.getError() .getType()) { case CkanError.NOT_FOUND_ERROR: throw new CkanNotFoundException(msg, ckanResponse, this); case CkanError.VALIDATION_ERROR: throw new CkanValidationException(msg, ckanResponse, this); } } throw new CkanException(msg, ckanResponse, this); } /** * * POSTs a body via HTTP. If {@link CkanResponse#isSuccess()} is false * throws {@link CkanException}. * * @param responseType * a descendant of CkanResponse * @param path * something like /api/3/action/package_create * @param body * the body of the POST * @param contentType * @param params * list of key, value parameters. They must be not be url * encoded. i.e. "id","laghi-monitorati-trento" * @throws CkanException * on error */ private <T extends CkanResponse> T postHttp(Class<T> responseType, String path, String body, ContentType contentType, Object... params) { checkNotNull(responseType); checkNotNull(path); checkNotNull(body); checkNotNull(contentType); String fullUrl = calcFullUrl(path, params); T ckanResponse; String returnedText; try { LOG.log(Level.FINE, "Posting to url {0}", fullUrl); LOG.log(Level.FINE, "Sending body:{0}", body); Request request = Request.Post(fullUrl); configureRequest(request); Response response = request.bodyString(body, contentType) .execute(); InputStream stream = response.returnResponse() .getEntity() .getContent(); try (InputStreamReader reader = new InputStreamReader(stream, Charsets.UTF_8)) { returnedText = CharStreams.toString(reader); } } catch (Exception ex) { throw new CkanException("Error while performing a POST! Request url is:" + fullUrl, this, ex); } try { ckanResponse = getObjectMapper().readValue(returnedText, responseType); } catch (Exception ex) { throw new CkanException( "Couldn't interpret json returned by the server! Returned text was: " + returnedText, this, ex); } if (!ckanResponse.isSuccess()) { throwCkanException("Error while performing a POST! Request url is:" + fullUrl, ckanResponse); } return ckanResponse; } /** * Returns the catalog URL (normalized). */ public String getCatalogUrl() { return catalogUrl; } /** * Returns the private CKAN token. */ public String getCkanToken() { return ckanToken; } private static void checkCatalogUrl(String catalogUrl) { checkNotEmpty(catalogUrl, "invalid catalog url"); } /** * Returns the URL of dataset page in the catalog website. * * Valid URLs have this format with the name: * http://dati.trentino.it/dataset/impianti-di-risalita-vivifiemme-2013 * * @param datasetIdOrName * either the dataset's {@link CkanDataset#getId() alphanumerical * id} (preferred as it is more stable) or the * {@link CkanDataset#getName() dataset name} * * @param catalogUrl * i.e. http://dati.trentino.it */ public static String makeDatasetUrl(String catalogUrl, String datasetIdOrName) { checkCatalogUrl(catalogUrl); checkNotEmpty(datasetIdOrName, "invalid dataset identifier"); return removeTrailingSlash(catalogUrl) + "/dataset/" + datasetIdOrName; } /** * * Returns the URL of resource page in the catalog website. * * Valid URLs have this format with the dataset name * 'impianti-di-risalita-vivifiemme-2013': * http://dati.trentino.it/dataset/impianti-di-risalita-vivifiemme-2013/ * resource/779d1d9d-9370-47f4-a194-1b0328c32f02 * * @param catalogUrl * i.e. http://dati.trentino.it * @param datasetIdOrName * either the dataset's alphanumerical {@link CkanDataset#getId() * id} (preferred as it is more stable) or the * {@link CkanDataset#getName() dataset name} * * @param resourceId * the {@link CkanResource#getId() alphanumerical id} of the * resource (DON'T use {@link CkanResource#getName() resource * name}) */ public static String makeResourceUrl(String catalogUrl, String datasetIdOrName, String resourceId) { checkCatalogUrl(catalogUrl); checkNotEmpty(datasetIdOrName, "invalid dataset identifier"); checkNotEmpty(resourceId, "invalid resource id"); return TodUtils.removeTrailingSlash(catalogUrl) + "/" + datasetIdOrName + "/resource/" + resourceId; } /** * * Given some group parameters, reconstruct the URL of group page in the * catalog website. * * Valid URLs have this format with the group name * 'gestione-del-territorio': * * http://dati.trentino.it/group/gestione-del-territorio * * @param catalogUrl * i.e. http://dati.trentino.it * @param groupNameOrId * the group name as in {@link CkanGroup#getName()} (preferred), * or the group's alphanumerical id. */ public static String makeGroupUrl(String catalogUrl, String groupNameOrId) { checkCatalogUrl(catalogUrl); checkNotEmpty(groupNameOrId, "invalid group identifier"); return TodUtils.removeTrailingSlash(catalogUrl) + "/group/" + groupNameOrId; } /** * * Given some organization parameters, reconstruct the URL of organization * page in the catalog website. * * Valid URLs have this format with the organization name * 'comune-di-trento': * * http://dati.trentino.it/organization/comune-di-trento * * @param catalogUrl * i.e. http://dati.trentino.it * @param orgNameOrId * the group name as in {@link CkanOrganization#getName()} * (preferred), or the group's alphanumerical id. */ public static String makeOrganizationUrl(String catalogUrl, String orgNameOrId) { checkCatalogUrl(catalogUrl); checkNotEmpty(orgNameOrId, "invalid organization identifier"); return TodUtils.removeTrailingSlash(catalogUrl) + "/organization/" + orgNameOrId; } /** * Returns list of dataset names like i.e. limestone-pavement-orders * * @throws JackanException * on error */ public synchronized List<String> getDatasetList() { return getHttp(DatasetListResponse.class, "/api/3/action/package_list").result; } /** * * @param limit * @param offset * Starts with 0 included. getDatasetList(1,0) will return * exactly one dataset, if catalog is not empty. * @return list of data names like i.e. limestone-pavement-orders * @throws JackanException * on error */ public synchronized List<String> getDatasetList(int limit, int offset) { return getHttp(DatasetListResponse.class, "/api/3/action/package_list", "limit", limit, "offset", offset).result; } /** * Returns the list of available licenses in the ckan catalog. */ public synchronized List<CkanLicense> getLicenseList() { return getHttp(LicenseListResponse.class, "/api/3/action/license_list").result; } /** * Returns the latest api version supported by the catalog * * @throws JackanException * on error */ public synchronized int getApiVersion() { for (int i = 5; i >= 1; i--) { // this is demential. But /api always // gives { "version": 1} .... try { return getApiVersion(i); } catch (Exception ex) { } } throw new CkanException("Error while getting api version!", this); } /** * Returns the given api number * * @throws JackanException * on error */ private synchronized int getApiVersion(int number) { String fullUrl = catalogUrl + "/api/" + number; LOG.log(Level.FINE, "getting {0}", fullUrl); try { Request request = Request.Get(fullUrl); configureRequest(request); String json = request.execute() .returnContent() .asString(); return getObjectMapper().readValue(json, ApiVersionResponse.class).version; } catch (Exception ex) { throw new CkanException("Error while fetching api version!", this, ex); } } /** * Fetches the dataset from ckan. Returned dataset will have resources with * at least all of the fields of {@link CkanResourceBase} * * @param idOrName * either the dataset name (i.e. certified-products) or the * alphanumerical id (i.e. 22eea137-9fc3-4222-a716-bac22cc2039a) * * @throws JackanException * on error */ public synchronized CkanDataset getDataset(String idOrName) { checkNotNull(idOrName, "Need a valid id or name!"); CkanDataset cd = getHttp(DatasetResponse.class, "/api/3/action/package_show", "id", idOrName).result; for (CkanResource cr : cd.getResources()) { cr.setPackageId(cd.getId()); } return cd; } /** * @throws JackanException * on error */ public synchronized List<CkanUser> getUserList() { return getHttp(UserListResponse.class, "/api/3/action/user_list").result; } /** * @param id * i.e. 'admin' * @throws JackanException * on error */ public synchronized CkanUser getUser(String id) { checkNotNull(id, "Need a valid id!"); return getHttp(UserResponse.class, "/api/3/action/user_show", "id", id).result; } /** * Creates ckan user on the server. * * @param user * ckan user object with the minimal set of parameters required. * See * {@link CkanUserBase#CkanUserBase(java.lang.String, java.lang.String, java.lang.String) * this constructor} * @return the newly created user * @throws JackanException */ public synchronized CkanUser createUser(CkanUserBase user) { checkNotNull(user, "Need a valid user!"); checkToken("Tried to create user" + user.getName()); ObjectMapper om = CkanClient.getObjectMapperForPosting(CkanUserBase.class); String json = null; try { json = om.writeValueAsString(user); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + user.getClass() .getSimpleName(), this, e); } return postHttp(UserResponse.class, "/api/3/action/user_create", json, ContentType.APPLICATION_JSON).result; } /** * @param id * The alphanumerical id of the resource, such as * d0892ada-b8b9-43b6-81b9-47a86be126db. * * @throws JackanException * on error */ public synchronized CkanResource getResource(String id) { checkNotNull(id, "Need a valid id!"); return getHttp(ResourceResponse.class, "/api/3/action/resource_show", "id", id).result; } /** * Creates ckan resource on the server. * * @param resource * ckan resource object with the minimal set of parameters * required. See * {@link CkanResource#CkanResource(String, String)} * @return the newly created resource * @throws JackanException * @since 0.4.1 */ public synchronized CkanResource createResource(CkanResourceBase resource) { checkNotNull(resource, "Need a valid resource!"); checkToken("Tried to create resource " + resource.getName()); ObjectMapper om = CkanClient.getObjectMapperForPosting(CkanResourceBase.class); String json = null; try { json = om.writeValueAsString(resource); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + resource.getClass() .getSimpleName(), this, e); } return postHttp(ResourceResponse.class, "/api/3/action/resource_create", json, ContentType.APPLICATION_JSON).result; } /** * Updates a resource on the server using a straight {@code resource_update} * call. Null fields will not be sent and thus won't get updated, but be * careful about custom fields of {@link CkanResourceBase#getOthers()}, if * not sent they will be erased on the server! To prevent this behaviour, * see {@link #patchUpdateResource(CkanResourceBase) } * * @throws CkanException * on error * @since 0.4.1 */ public synchronized CkanResource updateResource(CkanResourceBase resource) { checkNotNull(resource, "Need a valid resource!"); checkToken("Tried to update resource" + resource.getName()); String json = null; try { json = getObjectMapperForPosting(CkanResourceBase.class).writeValueAsString(resource); } catch (IOException ex) { throw new CkanException(COULDNT_JSONIZE + resource.getClass() .getSimpleName(), this, ex); } return postHttp(ResourceResponse.class, "/api/3/action/resource_update", json, ContentType.APPLICATION_JSON).result; } /** * Jackan specific. Patches a resource on the ckan server using a * {@code resource_update} call. Todo: this is a temporary solution until we * implement new {@code patch} api of CKAN 2.3 * * @param resource * ckan resource object. Fields set to {@code null} won't be * updated on the server. Items present in lists such as * {@link CkanResourceBase#getOthers() others} will be added to * existing ones on the server. To support this behaviour * provided {@code resource} might be patched with latest * metadata from the server prior sending it for update. * * @see #updateResource(CkanResourceBase) * @throws CkanException * on error * @since 0.4.1 */ public synchronized CkanResource patchUpdateResource(CkanResourceBase resource) { checkNotNull(resource, "Need a valid resource!"); checkToken("Tried to update resource" + resource.getName()); CkanResource origResource = getResource(resource.getId()); // others Map<String, Object> newOthers = new HashMap(); if (origResource.getOthers() != null) { newOthers.putAll(origResource.getOthers()); } if (resource.getOthers() != null) { newOthers.putAll(resource.getOthers()); } resource.setOthers(newOthers); String json = null; try { json = getObjectMapperForPosting(CkanResourceBase.class).writeValueAsString(resource); } catch (IOException ex) { throw new CkanException(COULDNT_JSONIZE + resource.getClass() .getSimpleName(), this, ex); } return postHttp(ResourceResponse.class, "/api/3/action/resource_update", json, ContentType.APPLICATION_JSON).result; } /** * * Marks a resource as 'deleted'. * * Note this will just set resource state to * {@link eu.trentorise.opendata.jackan.model.CkanState#deleted} and make it * inaccessible from the website, but you will still be able to get the * resource with the web api. * * @param id * The alphanumerical id of the resource, such as * d0892ada-b8b9-43b6-81b9-47a86be126db. * * @throws CkanException * on error * @since 0.4.1 */ public synchronized void deleteResource(String id) { checkNotNull(id, "Need a valid id!"); checkToken("Tried to delete resource with id " + id); String json = "{\"id\":\"" + id + "\"}"; postHttp(ResourceResponse.class, "/api/3/action/resource_delete", json, ContentType.APPLICATION_JSON); } /** * Returns the groups present in Ckan. * * Notice that organizations will <i>not</i> be returned by this method. To * get them, use {@link #getOrganizationList() } instead. * * @throws JackanException * on error */ public synchronized List<CkanGroup> getGroupList() { return getHttp(GroupListResponse.class, "/api/3/action/group_list", "all_fields", "True").result; } /** * Return group names, like i.e. management-of-territory * * @throws JackanException * on error */ public synchronized List<String> getGroupNames() { return getHttp(GroupNamesResponse.class, "/api/3/action/group_list").result; } /** * Returns a Ckan group. Do not pass an organization id, to get organization * use {@link #getOrganization(java.lang.String) } instead. * * @param idOrName * either the group name (i.e. hospitals-in-trento-district) or * the group alphanumerical id (i.e. * 55bb5fbd-7a7c-4eb8-8b1a-1192a5504421) * @throws JackanException * on error */ public synchronized CkanGroup getGroup(String idOrName) { checkNotNull(idOrName, "Need a valid id or name!"); return getHttp(GroupResponse.class, "/api/3/action/group_show", "id", idOrName, "include_datasets", "false").result; } /** * Returns the organizations present in CKAN. * * @see #getGroupList() * * @throws JackanException * on error */ public synchronized List<CkanOrganization> getOrganizationList() { return getHttp(OrganizationListResponse.class, "/api/3/action/organization_list", "all_fields", "True").result; } /** * Returns all the resource formats available in the catalog. * * @throws JackanException * on error */ public synchronized Set<String> getFormats() { return getHttp(FormatListResponse.class, "/api/3/action/format_autocomplete", "q", "", "limit", "1000").result; } /** * @throws JackanException * on error */ public synchronized List<String> getOrganizationNames() { return getHttp(GroupNamesResponse.class, "/api/3/action/organization_list").result; } /** * Returns a Ckan organization. * * @param idOrName * either the name of organization (i.e. culture-and-education) * or the alphanumerical id (i.e. * 232cad97-ecf2-447d-9656-63899023887f). Do not pass it a group * id. * @throws JackanException * on error */ public synchronized CkanOrganization getOrganization(String idOrName) { checkNotNull(idOrName, "Need a valid id or name!"); return getHttp(OrganizationResponse.class, "/api/3/action/organization_show", "id", idOrName, "include_datasets", "false").result; } /** * Creates CkanTag on the server. * * @param tag * Ckan tag without id * @return the newly created tag * @throws JackanException */ public synchronized CkanTag createTag(CkanTagBase tag) { checkNotNull(tag, "Need a valid tag!"); checkToken("Tried to create tag" + tag.getName()); String json = null; try { json = getObjectMapperForPosting(CkanTagBase.class).writeValueAsString(tag); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + tag.getClass() .getSimpleName(), this, e); } TagResponse response = postHttp(TagResponse.class, "/api/3/action/tag_create", json, ContentType.APPLICATION_JSON); return response.result; } /** * Returns a list of tags names, i.e. "gp-practice-earnings","Aid Project * Evaluation", "tourism-satellite-account". We think names SHOULD be * lowercase with minuses instead of spaces, but in some cases they aren't. * * @throws JackanException * on error */ public synchronized List<CkanTag> getTagList() { return getHttp(TagListResponse.class, "/api/3/action/tag_list", "all_fields", "True").result; } /** * Returns tags containing the string given in query. * * @param query * @throws JackanException * on error */ public synchronized List<String> getTagNamesList(String query) { checkNotNull(query, "Need a valid query!"); return getHttp(TagNamesResponse.class, "/api/3/action/tag_list", "query", query).result; } /** * @throws JackanException * on error */ public synchronized List<String> getTagNamesList() { return getHttp(TagNamesResponse.class, "/api/3/action/tag_list").result; } /** * Creates CkanVocabulary on the server. * * @param vocabulary * Ckan vocabulary without id * @return the newly created vocabulary * @throws JackanException */ public synchronized CkanVocabulary createVocabulary(CkanVocabularyBase vocabulary) { checkNotNull(vocabulary, "Need a valid vocabulary!"); checkToken("Tried to create vocabulary" + vocabulary.getName()); String json = null; try { json = getObjectMapperForPosting(CkanVocabularyBase.class).writeValueAsString(vocabulary); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + vocabulary.getClass() .getSimpleName(), this, e); } VocabularyResponse response = postHttp(VocabularyResponse.class, "/api/3/action/vocabulary_create", json, ContentType.APPLICATION_JSON); return response.result; } /** * Search datasets containing provided text in the metadata * * @param text * The query string * @param limit * maximum results to return * @param offset * search begins from offset. Starts from 0, so that offset 0 * limit 1 returns exactly 1 result, if there is a matching * dataset) * @throws JackanException * on error */ public synchronized SearchResults<CkanDataset> searchDatasets(String text, int limit, int offset) { return searchDatasets(CkanQuery.filter() .byText(text), limit, offset); } /** * @param fqPrefix * either "" or " AND " * @param list * list of names of ckan objects */ private static String appendNamesList(String fqPrefix, String key, List<String> list, StringBuilder fq) { checkNotNull(fqPrefix, "Need a valid prefix!"); checkNotNull(key, "Need a valid key!"); checkNotNull(list, "Need a valid list!"); checkNotNull(fq, "Need a valid string builder!"); if (list.size() > 0) { fq.append(fqPrefix) .append("("); String prefix = ""; for (String n : list) { fq.append(prefix) .append(key) .append(":"); fq.append('"' + n + '"'); prefix = " AND "; } fq.append(")"); return " AND "; } else { return ""; } } /** * Parses a Ckan timestamp into a Java Timestamp. * * @throws IllegalArgumentException * if timestamp can't be parsed. * @see #formatTimestamp(java.sql.Timestamp) for the inverse process. * @since 0.4.1 */ public static Timestamp parseTimestamp(String timestamp) { if (timestamp == null) { throw new IllegalArgumentException("Found null timestamp!"); } if (NONE.equals(timestamp)) { throw new IllegalArgumentException("Found timestamp with 'None' inside!"); } return Timestamp.valueOf(timestamp.replace("T", " ")); } /** * Formats a timestamp according to {@link #CKAN_TIMESTAMP_PATTERN}, with * precision up to microseconds. * * @see #parseTimestamp(java.lang.String) for the inverse process. * @since 0.4.1 */ @Nullable public static String formatTimestamp(Timestamp timestamp) { if (timestamp == null) { throw new IllegalArgumentException("Found null timestamp!"); } Timestamp ret = Timestamp.valueOf(timestamp.toString()); ret.setNanos((timestamp.getNanos() / 1000) * 1000); return Strings.padEnd(ret.toString() .replace(" ", "T"), "1970-01-01T01:00:00.000001".length(), '0'); } /** * @params s a string to encode in a format suitable for URLs. */ private static String urlEncode(String s) { try { return URLEncoder.encode(s, "UTF-8") .replaceAll("\\+", "%20"); } catch (UnsupportedEncodingException ex) { throw new JackanException("Unsupported encoding", ex); } } /** * Search datasets according to the provided query. * * @param query * The query object * @param limit * maximum results to return * @param offset * search begins from offset * @throws CkanException * on error */ public synchronized SearchResults<CkanDataset> searchDatasets(CkanQuery query, int limit, int offset) { checkNotNull(query, "Need a valid query!"); StringBuilder params = new StringBuilder(); params.append("rows=") .append(limit) .append("&start=") .append(offset); if (query.getText() .length() > 0) { params.append("&q="); params.append(urlEncode(query.getText())); } StringBuilder fq = new StringBuilder(); String fqPrefix = ""; fqPrefix = appendNamesList(fqPrefix, "groups", query.getGroupNames(), fq); fqPrefix = appendNamesList(fqPrefix, "organization", query.getOrganizationNames(), fq); fqPrefix = appendNamesList(fqPrefix, "tags", query.getTagNames(), fq); fqPrefix = appendNamesList(fqPrefix, "license_id", query.getLicenseIds(), fq); if (fq.length() > 0) { params.append("&fq=") .append(urlEncode(fq.insert(0, "(") .append(")") .toString())); } DatasetSearchResponse dsr; dsr = getHttp(DatasetSearchResponse.class, "/api/3/action/package_search?" + params.toString()); for (CkanDataset ds : dsr.result.getResults()) { for (CkanResource cr : ds.getResources()) { cr.setPackageId(ds.getId()); } } return dsr.result; } /** * @msg the prepended error message. * @throws CkanException */ private void checkToken(@Nullable String prependedErrorMessage) { if (ckanToken == null) { throw new CkanException(String.valueOf(prependedErrorMessage) + ", but ckan token was not set!", this); } } /** * Creates CkanDataset on the server. Will also create eventual resources * present in the dataset. * * @param dataset * Ckan dataset without id * @return the newly created dataset * @throws CkanException * @since 0.4.1 */ public synchronized CkanDataset createDataset(CkanDatasetBase dataset) { checkNotNull(dataset, "Need a valid dataset!"); checkToken("Tried to create dataset" + dataset.getName()); String json = null; try { json = getObjectMapperForPosting(CkanDatasetBase.class).writeValueAsString(dataset); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + dataset.getClass() .getSimpleName(), this, e); } DatasetResponse response = postHttp(DatasetResponse.class, "/api/3/action/package_create", json, ContentType.APPLICATION_JSON); return response.result; } /** * Updates a dataset on the ckan server using a straight * {@code package_update} call. Null fields will not be sent and thus won't * get updated, but be careful about list fields, if not sent they will be * erased on the server! To prevent this behaviour, see * {@link #patchUpdateDataset(CkanDatasetBase)} * * @throws CkanException * on error * @since 0.4.1 */ public synchronized CkanDataset updateDataset(CkanDatasetBase dataset) { checkNotNull(dataset, "Need a valid dataset!"); checkToken("Tried to update dataset" + dataset.getName()); String json = null; try { json = getObjectMapperForPosting(CkanDatasetBase.class).writeValueAsString(dataset); } catch (IOException ex) { throw new CkanException(COULDNT_JSONIZE + dataset.getClass() .getSimpleName(), this, ex); } return postHttp(DatasetResponse.class, "/api/3/action/package_update", json, ContentType.APPLICATION_JSON).result; } public static List<CkanPair> extrasMapToList(Map<String, String> map) { ArrayList ret = new ArrayList(); for (String key : map.keySet()) { ret.add(new CkanPair(key, map.get(key))); } return ret; } private void mergeResources(@Nullable List<CkanResource> resourcesToMerge, List<CkanResource> targetResources) { if (resourcesToMerge != null) { for (CkanResource resourceToMerge : resourcesToMerge) { boolean replaced = false; for (int i = 0; i < targetResources.size(); i++) { CkanResource targetRes = targetResources.get(i); if (resourceToMerge.getId() != null && resourceToMerge.getId() .equals(targetRes.getId())) { targetResources.set(i, resourceToMerge); replaced = true; break; } } if (!replaced) { targetResources.add(resourceToMerge); } } } } private void mergeGroups(@Nullable List<CkanGroup> groupsToMerge, List<CkanGroup> targetGroups) { if (groupsToMerge != null) { for (CkanGroup groupToMerge : groupsToMerge) { boolean replaced = false; for (int i = 0; i < targetGroups.size(); i++) { CkanGroup targetRes = targetGroups.get(i); if (groupToMerge.getId() != null && groupToMerge.getId() .equals(targetRes.getId())) { targetGroups.set(i, groupToMerge); replaced = true; break; } } if (!replaced) { targetGroups.add(groupToMerge); } } } } private void mergeRelationships(@Nullable List<CkanDatasetRelationship> relationshipsToMerge, List<CkanDatasetRelationship> targetDatasetRelationships) { if (relationshipsToMerge != null) { for (CkanDatasetRelationship relationshipToMerge : relationshipsToMerge) { boolean replaced = false; for (int i = 0; i < targetDatasetRelationships.size(); i++) { CkanDatasetRelationship targetRes = targetDatasetRelationships.get(i); if (relationshipToMerge.getId() != null && relationshipToMerge.getId() .equals(targetRes.getId())) { targetDatasetRelationships.set(i, relationshipToMerge); replaced = true; break; } } if (!replaced) { targetDatasetRelationships.add(relationshipToMerge); } } } } private void mergeTags(@Nullable List<CkanTag> tagsToMerge, List<CkanTag> targetTags) { if (tagsToMerge != null) { for (CkanTag tagToMerge : tagsToMerge) { boolean replaced = false; for (int i = 0; i < targetTags.size(); i++) { CkanTag targetRes = targetTags.get(i); if (tagToMerge.getId() != null && tagToMerge.getId() .equals(targetRes.getId())) { targetTags.set(i, tagToMerge); replaced = true; break; } } if (!replaced) { targetTags.add(tagToMerge); } } } } /** * Jackan specific. Patches a dataset on the ckan server using a * {@code package_update} call. Todo: this is a temporary solution until we * implement new {@code patch} api of CKAN 2.3 * * @param dataset * ckan dataset object. Fields set to {@code null} won't be * updated on the server. Items present in lists such as * {@code resources} or {@code extras} will be added to existing * ones on the server. To support this behaviour provided * {@code dataset} might be patched with latest metadata from the * server prior sending it for update. * * @throws CkanException * on error * @since 0.4.1 */ public synchronized CkanDataset patchUpdateDataset(CkanDatasetBase dataset) { checkNotNull(dataset, "Need a valid dataset!"); checkToken("Tried to patch update dataset" + dataset.getName()); CkanDataset origDataset = getDataset(dataset.idOrName()); // others Map<String, Object> newOthers = new HashMap(); if (origDataset.getOthers() != null) { newOthers.putAll(origDataset.getOthers()); } if (dataset.getOthers() != null) { newOthers.putAll(dataset.getOthers()); } dataset.setOthers(newOthers); // extras if (dataset.getExtras() == null) { dataset.setExtras(origDataset.getExtras()); } else { Map<String, String> newExtras = new HashMap(); if (origDataset.getExtras() != null) { newExtras.putAll(origDataset.getExtrasAsHashMap()); } if (dataset.getExtras() != null) { newExtras.putAll(dataset.getExtrasAsHashMap()); } dataset.setExtras(extrasMapToList(newExtras)); } // resources List<CkanResource> newResources = new ArrayList(); mergeResources(origDataset.getResources(), newResources); mergeResources(dataset.getResources(), newResources); dataset.setResources(newResources); // groups List<CkanGroup> newGroups = new ArrayList(); mergeGroups(origDataset.getGroups(), newGroups); mergeGroups(dataset.getGroups(), newGroups); dataset.setGroups(newGroups); // tags List<CkanTag> newTags = new ArrayList(); mergeTags(origDataset.getTags(), newTags); mergeTags(dataset.getTags(), newTags); dataset.setTags(newTags); // relationships as subject List<CkanDatasetRelationship> newRelationshipsAsSubject = new ArrayList(); mergeRelationships(origDataset.getRelationshipsAsSubject(), newRelationshipsAsSubject); mergeRelationships(dataset.getRelationshipsAsSubject(), newRelationshipsAsSubject); dataset.setRelationshipsAsSubject(newRelationshipsAsSubject); // relationships as object List<CkanDatasetRelationship> newRelationshipsAsObject = new ArrayList(); mergeRelationships(origDataset.getRelationshipsAsObject(), newRelationshipsAsObject); mergeRelationships(dataset.getRelationshipsAsObject(), newRelationshipsAsObject); dataset.setRelationshipsAsObject(newRelationshipsAsObject); String json = null; try { json = getObjectMapperForPosting(CkanDatasetBase.class).writeValueAsString(dataset); } catch (IOException ex) { throw new JackanException(COULDNT_JSONIZE + dataset.getClass() .getSimpleName(), ex); } return postHttp(DatasetResponse.class, "/api/3/action/package_update", json, ContentType.APPLICATION_JSON).result; } /** * Marks a dataset as 'deleted'. * * Note this will just set dataset state to * {@link eu.trentorise.opendata.jackan.model.CkanState#deleted} and make it * inaccessible from the website, but you will still be able to get the * dataset with the web api. Resources contained within will still be * 'active'. * * @param nameOrId * either the dataset name (i.e. apple-production) or the the * alphanumerical id (i.e. fe507a10-4c49-4b18-8bf6-6705198cfd42) * * @throws CkanException * on error */ public synchronized void deleteDataset(String nameOrId) { checkNotNull(nameOrId, "Need a valid name or id!"); checkToken("Tried to delete dataset" + nameOrId); String json = "{\"id\":\"" + nameOrId + "\"}"; postHttp(CkanResponse.class, "/api/3/action/package_delete", json, ContentType.APPLICATION_JSON); } /** * Creates CkanOrganization on the server. * * @param organization * requires at least the name or id. Only non-null fields of * {@link CkanGroupOrgBase} will be sent to server. * @return a new object with the created organization. * @throws CkanException * on error. * @since 0.4.1 */ public synchronized CkanOrganization createOrganization(CkanOrganization organization) { checkNotNull(organization, "Need a valid " + organization + "!"); checkToken("Tried to create organization " + organization.getName()); String json = null; try { json = getObjectMapperForPosting(CkanOrganization.class).writeValueAsString(organization); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + organization.getClass() .getSimpleName(), this, e); } return postHttp(OrganizationResponse.class, "/api/3/action/organization_create", json, ContentType.APPLICATION_JSON).result; } /** * Creates CkanGroup on the server. * * @param group * requires at least the name or id. Only non-null fields of * {@link CkanGroupOrgBase} will be sent to server. * @return a new object with the created group. * @throws CkanException * on error. * @since 0.4.1 */ public synchronized CkanGroup createGroup(CkanGroup group) { checkNotNull(group, "Need a valid " + group + "!"); checkToken("Tried to create group " + group.idOrName()); String json = null; try { json = getObjectMapperForPosting(CkanGroup.class).writeValueAsString(group); } catch (IOException e) { throw new CkanException(COULDNT_JSONIZE + group.getClass() .getSimpleName(), this, e); } return postHttp(GroupResponse.class, "/api/3/action/group_create", json, ContentType.APPLICATION_JSON).result; } /** * Returns the proxy used by the client. * * @since 0.4.1 */ @Nullable public String getProxy() { return proxy.toURI(); } /** * Convenience method to create a Builder with provided client to modify. * <p> * <strong>WARNING:</strong> The passed client will be modified, so * <strong> DO NOT </strong> pass an already built client. * </p> * <p> * The builder is not threadsafe and you can use one builder instance to * build only one client instance. * </p> */ protected static CkanClient.Builder newBuilder(CkanClient client) { return new Builder(client); } } class DatasetResponse extends CkanResponse { public CkanDataset result; } class ResourceResponse extends CkanResponse { public CkanResource result; } class DatasetListResponse extends CkanResponse { public List<String> result; } class UserListResponse extends CkanResponse { public List<CkanUser> result; } class UserResponse extends CkanResponse { public CkanUser result; } class TagListResponse extends CkanResponse { public List<CkanTag> result; } class OrganizationResponse extends CkanResponse { public CkanOrganization result; } class GroupResponse extends CkanResponse { public CkanGroup result; } class OrganizationListResponse extends CkanResponse { public List<CkanOrganization> result; } class GroupListResponse extends CkanResponse { public List<CkanGroup> result; } class GroupNamesResponse extends CkanResponse { public List<String> result; } class TagNamesResponse extends CkanResponse { public List<String> result; } class TagResponse extends CkanResponse { public CkanTag result; } class VocabularyResponse extends CkanResponse { public CkanVocabulary result; } class DatasetSearchResponse extends CkanResponse { public SearchResults<CkanDataset> result; } class LicenseListResponse extends CkanResponse { public List<CkanLicense> result; } class FormatListResponse extends CkanResponse { public Set<String> result; } class ApiVersionResponse { public int version; }