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

edu.ksu.canvas.net.SimpleRestClient Maven / Gradle / Ivy

The newest version!
package edu.ksu.canvas.net;

import com.google.gson.Gson;
import edu.ksu.canvas.constants.CanvasConstants;
import edu.ksu.canvas.errors.ErrorHandler;
import edu.ksu.canvas.errors.GenericErrorHandler;
import edu.ksu.canvas.errors.UserErrorHandler;
import edu.ksu.canvas.exception.*;
import edu.ksu.canvas.impl.GsonResponseParser;
import edu.ksu.canvas.model.status.CanvasErrorResponse;
import edu.ksu.canvas.model.status.CanvasErrorResponse.ErrorMessage;
import edu.ksu.canvas.oauth.OauthToken;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
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.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.mime.content.ContentBody;
import org.apache.http.entity.mime.content.FileBody;
import org.apache.http.entity.mime.content.InputStreamBody;
import org.apache.http.entity.mime.HttpMultipartMode;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.BasicResponseHandler;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.validation.constraints.NotNull;
import java.io.File;
import java.io.InputStream;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class SimpleRestClient implements RestClient {
    private static final Logger LOG = LoggerFactory.getLogger(SimpleRestClient.class);

    private List errorHandlers;

    public SimpleRestClient() {
        errorHandlers = new ArrayList<>();
        errorHandlers.add(new UserErrorHandler());
        errorHandlers.add(new GenericErrorHandler());
    }

    @Override
    public Response sendApiGet(@NotNull OauthToken token, @NotNull String url,
                               int connectTimeout, int readTimeout) throws IOException {

        LOG.debug("Sending GET request to URL: " + url);
        Long beginTime = System.currentTimeMillis();
        Response response = new Response();
        try (CloseableHttpClient httpClient = createHttpClient(connectTimeout, readTimeout)) {
            HttpGet httpGet = new HttpGet(url);
            httpGet.setHeader("Authorization", "Bearer" + " " + token.getAccessToken());

            try (CloseableHttpResponse httpResponse = httpClient.execute(httpGet)) {

                //deal with the actual content
                response.setContent(handleResponse(httpResponse, httpGet));
                response.setResponseCode(httpResponse.getStatusLine().getStatusCode());
                Long endTime = System.currentTimeMillis();
                LOG.debug("GET call took: " + (endTime - beginTime) + "ms");

                //deal with pagination
                Header linkHeader = httpResponse.getFirstHeader("Link");
                String linkHeaderValue = linkHeader == null ? null : httpResponse.getFirstHeader("Link").getValue();
                if (linkHeaderValue == null) {
                    return response;
                }
                List links = Arrays.asList(linkHeaderValue.split(","));
                for (String link : links) {
                    if (link.contains("rel=\"next\"")) {
                        LOG.debug("response has more pages");
                        String nextLink = link.substring(1, link.indexOf(';') - 1); //format is ; rel="next"
                        response.setNextLink(nextLink);
                    }
                }
            }
        }

        return response;
    }

    @Override
    public Response sendJsonPut(OauthToken token, String url, String json, int connectTimeout, int readTimeout) throws IOException {
        return sendJsonPostOrPut(token, url, json, connectTimeout, readTimeout, "PUT");
    }

    @Override
    public Response sendJsonPost(OauthToken token, String url, String json, int connectTimeout, int readTimeout) throws IOException {
        return sendJsonPostOrPut(token, url, json, connectTimeout, readTimeout, "POST");
    }

    // PUT and POST are identical calls except for the header specifying the method
    private Response sendJsonPostOrPut(OauthToken token, String url, String json,
                                       int connectTimeout, int readTimeout, String method) throws IOException {
        LOG.debug("Sending JSON " + method + " to URL: " + url);
        Response response = new Response();

        HttpClient httpClient = createHttpClient(connectTimeout, readTimeout);
        HttpEntityEnclosingRequestBase action;
        if("POST".equals(method)) {
            action = new HttpPost(url);
        } else if("PUT".equals(method)) {
            action = new HttpPut(url);
        } else {
            throw new IllegalArgumentException("Method must be either POST or PUT");
        }
        Long beginTime = System.currentTimeMillis();
        action.setHeader("Authorization", "Bearer" + " " + token.getAccessToken());

        StringEntity requestBody = new StringEntity(json, ContentType.APPLICATION_JSON);
        action.setEntity(requestBody);
        try {
            HttpResponse httpResponse = httpClient.execute(action);

            String content = handleResponse(httpResponse, action);

            response.setContent(content);
            response.setResponseCode(httpResponse.getStatusLine().getStatusCode());
            Long endTime = System.currentTimeMillis();
            LOG.debug("POST call took: " + (endTime - beginTime) + "ms");
        } finally {
            action.releaseConnection();
        }

        return response;
    }

    @Override
    public Response sendApiPost(OauthToken token, String url, Map> postParameters,
                                int connectTimeout, int readTimeout) throws InvalidOauthTokenException, IOException {
        LOG.debug("Sending API POST request to URL: " + url);
        Response response = new Response();
        HttpClient httpClient = createHttpClient(connectTimeout, readTimeout);
        Long beginTime = System.currentTimeMillis();
        HttpPost httpPost = new HttpPost(url);
        httpPost.setHeader("Authorization", "Bearer" + " " + token.getAccessToken());
        List params = convertParameters(postParameters);

        httpPost.setEntity(new UrlEncodedFormEntity(params, CanvasConstants.URLENCODING_TYPE));
        HttpResponse httpResponse =  httpClient.execute(httpPost);
        String content = handleResponse(httpResponse, httpPost);

        response.setContent(content);
        response.setResponseCode(httpResponse.getStatusLine().getStatusCode());
        Long endTime = System.currentTimeMillis();
        LOG.debug("POST call took: " + (endTime - beginTime) + "ms");
        return response;
    }

    @Override
    public Response sendApiPostFile(OauthToken token, String url, Map> postParameters, String fileParameter, String filePath, InputStream is,
                                int connectTimeout, int readTimeout) throws InvalidOauthTokenException, IOException {
        LOG.debug("Sending API POST file request to URL: " + url);
        Response response = new Response();
        HttpClient httpClient = createHttpClient(connectTimeout, readTimeout);
        Long beginTime = System.currentTimeMillis();
        HttpPost httpPost = new HttpPost(url);
        httpPost.setHeader("Authorization", "Bearer" + " " + token.getAccessToken());
        List params = convertParameters(postParameters);

        MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();
        entityBuilder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE);
        if(is == null) {
            FileBody fileBody = new FileBody(new File(filePath));
            entityBuilder.addPart(fileParameter, fileBody);
        } else {
            entityBuilder.addPart(fileParameter, new InputStreamBody(is, filePath));
        }
        for(NameValuePair param : params) {
            entityBuilder.addTextBody(param.getName(), param.getValue());
        }

        httpPost.setEntity(entityBuilder.build());
        HttpResponse httpResponse =  httpClient.execute(httpPost);
        String content = handleResponse(httpResponse, httpPost);

        response.setContent(content);
        response.setResponseCode(httpResponse.getStatusLine().getStatusCode());
        Long endTime = System.currentTimeMillis();
        LOG.debug("POST file call took: " + (endTime - beginTime) + "ms");
        return response;
    }

    @Override
    public Response sendApiPut(OauthToken token, String url, Map> putParameters,
                               int connectTimeout, int readTimeout) throws InvalidOauthTokenException, IOException {
        LOG.debug("Sending API PUT request to URL: " + url);
        Response response = new Response();
        HttpClient httpClient = createHttpClient(connectTimeout, readTimeout);
        Long beginTime = System.currentTimeMillis();
        HttpPut httpPut = new HttpPut(url);
        httpPut.setHeader("Authorization", "Bearer" + " " + token.getAccessToken());
        List params = convertParameters(putParameters);

        httpPut.setEntity(new UrlEncodedFormEntity(params, CanvasConstants.URLENCODING_TYPE));
        HttpResponse httpResponse =  httpClient.execute(httpPut);
        String content = handleResponse(httpResponse, httpPut);

        response.setContent(content);
        response.setResponseCode(httpResponse.getStatusLine().getStatusCode());
        Long endTime = System.currentTimeMillis();
        LOG.debug("PUT call took: " + (endTime - beginTime) + "ms");
        return response;
    }


    @Override
    public Response sendApiDelete(OauthToken token, String url, Map> deleteParameters,
                                  int connectTimeout, int readTimeout) throws InvalidOauthTokenException, IOException {
        LOG.debug("Sending API DELETE request to URL: " + url);
        Response response = new Response();

        Long beginTime = System.currentTimeMillis();
        HttpClient httpClient = createHttpClient(connectTimeout, readTimeout);

        //This class is defined here because we need to be able to add form body elements to a delete request for a few api calls.
        class HttpDeleteWithBody extends HttpPost {
            @Override
            public String getMethod() {
                return "DELETE";
            }
        }

        HttpDeleteWithBody httpDelete = new HttpDeleteWithBody();

        httpDelete.setURI(URI.create(url));
        httpDelete.setHeader("Authorization", "Bearer" + " " + token.getAccessToken());
        List params = convertParameters(deleteParameters);

        httpDelete.setEntity(new UrlEncodedFormEntity(params, CanvasConstants.URLENCODING_TYPE));
        HttpResponse httpResponse = httpClient.execute(httpDelete);

        String content = handleResponse(httpResponse, httpDelete);
        response.setContent(content);
        response.setResponseCode(httpResponse.getStatusLine().getStatusCode());
        Long endTime = System.currentTimeMillis();
        LOG.debug("DELETE call took: " + (endTime - beginTime) + "ms");

        return response;
    }

    @Override
    public String sendUpload(String uploadUrl, Map> params, InputStream in, String filename, int connectTimeout, int readTimeout) throws IOException {

        HttpClient client = buildHttpClient(connectTimeout, readTimeout)
                .disableRedirectHandling() // We need to handle redirects ourselves
                .build();

        HttpPost httpPost = new HttpPost(uploadUrl);
        MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();
        for (Map.Entry> entry : params.entrySet()) {
            for (String value : entry.getValue()) {
                entityBuilder.addTextBody(entry.getKey(), value);
            }
        }
        ContentBody fileBody = new InputStreamBody(in, filename);
        entityBuilder.addPart("file", fileBody);
        httpPost.setEntity(entityBuilder.build());

        HttpResponse httpResponse = client.execute(httpPost);
        checkHeaders(httpResponse, httpPost, true);
        int httpStatus = httpResponse.getStatusLine().getStatusCode();
        if (httpStatus == 201 || (300 <= httpStatus && httpStatus <= 399)) {
            Header location = httpResponse.getFirstHeader("Location");
            if (location != null) {
                return location.getValue();
            } else {
                throw new CanvasException("No location to redirect to when uploading file: " + httpStatus, uploadUrl);
            }
        } else {
            throw new CanvasException("Bad status when uploading file: "+ httpStatus, uploadUrl);
        }

    }

    private void checkHeaders(HttpResponse httpResponse, HttpRequestBase request, boolean allowRedirect) {
        int statusCode = httpResponse.getStatusLine().getStatusCode();
        double rateLimitThreshold = 0.1;
        double xRateCost = 0;
        double xRateLimitRemaining = 0;

        try {
            xRateCost = Double.parseDouble(httpResponse.getFirstHeader("x-request-cost").getValue());
            xRateLimitRemaining = Double.parseDouble(httpResponse.getFirstHeader("x-rate-limit-remaining").getValue());

            //Throws a 403 with a "Rate Limit Exceeded" error message if the API throttle limit is hit.
            //See https://canvas.instructure.com/doc/api/file.throttling.html.
            if(xRateLimitRemaining < rateLimitThreshold) {
                LOG.error("Canvas API rate limit exceeded. Bucket quota: " + xRateLimitRemaining + " Cost: " + xRateCost
                        + " Threshold: " + rateLimitThreshold + " HTTP status: " + statusCode + " Requested URL: " + request.getURI());
                throw new RateLimitException(extractErrorMessageFromResponse(httpResponse), String.valueOf(request.getURI()));
            }
        } catch (NullPointerException e) {
            LOG.debug("Rate not being limited: " + e);
        }
        if (statusCode == 401) {
            //If the WWW-Authenticate header is set, it is a token problem.
            //If the header is not present, it is a user permission error.
            //See https://canvas.instructure.com/doc/api/file.oauth.html#storing-access-tokens
            if(httpResponse.containsHeader(HttpHeaders.WWW_AUTHENTICATE)) {
                LOG.debug("User's token is invalid. It might need refreshing");
                throw new InvalidOauthTokenException();
            }
            LOG.error("User is not authorized to perform this action");
            throw new UnauthorizedException();
        }
        if(statusCode == 403) {
            LOG.error("Canvas has throttled this request. Requested URL: " + request.getURI());
            throw new ThrottlingException(extractErrorMessageFromResponse(httpResponse), String.valueOf(request.getURI()));
        }
        if(statusCode == 404) {
            LOG.error("Object not found in Canvas. Requested URL: " + request.getURI());
            throw new ObjectNotFoundException(extractErrorMessageFromResponse(httpResponse), String.valueOf(request.getURI()));
        }
        if(statusCode == 504) {
            LOG.error("504 Gateway Time-out while requesting: " + request.getURI());
            throw new RetriableException("status code: 504, reason phrase: Gateway Time-out", String.valueOf(request.getURI()));
        }
        // If we receive a 5xx exception, we should not wrap it in an unchecked exception for upstream clients to deal with.
        if(statusCode < 200 || (statusCode > (allowRedirect?399:299) && statusCode <= 499)) {
            LOG.error("HTTP status " + statusCode + " returned from " + request.getURI());
            handleError(request, httpResponse);
        }
        //TODO Handling of 422 when the entity is malformed.
    }

    private void handleError(HttpRequestBase httpRequest, HttpResponse httpResponse) {
        for (ErrorHandler handler : errorHandlers) {
            if (handler.shouldHandle(httpRequest, httpResponse)) {
                handler.handle(httpRequest, httpResponse);
            }
        }
        String canvasErrorString = extractErrorMessageFromResponse(httpResponse);
        throw new CanvasException(canvasErrorString, String.valueOf(httpRequest.getURI()));
    }

    /**
     * Attempts to extract a useful Canvas error message from a response object.
     * Sometimes Canvas API errors come back with a JSON body containing something like
     * 
{"errors":[{"message":"Human readable message here."}],"error_report_id":123456}
. * This method will attempt to extract the message. If parsing fails, it will return * the raw JSON string without trying to parse it. Returns null if all attempts fail. * @param response HttpResponse object representing the error response from Canvas * @return The Canvas human-readable error string or null if unable to extract it */ private String extractErrorMessageFromResponse(HttpResponse response) { String contentType = response.getEntity().getContentType().getValue(); String message = null; if(contentType.contains("application/json")) { Gson gson = GsonResponseParser.getDefaultGsonParser(false); String responseBody = null; try { responseBody = EntityUtils.toString(response.getEntity()); LOG.error("Body of error response from Canvas: " + responseBody); CanvasErrorResponse errorResponse = gson.fromJson(responseBody, CanvasErrorResponse.class); List errors = errorResponse.getErrors(); if(errors != null) { //I have only ever seen a single error message but it is an array so presumably there could be more. message = errors.stream().map(ErrorMessage::getMessage).collect(Collectors.joining(", ")); } else{ message = responseBody; } } catch (Exception e) { //Returned JSON was not in expected format. Fall back to returning the whole response body, if any if(StringUtils.isNotBlank(responseBody)) { message = responseBody; } } } return message; } private String handleResponse(HttpResponse httpResponse, HttpRequestBase request) throws IOException { checkHeaders(httpResponse, request, false); return new BasicResponseHandler().handleResponse(httpResponse); } private CloseableHttpClient createHttpClient(int connectTimeout, int readTimeout) { return buildHttpClient(connectTimeout, readTimeout).build(); } private HttpClientBuilder buildHttpClient(int connectTimeout, int readTimeout) { RequestConfig config = RequestConfig.custom() .setConnectTimeout(connectTimeout) .setSocketTimeout(readTimeout) .setCookieSpec(CookieSpecs.STANDARD) .build(); return HttpClientBuilder.create() .setDefaultRequestConfig(config); } private static List convertParameters(final Map> parameterMap) { final List params = new ArrayList<>(); if (parameterMap == null) { return params; } for (final Map.Entry> param : parameterMap.entrySet()) { final String key = param.getKey(); if(param.getValue() == null || param.getValue().isEmpty()) { params.add(new BasicNameValuePair(key, null)); LOG.debug("key: " + key + "\tempty value"); } for (final String value : param.getValue()) { params.add(new BasicNameValuePair(key, value)); LOG.debug("key: "+ key +"\tvalue: " + value); } } return params; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy