macgregor/alexandria

View on GitHub
alexandria-remote-jive/src/main/java/com/github/macgregor/alexandria/remotes/JiveRemote.java

Summary

Maintainability
C
1 day
Test Coverage
package com.github.macgregor.alexandria.remotes;

import com.github.macgregor.alexandria.Config;
import com.github.macgregor.alexandria.Context;
import com.github.macgregor.alexandria.exceptions.AlexandriaException;
import com.github.macgregor.alexandria.exceptions.HttpException;
import com.github.macgregor.alexandria.markdown.MarkdownConverter;
import lombok.*;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Credentials;
import okhttp3.OkHttpClient;
import org.apache.commons.lang3.StringUtils;

import java.io.IOException;
import java.net.URI;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

/**
 * Implements the Jive rest api for create/update/delete of documents.
 *
 * Does not currently support OAuth as I do not have access to a Jive instance that supports it.
 * <pre>
 * {@code
 * ---
 * remote:
 *   baseUrl: "https://jive.com/api/core/v3"
 *   username: "username"
 *   password: "password"
 *   supportsNativeMarkdown: false
 *   datetimeFormat: "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
 *   requestTimeout: 60
 *   class: "com.github.macgregor.alexandria.remotes.jive.JiveRemote"
 *   defaultExtraProps:
 *      jiveParentUri: "https://jive.com/groups/alexandria-test-group"
 * metadata:
 * - sourcePath: "docs/images.md"
 *   title: "images.md"
 *   remoteUri: "https://jive.com/docs/DOC-1140818"
 *   sourceChecksum: 1751689934
 *   convertedChecksum: 2834756773
 *   createdOn: "2018-08-22T02:27:48.644+0000"
 *   lastUpdated: "2018-08-22T02:27:51.789+0000"
 *   extraProps:
 *     jiveParentUri: "https://jive.com/groups/alexandria-test-group"
 *     jiveParentPlaceId: "1448512"
 *     jiveParentApiUri: "https://jive.com/api/core/v3/places/1448512"
 *     jiveContentId: "1448517"
 *     jiveTrackingTag: "fb25f4ee-b084-4bb2-a96f-3e656449ca20"
 * }
 * </pre>
 *
 * {@link Config.RemoteConfig#defaultExtraProps} and {@link Config.DocumentMetadata#extraProps}:
 * <ul>
 *  <li><b>USER DEFINED</b> {@value #JIVE_PARENT_URI} - the uri used to access a parent place for the document. Setting this is how you link a
 *  Jive document with the place where it will live. This will not be used for the api calls as the api needs a different URI
 *  (of course). This is the link a human can easily find in their browser, we have to search for the place to get the details
 *  needed by the content api.
 *  See {@link #findParentPlace(Config.DocumentMetadata)}</li>
 *  <li>{@value #JIVE_CONTENT_ID} - the identifier Jive uses for a document, hard for the user to get themselves. We either
 *  set it when we create the document or look it up from {@link Config.DocumentMetadata#remoteUri}.
 *  See {@link #findDocument(Config.DocumentMetadata)}</li>
 *  <li>{@value #JIVE_PARENT_PLACE_ID} - the identifier Jive uses for a place. Set when we lookup the parent place from the
 *  user defined {@value #JIVE_PARENT_URI}.
 *  See {@link #findParentPlace(Config.DocumentMetadata)}</li>
 *  <li>{@value #JIVE_PARENT_API_URI} - this is the actual api uri for a parent place. Set when we lookup the parent place
 *  from the user defined {@value #JIVE_PARENT_URI}.
 *  See {@link #findParentPlace(Config.DocumentMetadata)}</li>
 *  <li>{@value #JIVE_TRACKING_TAG} - this is a tag set to help Alexandria track documents created or updated in case it needs
 *  to find them later. Poor performance on the Jive instance can easily lead to state like the create request timing out
 *  but still going through server side. We need to be able to locate documents easily without knowing the {@link Config.DocumentMetadata#remoteUri}
 *  or {@value JIVE_CONTENT_ID}</li>
 * </ul>
 *
 * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/index.html">Introduction to the Jive REST API</a>
 */
