001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2014-2015 ForgeRock AS.
015 */
016
017package org.forgerock.openig.decoration.capture;
018
019import static groovy.json.JsonOutput.prettyPrint;
020import static groovy.json.JsonOutput.toJson;
021
022import java.io.IOException;
023import java.io.PrintWriter;
024import java.io.StringWriter;
025import java.io.UnsupportedEncodingException;
026import java.nio.charset.IllegalCharsetNameException;
027import java.nio.charset.UnsupportedCharsetException;
028import java.util.Arrays;
029import java.util.HashSet;
030import java.util.LinkedHashMap;
031import java.util.Map;
032import java.util.Set;
033
034import org.forgerock.http.header.ContentTypeHeader;
035import org.forgerock.http.protocol.Header;
036import org.forgerock.http.protocol.Message;
037import org.forgerock.http.protocol.Request;
038import org.forgerock.http.protocol.Response;
039import org.forgerock.openig.log.Logger;
040import org.forgerock.services.context.AttributesContext;
041import org.forgerock.services.context.Context;
042
043/**
044 * Capture a message.
045 */
046public class MessageCapture {
047
048    /** Set of common textual content with non-text content-types to capture. */
049    private static final Set<String> TEXT_TYPES = new HashSet<>(
050            Arrays.asList("application/atom+xml", "application/javascript", "application/json",
051                          "application/rss+xml", "application/xhtml+xml", "application/xml", "application/xml-dtd",
052                          "application/x-www-form-urlencoded")
053    ); // make all entries lower case
054
055    private final Logger logger;
056    private final boolean captureEntity;
057    private final boolean captureContext;
058
059    /**
060     * Builds a MessageCapture that will prints messages in the provided {@code logger}.
061     *
062     * @param logger
063     *         where to write captured messages
064     * @param captureEntity
065     *         capture the entity content (if not binary)
066     */
067    public MessageCapture(final Logger logger, final boolean captureEntity) {
068        this(logger, captureEntity, false);
069    }
070
071    /**
072     * Builds a MessageCapture that will prints messages in the provided {@code logger}.
073     *
074     * @param logger
075     *         where to write captured messages
076     * @param captureEntity
077     *         capture the entity content (if not binary)
078     * @param captureContext
079     *         capture the context content (excluding request and response object) as json
080     */
081    public MessageCapture(final Logger logger, final boolean captureEntity, final boolean captureContext) {
082        this.logger = logger;
083        this.captureEntity = captureEntity;
084        this.captureContext = captureContext;
085    }
086
087    /**
088     * Captures the given request, in the given mode.
089     *
090     * @param context
091     *         Captured message's {@link Context}
092     * @param request
093     *         Captured message
094     * @param mode
095     *         one of {@link CapturePoint#REQUEST},  {@link CapturePoint#FILTERED_REQUEST}
096     */
097    void capture(final Context context, final Request request, final CapturePoint mode) {
098        StringWriter out = new StringWriter();
099        PrintWriter writer = new PrintWriter(out);
100        String id = context.getId();
101        switch (mode) {
102        case REQUEST:
103            captureRequest(writer, request, id);
104            break;
105        case FILTERED_REQUEST:
106            captureFilteredRequest(writer, request, id);
107            break;
108        default:
109            throw new IllegalArgumentException("The given mode is not accepted: " + mode.name());
110        }
111
112        // Prints the context if required
113        if (captureContext) {
114            writer.println("Context's content as JSON:");
115            captureContextAsJson(writer, context);
116        }
117
118        // Print the message
119        logger.info(out.toString());
120    }
121
122    /**
123     * Captures the given response, in the given mode.
124     *
125     * @param context
126     *         Captured message's {@link Context}
127     * @param response
128     *         Captured message
129     * @param mode
130     *         one of {@link CapturePoint#FILTERED_RESPONSE} or {@link CapturePoint#RESPONSE}
131     */
132    void capture(final Context context, final Response response, final CapturePoint mode) {
133        StringWriter out = new StringWriter();
134        PrintWriter writer = new PrintWriter(out);
135        String id = context.getId();
136        switch (mode) {
137        case RESPONSE:
138            captureResponse(writer, response, id);
139            break;
140        case FILTERED_RESPONSE:
141            captureFilteredResponse(writer, response, id);
142            break;
143        default:
144            throw new IllegalArgumentException("The given mode is not accepted: " + mode.name());
145        }
146
147        // Prints the context if required
148        if (captureContext) {
149            writer.println("Context's content as JSON:");
150            captureContextAsJson(writer, context);
151        }
152
153        // Print the message
154        logger.info(out.toString());
155    }
156
157    private void captureContextAsJson(final PrintWriter writer, final Context context) {
158        // TODO we restrict ourselves to attributes only here, we should pretty print the chain of contexts instead
159        AttributesContext attributesContext = context.asContext(AttributesContext.class);
160        Map<String, Object> map = new LinkedHashMap<>(attributesContext.getAttributes());
161        map.remove("javax.servlet.http.HttpServletRequest");
162        map.remove("javax.servlet.http.HttpServletResponse");
163        writer.println(prettyPrint(toJson(map)));
164    }
165
166    private void captureRequest(PrintWriter writer, Request request, String id) {
167        writer.printf("%n%n--- (request) id:%s --->%n%n", id);
168        if (request != null) {
169            captureRequestMessage(writer, request);
170        }
171    }
172
173    private void captureFilteredRequest(PrintWriter writer, Request request, String id) {
174        writer.printf("%n%n--- (filtered-request) id:%s --->%n%n", id);
175        if (request != null) {
176            captureRequestMessage(writer, request);
177        }
178    }
179
180    private void captureResponse(PrintWriter writer, Response response, String id) {
181        writer.printf("%n%n<--- (response) id:%s ---%n%n", id);
182        if (response != null) {
183            captureResponseMessage(writer, response);
184        }
185    }
186
187    private void captureFilteredResponse(PrintWriter writer, Response response, String id) {
188        writer.printf("%n%n<--- (filtered-response) id:%s ---%n%n", id);
189        if (response != null) {
190            captureResponseMessage(writer, response);
191        }
192    }
193
194    private void captureRequestMessage(final PrintWriter writer, Request request) {
195        writer.println(request.getMethod() + " " + request.getUri() + " " + request.getVersion());
196        writeHeaders(writer, request);
197        writeEntity(writer, request);
198        writer.flush();
199    }
200
201    private void captureResponseMessage(final PrintWriter writer, Response response) {
202        writer.print(response.getVersion() + " ");
203        if (response.getStatus() != null) {
204            writer.print(response.getStatus().getCode() + " ");
205            writer.print(response.getStatus().getReasonPhrase());
206        }
207        writer.println();
208        writeHeaders(writer, response);
209        writeEntity(writer, response);
210        writer.flush();
211    }
212
213    private void writeHeaders(final PrintWriter writer, Message message) {
214        for (Map.Entry<String, Header> entry : message.getHeaders().asMapOfHeaders().entrySet()) {
215            for (String value : entry.getValue().getValues()) {
216                writer.println(entry.getKey() + ": " + value);
217            }
218        }
219    }
220
221    private void writeEntity(final PrintWriter writer, Message message) {
222        ContentTypeHeader contentType = ContentTypeHeader.valueOf(message);
223        if (message.getEntity() == null || contentType.getType() == null) {
224            return;
225        }
226        writer.println();
227        if (!captureEntity) {
228            // simply show presence of an entity
229            writer.println("[entity]");
230            return;
231        }
232        if (!isTextualContent(contentType)) {
233            writer.println("[binary entity]");
234            return;
235        }
236        try {
237            message.getEntity().push();
238            try {
239                message.getEntity().copyDecodedContentTo(writer);
240            } finally {
241                message.getEntity().pop();
242            }
243        } catch (UnsupportedEncodingException uee) {
244            writer.println("[entity contains data in unsupported encoding]");
245        } catch (UnsupportedCharsetException uce) {
246            writer.println("[entity contains characters in unsupported character set]");
247        } catch (IllegalCharsetNameException icne) {
248            writer.println("[entity contains characters in illegal character set]");
249        } catch (IOException e) {
250            writer.println("[IOException during entity writing] - " + e.getMessage());
251        }
252        // entity may not terminate with new line, so here it is
253        writer.println();
254    }
255
256    /**
257     * Decide if the given content-type is printable or not. The entity represents a textual/printable content if: <ul>
258     * <li>there is a charset associated to the content-type, we'll be able to print it correctly</li> <li>the content
259     * type is in the category 'text' or it is an accepted type</li> </ul>
260     *
261     * @param contentType
262     *         the message's content-type
263     * @return {@literal true} if the content-type represents a textual content
264     */
265    private static boolean isTextualContent(final ContentTypeHeader contentType) {
266        String type = (contentType.getType() != null ? contentType.getType().toLowerCase() : null);
267        return contentType.getCharset() != null
268                // text or white-listed type
269                || (type != null && (TEXT_TYPES.contains(type) || type.startsWith("text/")));
270    }
271
272}