/*
* Copyright (C) 2011 Google Inc.
*
* 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 com.google.api.explorer.client.history;
import com.google.api.explorer.client.Resources;
import com.google.api.explorer.client.Resources.Css;
import com.google.api.explorer.client.base.ApiMethod;
import com.google.api.explorer.client.base.ApiMethod.HttpMethod;
import com.google.api.explorer.client.base.ApiService;
import com.google.api.explorer.client.base.Config;
import com.google.api.explorer.client.base.Schema;
import com.google.api.explorer.client.base.dynamicjso.DynamicJsArray;
import com.google.api.explorer.client.base.dynamicjso.DynamicJso;
import com.google.api.explorer.client.base.dynamicjso.JsType;
import com.google.api.explorer.client.routing.HistoryWrapper;
import com.google.api.explorer.client.routing.HistoryWrapperImpl;
import com.google.api.explorer.client.routing.URLFragment;
import com.google.api.explorer.client.routing.UrlBuilder;
import com.google.api.explorer.client.routing.UrlBuilder.RootNavigationItem;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Strings;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JsonUtils;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.MouseOutEvent;
import com.google.gwt.event.dom.client.MouseOutHandler;
import com.google.gwt.json.client.JSONObject;
import com.google.gwt.json.client.JSONString;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.Anchor;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.FocusPanel;
import com.google.gwt.user.client.ui.Image;
import com.google.gwt.user.client.ui.InlineHyperlink;
import com.google.gwt.user.client.ui.InlineLabel;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.Panel;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.PopupPanel.PositionCallback;
import com.google.gwt.user.client.ui.PushButton;
import com.google.gwt.user.client.ui.Widget;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import javax.annotation.Nullable;
/**
* A simple syntax highlighter for JSON data.
*
*/
public class JsonPrettifier {
/**
* Class that we can use to re-write runtime Json exceptions to checked.
*/
public static class JsonFormatException extends Exception {
private JsonFormatException(String message, Throwable cause) {
super(message, cause);
}
}
private static final String PLACEHOLDER_TEXT = "...";
private static final String SEPARATOR_TEXT = ",";
private static final String OPEN_IN_NEW_WINDOW = "_blank";
private static final HistoryWrapper history = new HistoryWrapperImpl();
private static Css style;
private static Resources resources;
/**
* Factory that can be used to manufacture link information that can vary between the full and
* embedded explorer.
*/
public interface PrettifierLinkFactory {
/**
* Generate a click handler that will redirect to the fragment specified when invoked.
*/
ClickHandler generateMenuHandler(String fragment);
/**
* Generate an anchor widget which will redirect to the fragment specified when clicked.
*/
Widget generateAnchor(String embeddingText, String fragment);
}
/**
* Link factory which generates links which manipulate the current browser view. This should be
* used for the explorer full-view context.
*/
public static final PrettifierLinkFactory LOCAL_LINK_FACTORY = new PrettifierLinkFactory() {
@Override
public ClickHandler generateMenuHandler(final String fragment) {
return new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
history.newItem(fragment);
}
};
}
@Override
public Widget generateAnchor(String embeddingText, String fragment) {
return new InlineHyperlink(embeddingText, fragment);
}
};
/**
* Link factory which generates links which either open a new tab, or switch the entire URL to a
* new location. This should be used with the embedded explorer context.
*/
public static final PrettifierLinkFactory EXTERNAL_LINK_FACTORY = new PrettifierLinkFactory() {
@Override
public ClickHandler generateMenuHandler(final String fragment) {
return new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
Window.open(createFullLink(fragment), "_blank", null);
}
};
}
@Override
public Widget generateAnchor(String embeddingText, String fragment) {
return new Anchor(embeddingText, createFullLink(fragment));
}
private String createFullLink(String fragment) {
return Config.EXPLORER_URL + "#" + fragment;
}
};
private static class Collapser implements ClickHandler {
private final Widget toHide;
private final Widget placeHolder;
private final Widget clicker;
public Collapser(Widget toHide, Widget placeHolder, Widget clicker) {
this.toHide = toHide;
this.placeHolder = placeHolder;
this.clicker = clicker;
}
@Override
public void onClick(ClickEvent arg0) {
boolean makeVisible = !toHide.isVisible();
decorateCollapserControl(clicker, makeVisible);
toHide.setVisible(makeVisible);
placeHolder.setVisible(!makeVisible);
}
public static void decorateCollapserControl(Widget collapser, boolean visible) {
if (visible) {
collapser.addStyleName(style.jsonExpanded());
collapser.removeStyleName(style.jsonCollapsed());
} else {
collapser.addStyleName(style.jsonCollapsed());
collapser.removeStyleName(style.jsonExpanded());
}
}
}
/**
* This abstraction of an array creates formatted widgets from all children.
*/
private static class JsArrayIterable implements Iterable<Widget> {
private final DynamicJsArray backingObj;
private final int depth;
private final ApiService service;
private final PrettifierLinkFactory linkFactory;
public JsArrayIterable(
ApiService service, DynamicJsArray array, int depth, PrettifierLinkFactory linkFactory) {
this.backingObj = array;
this.depth = depth;
this.service = service;
this.linkFactory = linkFactory;
}
@Override
public Iterator<Widget> iterator() {
return new Iterator<Widget>() {
private int nextOffset = 0;
@Override
public boolean hasNext() {
return nextOffset < backingObj.length();
}
@Override
public Widget next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Widget next = formatArrayValue(service,
backingObj,
nextOffset,
depth,
nextOffset + 1 < backingObj.length(),
linkFactory);
nextOffset++;
return next;
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
}
/**
* This abstraction of an object creates formatted widgets from all children.
*/
private static class JsObjectIterable implements Iterable<Widget> {
private final DynamicJso backingObj;
private final int depth;
private final ApiService service;
private final PrettifierLinkFactory linkFactory;
public JsObjectIterable(
ApiService service, DynamicJso obj, int depth, PrettifierLinkFactory linkFactory) {
this.backingObj = obj;
this.depth = depth;
this.service = service;
this.linkFactory = linkFactory;
}
@Override
public Iterator<Widget> iterator() {
return new Iterator<Widget>() {
int nextOffset = 0;
@Override
public boolean hasNext() {
return nextOffset < backingObj.keys().length();
}
@Override
public Widget next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Widget next =
formatValue(service, backingObj, backingObj.keys().get(nextOffset), depth,
nextOffset + 1 < backingObj.keys().length(), linkFactory);
nextOffset++;
return next;
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
}
/**
* Must be called before calling prettify to set the resources file to be used. Makes it possible
* to test this class under JUnit.
*
* @param resources Resources (images and style) to use when prettifying.
*/
public static void setResources(Resources resources) {
JsonPrettifier.resources = resources;
JsonPrettifier.style = resources.style();
}
/**
* Entry point for the formatter.
*
* @param destination Destination GWT object where the results will be placed
* @param jsonString String to format
* @param linkFactory Which links factory should be used when generating links and navigation
* menus.
* @throws JsonFormatException when parsing the Json causes an error
*/
public static void prettify(
ApiService service, Panel destination, String jsonString, PrettifierLinkFactory linkFactory)
throws JsonFormatException {
// Make sure the user set a style before invoking prettify.
Preconditions.checkState(style != null, "Must call setStyle before using.");
Preconditions.checkNotNull(service);
Preconditions.checkNotNull(destination);
// Don't bother syntax highlighting empty text.
boolean empty = Strings.isNullOrEmpty(jsonString);
destination.setVisible(!empty);
if (empty) {
return;
}
if (!GWT.isScript()) {
// Syntax highlighting is *very* slow in Development Mode (~30s for large
// responses), but very fast when compiled and run as JS (~30ms). For the
// sake of my sanity, syntax highlighting is disabled in Development
destination.add(new InlineLabel(jsonString));
} else {
try {
DynamicJso root = JsonUtils.<DynamicJso>safeEval(jsonString);
Collection<ApiMethod> compatibleMethods = computeCompatibleMethods(root, service);
Widget menuForMethods = createRequestMenu(compatibleMethods, service, root, linkFactory);
JsObjectIterable rootObject = new JsObjectIterable(service, root, 1, linkFactory);
Widget object = formatGroup(rootObject, "", 0, "{", "}", false, menuForMethods);
destination.add(object);
} catch (IllegalArgumentException e) {
// JsonUtils will throw an IllegalArgumentException when it gets invalid
// Json data. Rewrite as a checked exception and throw.
throw new JsonFormatException("Invalid json.", e);
}
}
}
/**
* Check the provided javascript object for a "kind" key and, and find all methods from the
* provided service that accept the specified type for the request body.
*
* @param object Object which is checked against other methods.
* @param service Service for which we want to find compatible methods.
* @return Matching methods that accept the object type as an input, or an empty collection.
*/
private static Collection<ApiMethod> computeCompatibleMethods(
DynamicJso object, ApiService service) {
String kind = object.getString(Schema.KIND_KEY);
if (kind != null) {
return service.usagesOfKind(kind);
} else {
return Collections.emptyList();
}
}
/**
* Iterate through an object or array adding the widgets generated for all children
*/
private static FlowPanel formatGroup(Iterable<Widget> objIterable,
String title,
int depth,
String openGroup,
String closeGroup,
boolean hasSeparator,
@Nullable Widget menuButtonForReuse) {
FlowPanel object = new FlowPanel();
FlowPanel titlePanel = new FlowPanel();
Label paddingSpaces = new InlineLabel(indentation(depth));
titlePanel.add(paddingSpaces);
Label titleLabel = new InlineLabel(title + openGroup);
titleLabel.addStyleName(style.jsonKey());
Collapser.decorateCollapserControl(titleLabel, true);
titlePanel.add(titleLabel);
object.add(titlePanel);
FlowPanel objectContents = new FlowPanel();
if (menuButtonForReuse != null) {
objectContents.addStyleName(style.reusableResource());
objectContents.add(menuButtonForReuse);
}
for (Widget child : objIterable) {
objectContents.add(child);
}
object.add(objectContents);
InlineLabel placeholder = new InlineLabel(indentation(depth + 1) + PLACEHOLDER_TEXT);
ClickHandler collapsingHandler = new Collapser(objectContents, placeholder, titleLabel);
placeholder.setVisible(false);
placeholder.addClickHandler(collapsingHandler);
object.add(placeholder);
titleLabel.addClickHandler(collapsingHandler);
StringBuilder closingLabelText = new StringBuilder(indentation(depth)).append(closeGroup);
if (hasSeparator) {
closingLabelText.append(SEPARATOR_TEXT);
}
object.add(new Label(closingLabelText.toString()));
return object;
}
private static Widget formatArrayValue(ApiService service,
DynamicJsArray obj,
int index,
int depth,
boolean hasSeparator,
PrettifierLinkFactory linkFactory) {
JsType type = obj.typeofIndex(index);
if (type == null) {
return simpleInline("", "null", style.jsonNull(), depth, hasSeparator);
}
String title = "";
switch (type) {
case NUMBER:
return simpleInline(
title, String.valueOf(obj.getDouble(index)), style.jsonNumber(), depth, hasSeparator);
case INTEGER:
return simpleInline(
title, String.valueOf(obj.getInteger(index)), style.jsonNumber(), depth, hasSeparator);
case BOOLEAN:
return simpleInline(
title, String.valueOf(obj.getBoolean(index)), style.jsonBoolean(), depth, hasSeparator);
case STRING:
return inlineWidget(
title, formatString(service, obj.getString(index), linkFactory), depth, hasSeparator);
case ARRAY:
return formatGroup(
new JsArrayIterable(service, obj.<DynamicJsArray>get(index), depth + 1, linkFactory),
title, depth, "[", "]", hasSeparator, null);
case OBJECT:
DynamicJso subObject = obj.<DynamicJso>get(index);
// Determine if this object can be used as the request parameter for another method.
Collection<ApiMethod> compatibleMethods = computeCompatibleMethods(subObject, service);
Widget menuFromMethods =
createRequestMenu(compatibleMethods, service, subObject, linkFactory);
JsObjectIterable objIter = new JsObjectIterable(service, subObject, depth + 1, linkFactory);
return formatGroup(objIter, title, depth, "{", "}", hasSeparator, menuFromMethods);
}
return new FlowPanel();
}
private static Widget formatValue(ApiService service,
DynamicJso obj,
String key,
int depth,
boolean hasSeparator,
PrettifierLinkFactory linkFactory) {
JsType type = obj.typeofKey(key);
if (type == null) {
return simpleInline(titleString(key), "null", style.jsonNull(), depth, hasSeparator);
}
String title = titleString(key);
switch (type) {
case NUMBER:
return simpleInline(
title, String.valueOf(obj.getDouble(key)), style.jsonNumber(), depth, hasSeparator);
case INTEGER:
return simpleInline(
title, String.valueOf(obj.getInteger(key)), style.jsonNumber(), depth, hasSeparator);
case BOOLEAN:
return simpleInline(
title, String.valueOf(obj.getBoolean(key)), style.jsonBoolean(), depth, hasSeparator);
case STRING:
return inlineWidget(
title, formatString(service, obj.getString(key), linkFactory), depth, hasSeparator);
case ARRAY:
return formatGroup(
new JsArrayIterable(service, obj.<DynamicJsArray>get(key), depth + 1, linkFactory),
title, depth, "[", "]", hasSeparator, null);
case OBJECT:
DynamicJso subObject = obj.<DynamicJso>get(key);
// Determine if this object can be used as the request parameter for another method.
Collection<ApiMethod> compatibleMethods = computeCompatibleMethods(subObject, service);
JsObjectIterable objIter = new JsObjectIterable(service, subObject, depth + 1, linkFactory);
return formatGroup(objIter, title, depth, "{", "}", hasSeparator, null);
}
return new FlowPanel();
}
private static Widget simpleInline(
String title, String inlineText, String style, int depth, boolean hasSeparator) {
Widget valueLabel = new InlineLabel(inlineText);
valueLabel.addStyleName(style);
return inlineWidget(title, Lists.newArrayList(valueLabel), depth, hasSeparator);
}
private static Widget inlineWidget(
String title, List<Widget> inlineWidgets, int depth, boolean hasSeparator) {
FlowPanel inlinePanel = new FlowPanel();
StringBuilder keyText = new StringBuilder(indentation(depth)).append(title);
InlineLabel keyLabel = new InlineLabel(keyText.toString());
keyLabel.addStyleName(style.jsonKey());
inlinePanel.add(keyLabel);
for (Widget child : inlineWidgets) {
inlinePanel.add(child);
}
if (hasSeparator) {
inlinePanel.add(new InlineLabel(SEPARATOR_TEXT));
}
return inlinePanel;
}
private static String indentation(int depth) {
return Strings.repeat(" ", depth);
}
private static List<Widget> formatString(
ApiService service, String rawText, PrettifierLinkFactory linkFactory) {
if (isLink(rawText)) {
List<Widget> response = Lists.newArrayList();
response.add(new InlineLabel("\""));
boolean createdExplorerLink = false;
try {
ApiMethod method = getMethodForUrl(service, rawText);
if (method != null) {
String explorerLink = createExplorerLink(service, rawText, method);
Widget linkObject = linkFactory.generateAnchor(rawText, explorerLink);
linkObject.addStyleName(style.jsonStringExplorerLink());
response.add(linkObject);
createdExplorerLink = true;
}
} catch (IndexOutOfBoundsException e) {
// Intentionally blank - this will only happen when iterating the method
// url template in parallel with the url components and you run out of
// components
}
if (!createdExplorerLink) {
Anchor linkObject = new Anchor(rawText, rawText, OPEN_IN_NEW_WINDOW);
linkObject.addStyleName(style.jsonStringLink());
response.add(linkObject);
}
response.add(new InlineLabel("\""));
return response;
} else {
JSONString encoded = new JSONString(rawText);
Widget stringText = new InlineLabel(encoded.toString());
stringText.addStyleName(style.jsonString());
return Lists.newArrayList(stringText);
}
}
private static String titleString(String name) {
return "\"" + name + "\": ";
}
/**
* Attempts to identify an {@link ApiMethod} corresponding to the given url.
* If one is found, a {@link java.util.Map.Entry} will be returned where the key is the
* name of the method, and the value is the {@link ApiMethod} itself. If no
* method is found, this will return {@code null}.
*/
@VisibleForTesting
static ApiMethod getMethodForUrl(ApiService service, String url) {
String apiLinkPrefix = Config.getBaseUrl() + service.basePath();
if (!url.startsWith(apiLinkPrefix)) {
return null;
}
// Only check GET methods since those are the only ones that can be returned
// in the response.
Iterable<ApiMethod> getMethods =
Iterables.filter(service.allMethods().values(), new Predicate<ApiMethod>() {
@Override
public boolean apply(ApiMethod input) {
return input.getHttpMethod() == HttpMethod.GET;
}
});
int paramIndex = url.indexOf("?");
String path = url.substring(0, paramIndex > 0 ? paramIndex : url.length());
for (ApiMethod method : getMethods) {
// Try to match the request URL with its method by comparing it to the
// method's rest base path URI template. To do this we have to remove the
// {...} placeholders.
String regex =
apiLinkPrefix + method.getPath().replaceAll("\\{[^\\/]+\\}", "[^\\/]+");
if (path.matches(regex)) {
return method;
}
}
return null;
}
/**
* Creates an Explorer link token (e.g.,
* #s/<service>/<version>/<method>) corresponding to the given request
* URL, given the method name and method definition returned by
* {@link #getMethodForUrl(ApiService, String)}.
*/
@VisibleForTesting
static String createExplorerLink(ApiService service, String url, ApiMethod method) {
UrlBuilder builder = new UrlBuilder();
// Add the basic information to the
builder.addRootNavigationItem(RootNavigationItem.ALL_VERSIONS)
.addService(service.getName(), service.getVersion())
.addMethodName(method.getId());
// Calculate the params from the path template and url.
URLFragment parsed = URLFragment.parseFragment(url);
Multimap<String, String> params = HashMultimap.create();
String pathTemplate = method.getPath();
if (pathTemplate.contains("{")) {
String urlPath = parsed.getPath().replaceFirst(Config.getBaseUrl() + service.basePath(), "");
String[] templateSections = pathTemplate.split("/");
String[] urlSections = urlPath.split("/");
for (int i = 0; i < templateSections.length; i++) {
if (templateSections[i].contains("{")) {
String paramName = templateSections[i].substring(1, templateSections[i].length() - 1);
params.put(paramName, urlSections[i]);
}
}
}
// Apply the params.
String fullUrl = builder.addQueryParams(params).toString();
// Check if the url had query parameters to add.
if (!parsed.getQueryString().isEmpty()) {
fullUrl = fullUrl + parsed.getQueryString();
}
return fullUrl;
}
private static boolean isLink(String value) {
return (value.startsWith("http://") || value.startsWith("https://")) && !value.contains("\n")
&& !value.contains("\t");
}
/**
* Create a drop down menu that allows the user to navigate to compatible methods for the
* specified resource.
*
* @param methods Methods for which to build the menu.
* @param service Service to which the methods correspond.
* @param objectToPackage Object which should be passed to the destination menus.
* @param linkFactory Factory that will be used to create links.
* @return A button that will show the menu that was generated or {@code null} if there are no
* compatible methods.
*/
private static PushButton createRequestMenu(final Collection<ApiMethod> methods,
final ApiService service, DynamicJso objectToPackage, PrettifierLinkFactory linkFactory) {
// Determine if a menu even needs to be generated.
if (methods.isEmpty()) {
return null;
}
// Create the parameters that will be passed to the destination menu.
String resourceContents = new JSONObject(objectToPackage).toString();
final Multimap<String, String> resourceParams =
ImmutableMultimap.of(UrlBuilder.BODY_QUERY_PARAM_KEY, resourceContents);
// Create the menu itself.
FlowPanel menuContents = new FlowPanel();
// Add a description of what the menu does.
Label header = new Label("Use this resource in one of the following methods:");
header.addStyleName(style.dropDownMenuItem());
menuContents.add(header);
// Add a menu item for each method.
for (ApiMethod method : methods) {
PushButton methodItem = new PushButton();
methodItem.addStyleName(style.dropDownMenuItem());
methodItem.addStyleName(style.selectableDropDownMenuItem());
methodItem.setText(method.getId());
menuContents.add(methodItem);
// When clicked, Navigate to the menu item.
UrlBuilder builder = new UrlBuilder();
String newUrl = builder
.addRootNavigationItem(RootNavigationItem.ALL_VERSIONS)
.addService(service.getName(), service.getVersion())
.addMethodName(method.getId())
.addQueryParams(resourceParams)
.toString();
methodItem.addClickHandler(linkFactory.generateMenuHandler(newUrl));
}
// Create the panel which will be disclosed.
final PopupPanel popupMenu = new PopupPanel(/* auto hide */ true);
popupMenu.setStyleName(style.dropDownMenuPopup());
FocusPanel focusContents = new FocusPanel();
focusContents.addMouseOutHandler(new MouseOutHandler() {
@Override
public void onMouseOut(MouseOutEvent event) {
popupMenu.hide();
}
});
focusContents.setWidget(menuContents);
popupMenu.setWidget(focusContents);
// Create the button which will disclose the menu.
final PushButton menuButton = new PushButton(new Image(resources.downArrow()));
menuButton.addStyleName(style.reusableResourceButton());
menuButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(ClickEvent event) {
popupMenu.setPopupPositionAndShow(new PositionCallback() {
@Override
public void setPosition(int offsetWidth, int offsetHeight) {
popupMenu.setPopupPosition(
menuButton.getAbsoluteLeft() + menuButton.getOffsetWidth() - offsetWidth,
menuButton.getAbsoluteTop() + menuButton.getOffsetHeight());
}
});
}
});
// Return only the button to the caller.
return menuButton;
}
}