@Slf4j
@ToString
@Getter @Setter @Accessors(fluent = true)
@NoArgsConstructor
public class JiveRemote implements Remote, Context.ContextAware {
    public static final String JIVE_CONTENT_ID = "jiveContentId";
    public static final String JIVE_PARENT_URI = "jiveParentUri";
    public static final String JIVE_PARENT_API_URI = "jiveParentApiUri";
    public static final String JIVE_PARENT_PLACE_ID = "jiveParentPlaceId";
    public static final String JIVE_TRACKING_TAG = "jiveTrackingTag";

    @NonNull protected OkHttpClient client;
    @NonNull protected Config.RemoteConfig config;
    @NonNull protected MarkdownConverter markdownConverter;
    @NonNull protected Context context;

    /**
     * Create {@link JiveRemote} with a default {@link OkHttpClient}.
     *
     * @param config  remote configuration with at least {@link Config.RemoteConfig#clazz} set.
     */
    public JiveRemote(Config.RemoteConfig config){
        client = new OkHttpClient.Builder()
                .connectTimeout(config.requestTimeout(), TimeUnit.SECONDS)
                .writeTimeout(config.requestTimeout(), TimeUnit.SECONDS)
                .readTimeout(config.requestTimeout(), TimeUnit.SECONDS)
                .build();
        this.config = config;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void configure(Config.RemoteConfig config){
        this.client = new OkHttpClient.Builder()
                .connectTimeout(config.requestTimeout(), TimeUnit.SECONDS)
                .writeTimeout(config.requestTimeout(), TimeUnit.SECONDS)
                .readTimeout(config.requestTimeout(), TimeUnit.SECONDS)
                .build();
        this.config = config;
    }

    /**
     * {@inheritDoc}
     *
     * Requires:
     * <ul>
     *  <li>{@link Config.RemoteConfig#baseUrl}</li>
     *  <li>{@link Config.RemoteConfig#username}</li>
     *  <li>{@link Config.RemoteConfig#password}</li>
     * </ul>
     *
     * @throws IllegalStateException  Username, password and/or baseUrl are not set.
     */
    @Override
    public void validateRemoteConfig() throws IllegalStateException {
        List<String> missingProperties = new ArrayList<>();
        if(!config.baseUrl().isPresent()){
            missingProperties.add("remote.baseUrl");
        }
        if(!config.username().isPresent()){
            missingProperties.add("remote.username");
        }
        if(!config.password().isPresent()){
            missingProperties.add("remote.password");
        }
        if(!missingProperties.isEmpty()){
            log.warn(String.format("Jive remote configuration missing required properties: %s", missingProperties));
            throw new IllegalStateException(String.format("Jive remote configuration missing required properties: %s", missingProperties));
        }
    }

    /**
     * {@inheritDoc}
     *
     * We will always make a request to {@code POST baseUrl/contents}, but we may also need to make a request to
     * {@code GET baseUrl/places} if the document has a parent place.
     *
     * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/ContentService.html#createContent(String,%20String,%20String,%20String)">Jive REST API - Create Content</a>
     * @see JiveUtils#needsParentPlaceUri(Context, Config.DocumentMetadata)
     * @see #findParentPlace(Config.DocumentMetadata)
     */
    @Override
    public void create(Config.DocumentMetadata metadata) throws IOException {
        JiveUtils.setTrackingTagAsNeeded(context, metadata);
        boolean found = false;
        try {
            JiveData.JiveContent content = findDocument(metadata);
            if(content != null){
                found = true;
            }
        } catch(HttpException e){
            if(e.response().isPresent() && e.response().get().code() == 404){
                log.debug(String.format("Document %s not found on remote. Creating.", metadata.sourceFileName()));
            } else {
                throw e;
            }
        }

        if(found){
            log.debug(String.format("Document %s (%s) already exists on remote. Not recreating.", metadata.sourceFileName(), metadata.remoteUri().get()));
            return;
        }

        if(JiveUtils.needsParentPlaceUri(context, metadata)){
            findParentPlace(metadata);
        }

        RemoteDocument<JiveData.JiveContent> jiveContent = remoteJiveContentBuilder()
                .build();
        JiveData.JiveContent content = jiveContent.post(JiveData.documentPostBody(context, metadata));
        updateMetadata(metadata, content);
    }

    /**
     * {@inheritDoc}
     *
     * We will always make a request to {@code POST baseUrl/contents/$&#123;{@value #JIVE_CONTENT_ID}&#125;}, but
     * we may also need to make a request to {@code GET baseUrl/contents} to convert the browsers reachable document
     * endpoint to the rest api endpoint for the document.
     *
     * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/ContentService.html#updateContent(String,%20String,%20String,%20boolean,%20String,%20boolean)">Jive REST API - Update Content</a>
     * @see JiveUtils#needsContentId(Config.DocumentMetadata)
     * @see #findDocument(Config.DocumentMetadata)
     */
    @Override
    public void update(Config.DocumentMetadata metadata) throws IOException {
        JiveUtils.setTrackingTagAsNeeded(context, metadata);
        if(JiveUtils.needsContentId(metadata)){
            findDocument(metadata);
        }
        String contentId = context.getExtraPropertiesForDocument(metadata).get(JIVE_CONTENT_ID);

        if(JiveUtils.needsParentPlaceUri(context, metadata)){
            findParentPlace(metadata);
        }

        RemoteDocument<JiveData.JiveContent> jiveContent = remoteJiveContentBuilder()
                .pathSegment(contentId)
                .build();
        JiveData.JiveContent content = jiveContent.put(JiveData.documentPostBody(context, metadata));
        updateMetadata(metadata, content);
    }
    /**
     * {@inheritDoc}
     *
     * Potentially three requests can be made here, because the Jive api handles requests for deleted documents poorly.
     * Firstly, the usual {@code GET baseUrl/contents} if the metadata is missing the {@value #JIVE_CONTENT_ID}. After that
     * attempts to delete a document that is already deleted will yield a 403 unauthorized. So if for some reason
     * Alexandria's state gets messed up and it tries to re-delete a document it will get an unexpected error. We avoid
     * that by fetching the document directly via {@code GET baseUrl/contents/$&#123;{@value #JIVE_CONTENT_ID}&#125;},
     * checking for 403's and only executing {@code DELETE baseUrl/contents/$&#123;{@value #JIVE_CONTENT_ID}&#125;} if
     * the document exists. Then we know a 403 is actually an authorization error.
     *
     * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/ContentService.html#getContent(String,%20String,%20boolean,%20List%3CString%3E)">Jive REST API - Get Content</a>
     * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/ContentService.html#deleteContent(String,%20Boolean)">Jive REST API - Delete Content</a>
     * @see JiveUtils#needsContentId(Config.DocumentMetadata)
     * @see #findDocument(Config.DocumentMetadata)
     */
    @Override
    public void delete(Config.DocumentMetadata metadata) throws IOException {
        boolean deleted = false;
        try{
            JiveData.JiveContent content = findDocument(metadata);
            if(content == null){
                deleted = true;
            }
        } catch(HttpException e){
            if(e.response().isPresent() && e.response().get().code() == 404){
                deleted = true;
            } else {
                throw e;
            }
        }

        if(deleted){
            log.debug("Looking for document wasnt found, assuming its already deleted.");
            metadata.deletedOn(Optional.of(ZonedDateTime.now(ZoneOffset.UTC)));
            return;
        }

        String contentId = context.getExtraPropertiesForDocument(metadata).get(JIVE_CONTENT_ID);
        remoteJiveContentBuilder()
                .pathSegment(contentId)
                .build()
                .delete();
        metadata.deletedOn(Optional.of(ZonedDateTime.now(ZoneOffset.UTC)));
    }

    /**
     * Find a document's api identifiers from the human accessible uri.
     *
     * The uri a human uses to access a document ({@link Config.DocumentMetadata#remoteUri})
     * is not the uri we need to make rest requests. There is an sort of identifier in this uri, but we have to extract it
     * and then run a search for it to get the {@value JIVE_CONTENT_ID} which we can use to modify the document.
     *
     * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/ContentService.html#getContents(List%3CString%3E,%20String,%20int,%20int,%20String,%20boolean,%20boolean)">Jive REST API - Get Contents</a>
     * @see <a href="https://community.jivesoftware.com/docs/DOC-153931">Finding the Content ID and Place ID using Jive v3 API</a>
     *
     * @param metadata  metadata to find on remote
     * @throws IOException  there was a problem with the request
     * @return the matching {@link JiveData.JiveContent} or null if it wasnt found
     */
    public JiveData.JiveContent findDocument(Config.DocumentMetadata metadata) throws IOException {
        log.debug(String.format("Missing jive content id for %s, attempting to retrieve from remote.", metadata.sourceFileName()));

        String filter;
        if(metadata.hasExtraProperty(JIVE_TRACKING_TAG)){
            filter = String.format("tag(%s)", context.getExtraPropertiesForDocument(metadata).get(JIVE_TRACKING_TAG));
        } else if (metadata.remoteUri().isPresent()){
            filter = String.format("entityDescriptor(102,%s)", JiveUtils.jiveObjectId(metadata.remoteUri().get()));
        } else{
            throw new AlexandriaException.Builder()
                    .withMessage("Not enough information to find document on remote. Manual intervention may be necessary.")
                    .metadataContext(metadata)
                    .build();
        }

        RemoteDocument<JiveData.JiveContent> pagedJiveContent = remoteJiveContentBuilder()
                .queryParameter("filter", filter)
                .build();
        JiveData.JiveContent content = pagedJiveContent.getPaged().first();
        if(content != null) {
            updateMetadata(metadata, content);
        }
        return content;
    }

    /**
     * Find a parent place's api identifiers from the human accessible uri.
     *
     * Just like with documents, the parent uri a human interacts with is not the same as the one we need for rest requests.
     * This gives us the {@value JIVE_PARENT_API_URI} to use in the post body of create and update requests. The api
     * we are calling for this is very limiting and inaccurate. To compensate for this we run a series of queries of decreasing
     * accuracy:
     * <ol>
     *     <li>api/core/v3/places?filter=relationship(member)</li>
     *     <li>api/core/v3/places?filter=relationship(following)</li>
     *     <li>api/core/v3/places?filter=relationship(owner)</li>
     *     <li>api/core/v3/places?filter=search({@link JiveUtils#jiveParentPlaceName(String)})</li>
     * </ol>
     *
     * All of these require client side filtering to match the place name to the name extracted from the uri. The last query
     * really is a last resort as it is slow and is a coin toss on whether you will find the place or not. For best results,
     * the Jive user Alexandria uses should be made a member, owner or follower of the places it will be using.
     *
     * @see <a href="https://developers.jivesoftware.com/api/v3/cloud/rest/PlaceService.html#getPlaces(List%3CString%3E,%20String,%20int,%20int,%20String)">Jive REST API - Get Places</a>
     * @see <a href="https://community.jivesoftware.com/docs/DOC-153931">Finding the Content ID and Place ID using Jive v3 API</a>
     *
     * @param metadata  document that needs parent details
     * @throws IOException  there was a problem with the request
     */
    public void findParentPlace(Config.DocumentMetadata metadata) throws IOException {
        log.debug(String.format("Jive parent place detected, attempting to retrieve from remote."));

        String parentPlaceUrl = context.getExtraPropertiesForDocument(metadata).get(JIVE_PARENT_URI);
        String parentPlaceName = JiveUtils.jiveParentPlaceName(parentPlaceUrl);
        List<String> filters = Arrays.asList("relationship(member)", "relationship(following)", "relationship(owner)",
                String.format("search(%s)", parentPlaceName));

        for(String filter : filters) {

            RemoteDocument<JiveData.JivePlace> jivePlaces = remoteJivePlaceBuilder()
                    .queryParameter("filter", filter)
                    .build();
            try {
                for (JiveData.JivePlace place : jivePlaces.getPaged()) {
                    if (place.displayName.equals(parentPlaceName)) {
                        updateMetadata(metadata, place);
                        break;
                    }
                }
                if(!JiveUtils.needsParentPlaceUri(context, metadata)){
                    break;
                }
            } catch (Exception e) {
                if (e.getCause() instanceof HttpException) {
                    HttpException exception = (HttpException) e.getCause();
                    if (!exception.response().isPresent() || exception.response().get().code() != 404) {
                        throw exception;
                    }
                } else {
                    throw e;
                }
            }
        }

        if(JiveUtils.needsParentPlaceUri(context, metadata)){
            log.warn(String.format("Parent Place %s (%s) not found. Document will not be part of any Jive place.", parentPlaceName, parentPlaceUrl));
        }
    }

    /**
     * Update metadata from the {@link JiveData.JiveContent} from a create, update or find request
     *
     * @param metadata  document to update with request content
     * @param content  parsed content from the request
     * @return  the updated document metadata passed to it
     */
    protected static Config.DocumentMetadata updateMetadata(Config.DocumentMetadata metadata, JiveData.JiveContent content) {
        metadata.createdOn(Optional.ofNullable(content.published));
        metadata.lastUpdated(Optional.ofNullable(content.updated));

        if(content.resources != null && content.resources.containsKey("html")){
            try {
                metadata.remoteUri(Optional.of(new URI(content.resources.get("html").ref)));
            } catch (Exception e) {}
        }
        if(content.parentPlace != null){
            if(StringUtils.isNotBlank(content.parentPlace.html)){
                metadata.setExtraProperty(JIVE_PARENT_URI, content.parentPlace.html);
            }
            if(StringUtils.isNotBlank(content.parentPlace.placeID)){
                metadata.setExtraProperty(JIVE_PARENT_PLACE_ID, content.parentPlace.placeID);
            }
            if(StringUtils.isNotBlank(content.parentPlace.uri)){
                metadata.setExtraProperty(JIVE_PARENT_API_URI, content.parentPlace.uri);
            }
        }
        if(StringUtils.isNotBlank(content.contentID)){
            metadata.setExtraProperty(JIVE_CONTENT_ID, content.contentID);
        }

        log.debug(String.format("Updated %s metadata from response content.", metadata.sourcePath().toAbsolutePath().toString()));
        return metadata;
    }

    /**
     * Update metadata from the {@link JiveData.JivePlace}
     *
     * @param metadata  document to update with request content
     * @param place  parsed content from the request
     * @return  the updated document metadata passed to it
     */
    protected static Config.DocumentMetadata updateMetadata(Config.DocumentMetadata metadata, JiveData.JivePlace place) {
        if(StringUtils.isNotBlank(place.placeID)){
            metadata.setExtraProperty(JIVE_PARENT_PLACE_ID, place.placeID);
        }
        if(place.resources != null && place.resources.containsKey("html")){
            metadata.setExtraProperty(JIVE_PARENT_URI, place.resources.get("html").ref);
        }
        if(place.resources != null && place.resources.containsKey("self")){
            metadata.setExtraProperty(JIVE_PARENT_API_URI, place.resources.get("self").ref);
        }

        log.debug(String.format("Updated %s metadata from response content.", metadata.sourcePath().toAbsolutePath().toString()));
        return metadata;
    }

    /**
     * Base {@link RemoteDocument.RemoteDocumentBuilder} for jive content requests.
     *
     * @return  {@link RemoteDocument.RemoteDocumentBuilder} with authorization credentials and field projects set.
     */
    protected RemoteDocument.RemoteDocumentBuilder remoteJivePlaceBuilder(){
        return RemoteDocument.<JiveData.JivePlace>builder()
                .baseUrl(config.baseUrl().get())
                .pathSegment("places")
                .entity(JiveData.JivePlace.class)
                .header("Authorization", Credentials.basic(config.username().get(), config.password().get()))
                .queryParameter("fields", JiveData.JivePlace.FIELDS);
    }

    /**
     * Base {@link RemoteDocument.RemoteDocumentBuilder} for jive place requests.
     *
     * @return  {@link RemoteDocument.RemoteDocumentBuilder} with authorization credentials and field projects set.
     */
    protected RemoteDocument.RemoteDocumentBuilder remoteJiveContentBuilder(){
        return RemoteDocument.<JiveData.JiveContent>builder()
                .baseUrl(config.baseUrl().get())
                .pathSegment("contents")
                .entity(JiveData.JiveContent.class)
                .header("Authorization", Credentials.basic(config.username().get(), config.password().get()))
                .queryParameter("fields", JiveData.JiveContent.FIELDS);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public MarkdownConverter markdownConverter() {
        return markdownConverter;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void markdownConverter(MarkdownConverter markdownConverter) {
        this.markdownConverter = markdownConverter;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void alexandriaContext(Context context) {
        this.context = context;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Context alexandriaContext() {
        return context;
    }
}