diff options
Diffstat (limited to 'qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid')
13 files changed, 3157 insertions, 0 deletions
diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/ConnectionProxy.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/ConnectionProxy.java new file mode 100644 index 0000000000..435372a0d6 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/ConnectionProxy.java @@ -0,0 +1,287 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +// Misc Imports +import java.util.TimerTask; + +// JMS Imports +import javax.jms.Connection; +import javax.jms.ExceptionListener; +import javax.jms.JMSException; + +// Simple Logging Facade 4 Java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// QMF2 Imports +import org.apache.qpid.qmf2.common.BlockingNotifier; +import org.apache.qpid.qmf2.common.QmfException; +import org.apache.qpid.qmf2.console.Console; +import org.apache.qpid.qmf2.util.ConnectionHelper; + +/** + * Contains a Connection object under a "leasehold agreement" whereby the Connection (and associated Sessions and QMF + * Consoles) will expire after a period of time. + * <p> + * The idea here is to allow a user to create multiple Connection instances (for example to monitor multiple brokers) + * but by using the lease metaphor we can expire instances that haven't been used for some predetermined period. + * Using the leashold agreement means that we don't have to rely on users explicitly deleting Connections that they + * are no longer interested in, because obviously we can't rely on that :-) + * + * @author Fraser Adams + */ +public final class ConnectionProxy extends TimerTask implements ExceptionListener +{ + private static final Logger _log = LoggerFactory.getLogger(ConnectionProxy.class); + + private static final int MAX_WORKITEM_QUEUE_SIZE = 20; // Maximum number of items allowed on WorkItem queue. + + // Connections expire after 20 minutes of no use. + private static final int TIMEOUT_THRESHOLD = (20*60000)/ConnectionStore.PING_PERIOD; + + // Connections expire after 1 minute if they have never been dereferenced. + private static final int UNUSED_THRESHOLD = 60000/ConnectionStore.PING_PERIOD; + + private Connection _connection; + private Console _console; + private boolean _connected; + private int _expireCount; + private final ConnectionStore _store; + private final String _name; + private final String _url; + private final String _connectionOptions; + private final boolean _disableEvents; + + /** + * Actually create the Qpid Connection and QMF2 Console specified in the Constructor. + */ + private synchronized void createConnection() + { + //System.out.println("ConnectionProxy createConnection() name: " + _name + ", thread: " + Thread.currentThread().getId() + ", creating connection to " + _url + ", options " + _connectionOptions); + try + { + _connection = ConnectionHelper.createConnection(_url, _connectionOptions); + if (_connection != null) + { + _connection.setExceptionListener(this); + + // N.B. creating a Console with a notifier causes the internal WorkQueue to get populated, so care must + // be taken to manage its size. In a normal Console application the application would only declare this + // if there was an intention to retrieve work items, but in a fairly general REST API we can't guarantee + // that clients will. ConsoleLease acts to make the WorkQueue "circular" by deleting items from the + // front of the WorkQueue if it exceeds a particular size. + if (_disableEvents) + { + _console = new Console(_name, null, null, null); + _console.disableEvents(); + } + else + { + BlockingNotifier notifier = new BlockingNotifier(); + _console = new Console(_name, null, notifier, null); + } + _console.addConnection(_connection); + _connected = true; + _expireCount = UNUSED_THRESHOLD; + notifyAll(); + } + } + catch (Exception ex) + { + _log.info("Exception {} caught in ConnectionProxy constructor.", ex.getMessage()); + _connected = false; + } + } + + /** + * This method blocks until the Connection has been created. + */ + public synchronized void waitForConnection() + { + while (!_connected) + { + try + { + wait(); + } + catch (InterruptedException ie) + { + continue; + } + } + } + + /** + * This method blocks until the Connection has been created or timeout expires (or wait has been interrupted). + * @param timeout the maximum time in milliseconds to wait for notification of the connection's availability. + */ + public synchronized void waitForConnection(long timeout) + { + try + { + wait(timeout); + } + catch (InterruptedException ie) + { // Ignore + } + } + + /** + * Construct a Proxy to the specified Qpid Connection with the supplied name to be stored in the specified store. + * @param store The ConnectionStore that we want to store this ConnectionProxy in. + * @param name A unique name for the Connection that we want to create. + * @param url A Connection URL using one of the forms supported by {@link org.apache.qpid.qmf2.util.ConnectionHelper}. + * @param connectionOptions A set of connection options in the form supported by {@link org.apache.qpid.qmf2.util.ConnectionHelper}. + * @param disableEvents if true create a QMF Console Connection that can only perform synchronous + * operations like getObjects() and cannot do asynchronous things like Agent discovery or receive Events. + */ + public ConnectionProxy(final ConnectionStore store, final String name, + final String url, final String connectionOptions, final boolean disableEvents) + { + _connected = false; + _store = store; + _name = name; + _url = url; + _connectionOptions = connectionOptions; + _disableEvents = disableEvents; + } + + /** + * The exception listener for the underlying Qpid Connection. This is used to trigger the ConnectionProxy internal + * reconnect logic. N.B. ConnectionProxy uses its own reconnection logic for two reasons: firstly the Qpid auto + * retry mechanism has some undesireable and unreliable behaviours prior to Qpid version 0.16 and secondly the + * Qpid auto retry mechanism is transparent whereas we actually <b>want</b> to detect connection failures in the REST + * API so that we can report failures back to the client. + * @param jmse The JMSException that has caused onException to be triggered. + */ + public void onException(JMSException jmse) + { + _log.info("ConnectionProxy onException {}", jmse.getMessage()); + _connected = false; + } + + /** + * This method is called periodically by {@link org.apache.qpid.restapi.ConnectionStore} to carry out a number + * of housekeeping tasks. It checks if the Qpid Connection is still connected and if not it attempts to reconnect + * it also checks whether the Connection "lease" has run out and if it has it tidies up the Connection. Finally + * it restricts the size of the QMF2 WorkItem queue as the REST API has no control over whether a client is or + * is not interested in being notified of QMF2 Events. + */ + public void run() + { + if (_connected) + { + //System.out.println("ConnectionProxy name: " + _name + ", thread: " + Thread.currentThread().getId() + ", WorkItem count = " + _console.getWorkitemCount()); + + while (_console.getWorkitemCount() > MAX_WORKITEM_QUEUE_SIZE) + { + _console.getNextWorkitem(); + } + + _expireCount--; + //System.out.println("ConnectionProxy name: " + _name + ", thread: " + Thread.currentThread().getId() + ", expireCount = " + _expireCount); + if (_expireCount == 0) + { + _store.delete(_name); + } + } + else + { + createConnection(); + } + } + + /** + * Stops scheduled housekeeping, destroys any attached QMF2 Console instances then closes the Qpid Connection. + */ + public synchronized void close() + { + //System.out.println("ConnectionProxy close() name: " + _name + ", thread: " + Thread.currentThread().getId() + ", expireCount = " + _expireCount); + + cancel(); + + try + { + _console.destroy(); + _connection.close(); + } + catch (Exception e) + { // Log and Ignore + _log.info("ConnectionProxy close() caught Exception {}", e.getMessage()); + } + } + + /** + * Retrieves the QMF2 Console that we've associated with this Connection. + * @return The QMF2 Console that we've associated with this Connection. + */ + public Console getConsole() + { + _expireCount = TIMEOUT_THRESHOLD; + return _console; + } + + /** + * Returns whether or not the Connection is currently connected to the broker. This is used by the REST API to + * tell any clients about the Connection state. + * @return true if currently connected or false if not. + */ + public boolean isConnected() + { + _expireCount = TIMEOUT_THRESHOLD; + return _connected; + } + + /** + * Returns the Connection URL String used to create the Connection. + * @return The Connection URL String used to create the Connection. + */ + public String getUrl() + { + _expireCount = TIMEOUT_THRESHOLD; + return _url; + } + + /** + * Returns the Connection options String used to create the Connection. + * @return The Connection options String used to create the Connection. + */ + public String getConnectionOptions() + { + _expireCount = TIMEOUT_THRESHOLD; + return _connectionOptions; + } + + /** + * Returns a String representation of a ConnectionProxy. + * @return The String representation of this ConnectionProxy Object. + */ + @Override + public String toString() + { + // The reason we use JSON.toMap on the string is because it is fairly tolerant and doesn't need pure JSON + // if we then call JSON.fromMap we get a pure JSON String. + return "{" + "\"url\":\"" + _url + "\",\"connectionOptions\":" + + JSON.fromMap(JSON.toMap(_connectionOptions)) + "}"; + } +} + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/ConnectionStore.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/ConnectionStore.java new file mode 100644 index 0000000000..79b85c9c42 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/ConnectionStore.java @@ -0,0 +1,138 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +// Misc Imports +import java.util.Map; +import java.util.Timer; +import java.util.concurrent.ConcurrentHashMap; + +// QMF2 Imports +import org.apache.qpid.qmf2.common.QmfException; +import org.apache.qpid.qmf2.console.Console; + +/** + * A ConnectionStore is a container for Qpid Connection Objects, or rather it's a container for ConnectionProxy + * Objects which wrap Qpid Connections and provide some additional housekeeping behaviour necessary for a distributed + * system. The ConnectionStore schedules regular housekeeping tasks to be executed on the ConnectionProxy Objects + * using a java.util.Timer, which scales fairly well. + * + * @author Fraser Adams + */ +public class ConnectionStore +{ + /** + * This represents the time between "pings" to the stored ConnectionProxy Objects. The pings run scheduled tasks + * such as attempting reconnection if the broker has disconnected and checking lease timeouts. + */ + public static final int PING_PERIOD = 5000; + + /** + * This Map is used to associate connection names with their ConnectionProxies. Note that the names are prefixed + * internally with the authenticated user name to prevent users accidentally (or maliciously) sharing connections. + */ + private Map<String, ConnectionProxy> _connections = new ConcurrentHashMap<String, ConnectionProxy>(); + + /** + * Create a Timer used to schedule regular checks on ConnectionProxy Objects to see that they are still in use. + * In essence ConnectionProxy Objects behave in a similar way to RMI Leases in that if they are not used + * (dereferenced) within a particular period it is assumed that the client has lost interest and they are reaped. + */ + private Timer _timer = new Timer(true); + + /** + * Creates a new ConnectionProxy Object with the given name, which in turn creates a Qpid Connection using the + * supplied Connection URL and options. In addition it schedules some regular housekeeping on the ConnectionProxy + * to enable it to manage Connection failures and perform what amounts to distributed garbage collection. + * When a ConnectionProxy with a given name has been created it is cached and subsequent calls to this method + * will return the cached instance. If an new instance is required one must first call the delete method. + * @param name A unique name for the Connection that we want to create. + * @param url A Connection URL using one of the forms supported by {@link org.apache.qpid.qmf2.util.ConnectionHelper}. + * @param opts A set of connection options in the form supported by {@link org.apache.qpid.qmf2.util.ConnectionHelper}. + * @param disableEvents if true create a QMF Console Connection that can only perform synchronous + * operations like getObjects() and cannot do asynchronous things like Agent discovery or receive Events. + * @return the ConnectionProxy Object created or cached by this method. + */ + public synchronized ConnectionProxy create(final String name, final String url, final String opts, + final boolean disableEvents) + { + ConnectionProxy connection = _connections.get(name); + if (connection == null) + { + connection = new ConnectionProxy(this, name, url, opts, disableEvents); + _connections.put(name, connection); + _timer.schedule(connection, 0, PING_PERIOD); + } + return connection; + } + + /** + * Closes the named Connection, stops its scheduled housekeeping and removes from the store. + * @param name the name of the Connection that we want to delete. + */ + public synchronized void delete(final String name) + { + ConnectionProxy connection = _connections.get(name); + if (connection != null) + { + connection.close(); + _connections.remove(name); + } + } + + /** + * Retrieves the named Connection from the store. + * @param name the name of the Connection that we want to retrieve. + * @return the ConnectionProxy instance with the given name. + */ + public ConnectionProxy get(final String name) + { + return _connections.get(name); + } + + /** + * Return a Map of ConnectionProxies associated with a given user. Note that this method is fairly inefficient + * as the main _connections Map contains all ConnectionProxies and we normally do a lookup by prefixing the + * key with the user name in order to demultiplex. It is done this way as we need to look up ConnectionProxy + * for each API call, whereas looking up all Connections for a user is likely to be rarely called. + * @param user the security principal associated with a particular user. + * @return a Map of ConnectionProxy objects associated with the given user. + */ + public Map<String, ConnectionProxy> getAll(final String user) + { + Map<String, ConnectionProxy> map = new ConcurrentHashMap<String, ConnectionProxy>(); + String prefix = user + "."; + int prefixLength = prefix.length(); + for (Map.Entry<String, ConnectionProxy> entry : _connections.entrySet()) + { + String key = entry.getKey(); + if (key.startsWith(prefix)) + { + key = key.substring(prefixLength); + ConnectionProxy value = entry.getValue(); + map.put(key, value); + } + } + return map; + } +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/FileServer.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/FileServer.java new file mode 100644 index 0000000000..2cbb1140b1 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/FileServer.java @@ -0,0 +1,369 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; + +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_PARTIAL; +import static java.net.HttpURLConnection.HTTP_MOVED_PERM; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; + +/** + * FileServer is a fairly simple HTTP File Server that can be used to serve files from the root directory specified + * during construction. + * <p> + * Although this is a relatively simple File Server it is still able to serve large files as it uses streaming, in + * addition it uses the HTTP Range/Content-Range/Content-Length Headers to allow resuming of partial downloads + * from clients that support it. + * + * @author Fraser Adams + */ +public class FileServer implements Server +{ + /** + * HashMap mapping file extension to MIME type for common types. + * TODO make this set of mappings configurable via properties or similar mechanism. + */ + private static Map<String, String> _mimeTypes = new HashMap<String, String>(); + static + { + _mimeTypes.put("htm", "text/html"); + _mimeTypes.put("html", "text/html"); + _mimeTypes.put("txt", "text/plain"); + _mimeTypes.put("asc", "text/plain"); + _mimeTypes.put("xml", "text/xml"); + _mimeTypes.put("css", "text/css"); + _mimeTypes.put("htc", "text/x-component"); + _mimeTypes.put("gif", "image/gif"); + _mimeTypes.put("jpg", "image/jpeg"); + _mimeTypes.put("jpeg", "image/jpeg"); + _mimeTypes.put("png", "image/png"); + _mimeTypes.put("ico", "image/x-icon"); + _mimeTypes.put("mp3", "audio/mpeg"); + _mimeTypes.put("m3u", "audio/mpeg-url"); + _mimeTypes.put("js", "application/x-javascript"); + _mimeTypes.put("pdf", "application/pdf"); + _mimeTypes.put("doc", "application/msword"); + _mimeTypes.put("ppt", "application/mspowerpoint"); + _mimeTypes.put("xls", "application/excel"); + _mimeTypes.put("ogg", "application/x-ogg"); + _mimeTypes.put("zip", "application/octet-stream"); + _mimeTypes.put("exe", "application/octet-stream"); + _mimeTypes.put("class", "application/octet-stream"); + } + + private final File _home; + private final boolean _allowDirectoryListing; + + /** + * URL-encodes everything between "/"-characters. Encodes spaces as '%20' instead of '+'. + * + * @param uri the uri to be encoded. + * @return the encoded uri as a String. + */ + private String encodeUri(final String uri) + { + StringBuilder encodedUri = new StringBuilder(); + StringTokenizer st = new StringTokenizer(uri, "/ ", true); + while (st.hasMoreTokens()) + { + String tok = st.nextToken(); + if (tok.equals("/")) + { + encodedUri.append("/"); + } + else if (tok.equals(" ")) + { + encodedUri.append("%20"); + } + else + { + try + { + encodedUri.append(URLEncoder.encode(tok, "UTF-8")); + } + catch (UnsupportedEncodingException uee) + { + } + } + } + return encodedUri.toString(); + } + + /** + * Renders a number in a more "human readable" format providing a bytes/KB/MB/GB format depending on the size. + * + * @param number the number to be rendered. + * @return a String representation of the number in a more human readable form. + */ + private String renderNumber(float number) + { + if (number < 1000) + { + return String.format("%.0f", number) + " bytes"; + } + else if (number < 1000000) + { + number /= 1000.0f; + return String.format("%.1f", number) + " KB"; + } + else if (number < 1000000000) + { + number /= 1000000.0f; + return String.format("%.1f", number) + " MB"; + } + else + { + number /= 1000000000.0f; + return String.format("%.1f", number) + " GB"; + } + } + + /** + * Construct an instance of FileServer. + * + * @param home the path name of the root directory that we wish this FieServer to serve via HTTP. + * @param allowDirectoryListing a flag that if set will serve a directory listing to the client and thus enable + * browsing to sub-directories of the home directory. N.B. protection has been put in place to mitigate + * against the possibility of serving directories that may be parents of the root directory. + */ + public FileServer(final String home, final boolean allowDirectoryListing) + { + _home = new File(home); + _allowDirectoryListing = allowDirectoryListing; + } + + /** + * Called by the Web Server to allow a Server to handle a GET request. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doGet(final HttpTransaction tx) throws IOException + { + String user = tx.getPrincipal() != null ? tx.getPrincipal() : "none"; + String path = tx.getRequestURI(); + + //System.out.println(); + //System.out.println("FileServer doGet " + path + ", user: " + user); + //System.out.println("thread = " + Thread.currentThread().getId()); + //tx.logRequest(); + + // If the _home filesystem that we use as a root to serve files from is not a directory then + // we sent an error response and return. + if (!_home.isDirectory()) + { + tx.sendResponse(HTTP_INTERNAL_ERROR, "text/plain", + "500 Internal Server Error: given document root is not a directory."); + return; + } + + //String path = tx.getRequestURI(); + + // Prohibit getting out of _home directory + if (path.startsWith("..") || path.endsWith("..") || path.indexOf("../") >= 0) + { + tx.sendResponse(HTTP_FORBIDDEN, "text/plain", "403 Forbidden: Won't serve ../ for security reasons."); + return; + } + + File file = new File(_home, path); + if (!file.exists()) + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found: File " + path + " not found."); + return; + } + + // List the directory, if necessary + if (file.isDirectory()) + { + File directory = file; + // Browsers get confused without '/' after the directory, send a redirect. + if (!path.endsWith("/")) + { + path += "/"; + tx.setHeader("Location", path); + tx.sendResponse(HTTP_MOVED_PERM, "text/html", + "<html><body>Redirected: <a href=\"" + path + "\">" + path + "</a></body></html>"); + return; + } + + // First try index.html and index.htm + if (new File(directory, "index.html").exists()) + { + file = new File(_home, path + "/index.html"); + } + else if (new File(directory, "index.htm").exists()) + { + file = new File(_home, path + "/index.htm"); + } + else if (_allowDirectoryListing) + { // No index file, list the directory + StringBuilder response = new StringBuilder("<html><body><h1>Directory " + path + "</h1><br/>"); + + if (path.length() > 1) + { + String u = path.substring(0, path.length() - 1); + int slash = u.lastIndexOf('/'); + if (slash >= 0 && slash < u.length()) + { + response.append("<b><a href=\"" + path.substring(0, slash + 1) + "\">..</a></b><br/>"); + } + } + + String[] files = directory.list(); + for (String name : files) + { + File current = new File(directory, name); + boolean isDir = current.isDirectory(); + boolean isFile = current.isFile(); + if (isDir) + { + response.append("<b>"); + name += "/"; + } + + response.append("<a href=\"" + encodeUri(path + name) + "\">" + name + "</a>"); + + if (isFile) + { // If it's a file show the file size + response.append(" <font size=2>(" + renderNumber(current.length()) + ")</font>"); + } + response.append("<br/>"); + if (isDir) + { + response.append("</b>"); + } + } + tx.sendResponse(HTTP_OK, "text/html", response.toString()); + return; + } + else + { + tx.sendResponse(HTTP_FORBIDDEN, "text/plain", "403 Forbidden: No directory listing."); + return; + } + } + + try + { + // Get MIME type from file name extension, if possible + String fileName = file.getCanonicalPath(); + String mime = null; + int dot = fileName.lastIndexOf('.'); + if (dot >= 0) + { + String fileExtension = fileName.substring(dot + 1).toLowerCase(); + mime = _mimeTypes.get(fileExtension); + } + + if (mime == null) + { + mime = "application/octet-stream"; + } + + // Use Range header allow download resuming. + long startFrom = 0; + long length = file.length(); + + String range = tx.getHeader("Range"); + if (range != null) + { + if (range.startsWith("bytes=")) + { + range = range.substring("bytes=".length()); + + int minus = range.indexOf('-'); + if (minus > 0) + { + range = range.substring(0, minus); + } + + try + { + startFrom = Long.parseLong(range); + } + catch (NumberFormatException nfe) + { + } + } + } + + FileInputStream is = new FileInputStream(file); + is.skip(startFrom); + + int status = (startFrom == 0) ? HTTP_OK : HTTP_PARTIAL; + tx.setHeader("Content-Length", "" + (length - startFrom)); + tx.setHeader("Content-Range", "" + startFrom + "-" + (length - 1) + "/" + length); + + tx.sendResponse(status, mime, is); + } + catch (IOException ioe) + { + tx.sendResponse(HTTP_FORBIDDEN, "text/plain", "403 Forbidden: Reading file failed."); + } + } + + /** + * Called by the Web Server to allow a Server to handle a POST request. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doPost(final HttpTransaction tx) throws IOException + { + tx.sendResponse(HTTP_BAD_METHOD, "text/plain", "405 Bad Method."); + } + + /** + * Called by the Web Server to allow a Server to handle a PUT request. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doPut(final HttpTransaction tx) throws IOException + { + tx.sendResponse(HTTP_BAD_METHOD, "text/plain", "405 Bad Method."); + } + + /** + * Called by the Web Server to allow a Server to handle a DELETE request. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doDelete(final HttpTransaction tx) throws IOException + { + tx.sendResponse(HTTP_BAD_METHOD, "text/plain", "405 Bad Method."); + } +} + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/HttpTransaction.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/HttpTransaction.java new file mode 100644 index 0000000000..323ba29861 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/HttpTransaction.java @@ -0,0 +1,165 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +import java.io.IOException; +import java.io.InputStream; + +/** + * An HttpTransaction encapsulates an HTTP request received and a response to be generated in one HTTP request/response + * "transaction". It provides methods for examining the request from the client, and for building and sending the + * response from the server. + * <p> + * Note that the HttpTransaction interface isn't intended to be completely general, rather it is intended to abstract + * the services needed in org.apache.qpid.restapi in such a way as to be neutral as to whether the Web Server is + * based on com.sun.net.httpserver.HttpServer or javax.servlet.http.HttpServlet. + * <p> + * The Server and HttpTransaction interfaces are intended to provide abstractions to enable the "business logic" to + * be isolated from the actual Web Server implementation choice, so for example a concrete HttpTransaction implementation + * could be created by wrapping a com.sun.net.httpserver.HttpExchange, but equally another implementation could wrap + * javax.servlet.http.HttpServletRequest and javax.servlet.http.HttpServletResponse so for example an HttpServlet + * could delegate to a Server instance passing the HttpTransaction it constructed from the HttpServletRequest and + * HttpServletResponse. + * + * @author Fraser Adams + */ +public interface HttpTransaction +{ + /** + * Log the HTTP request information (primarily for debugging purposes) + */ + public void logRequest(); + + /** + * Return the content passed in the request from the client as a Stream. + * @return the content passed in the request from the client as a Stream. + */ + public InputStream getRequestStream() throws IOException; + + /** + * Return the content passed in the request from the client as a String. + * @return the content passed in the request from the client as a String. + */ + public String getRequestString() throws IOException; + + /** + * Return the content passed in the request from the client as a byte[]. + * @return the content passed in the request from the client as a byte[]. + */ + public byte[] getRequest() throws IOException; + + /** + * Send the content passed as a String as an HTTP response back to the client. + * @param status the HTTP status code e.g. 200 for OK. + * @param mimeType the mimeType of the response content e.g. text/plain, text/xml, image/jpeg etc. + * @param content the content of the response passed as a String. + */ + public void sendResponse(final int status, final String mimeType, final String content) throws IOException; + + /** + * Send the content passed as a byte[] as an HTTP response back to the client. + * @param status the HTTP status code e.g. 200 for OK. + * @param mimeType the mimeType of the response content e.g. text/plain, text/xml, image/jpeg etc. + * @param content the content of the response passed as a byte[]. + */ + public void sendResponse(final int status, final String mimeType, final byte[] content) throws IOException; + + /** + * Send the content passed as an InputStream as an HTTP response back to the client. + * @param status the HTTP status code e.g. 200 for OK. + * @param mimeType the mimeType of the response content e.g. text/plain, text/xml, image/jpeg etc. + * @param is the content of the response passed as an InputStream. + */ + public void sendResponse(final int status, final String mimeType, final InputStream is) throws IOException; + + /** + * Returns the Internet Protocol (IP) address of the client or last proxy that sent the request. + * @return the Internet Protocol (IP) address of the client or last proxy that sent the request. + */ + public String getRemoteAddr(); + + /** + * Returns the fully qualified name of the client or the last proxy that sent the request. + * @return the fully qualified name of the client or the last proxy that sent the request. + */ + public String getRemoteHost(); + + /** + * Returns the Internet Protocol (IP) source port of the client or last proxy that sent the request. + * @return the Internet Protocol (IP) source port of the client or last proxy that sent the request. + */ + public int getRemotePort(); + + /** + * Returns a String containing the name of the current authenticated user. If the user has not been authenticated, + * the method returns null. + * @return a String containing the name of the user making this request; null if the user has not been authenticated. + */ + public String getPrincipal(); + + /** + * Returns the name of the HTTP method with which this request was made, for example, GET, POST, or PUT. + * @return a String specifying the name of the method with which this request was made. + */ + public String getMethod(); + + /** + * Returns the part of this request's URL from the protocol name up to the query string in the first line of + * the HTTP request. + * @return a String containing the part of the URL from the protocol name up to the query string. + */ + public String getRequestURI(); + + /** + * Sets a response header with the given name and value. If the header had already been set, the new value + * overwrites the previous one. + * @param name a String specifying the header name. + * @param value a String specifying the header value. If it contains octet string, it should be encoded according + * to RFC 2047. + */ + public void setHeader(final String name, final String value); + + /** + * Returns the value of the specified request header as a String. If the request did not include a header of the + * specified name, this method returns null. If there are multiple headers with the same name, this method returns + * the first head in the request. The header name is case insensitive. You can use this method with any request + * header. + * @param name a String specifying the header name. + * @return a String containing the value of the requested header, or null if the request does not have a header of + * that name. + */ + public String getHeader(final String name); + + /** + * Returns the String value of the specified cookie. + * @param name a String specifying the cookie name. + */ + public String getCookie(final String name); + + /** + * Adds the specified cookie to the response. This method can be called multiple times to set more than one cookie. + * @param name a String specifying the cookie name. + * @param value a String specifying the cookie value. + */ + public void addCookie(final String name, final String value); +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/JSON.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/JSON.java new file mode 100644 index 0000000000..dfc1e0c85d --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/JSON.java @@ -0,0 +1,265 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +// QMF2 imports +import org.apache.qpid.qmf2.common.Handle; +import org.apache.qpid.qmf2.common.ObjectId; +import org.apache.qpid.qmf2.common.QmfData; +import org.apache.qpid.qmf2.common.QmfDescribed; +import org.apache.qpid.qmf2.common.SchemaClassId; +import org.apache.qpid.qmf2.common.WorkItem; +import org.apache.qpid.qmf2.console.QmfConsoleData; + +/** + * This class provides a number of convenience methods to serialise and deserialise JSON strings to/from Java + * Collections or QmfData objects. + * + * The JSONMapParser class used here is largely a direct copy of org.apache.qpid.messaging.util.AddressParser + * as it provides a handy mechanism to parse a JSON String into a Map which is the only JSON requirement that + * we really need for QMF. Originally this code simply did "import org.apache.qpid.messaging.util.AddressParser;" + * but there's a restriction/bug on the core AddressParser whereby it serialises integers into Java Integer + * which means that long integer values aren't correctly stored. It's this restriction that gives Java Address + * Strings a defacto 2GB queue size. I should really provide a patch for the *real* AddressParser but it's better + * to add features covering "shorthand" forms for large values (e.g. k/K, m/M, g/G for kilo, mega, giga etc.) + * to both the Java and C++ AddressParser to ensure maximum consistency. + * + * Because the JSON requirements for the REST API are relatively modest a fairly simple serialisation/deserialisation + * mechanism is included here and in the modified AddressParser classes rather than incorporating a full-blown + * Java JSON parser. This helps avoid a bot of bloat and keeps dependencies limited to the core qpid classes. + * + * @author Fraser Adams + */ +public final class JSON +{ + /** + * Serialise an Object to JSON. Note this isn't a full JSON serialisation of java.lang.Object, rather it only + * includes types that are relevant to QmfData Objects and the types that may be contained therein. + * @param item the Object that we wish to serialise to JSON. + * @return the JSON String encoding. + */ + public final static String fromObject(final Object item) + { + if (item == null) + { + return ""; + } + else + { + if (item instanceof Map) + { // Check if the value part is an ObjectId and serialise appropriately + Map map = (Map)item; + if (map.containsKey("_object_name")) + { // Serialise "ref" properties as String versions of ObjectId to match encoding used in fromQmfData() + return "\"" + new ObjectId(map) + "\""; + } + else + { + return fromMap(map); + } + } + else if (item instanceof List) + { + return fromList((List)item); + } + else if (item instanceof QmfData) + { + return fromQmfData((QmfData)item); + } + else if (item instanceof WorkItem) + { + return fromWorkItem((WorkItem)item); + } + else if (item instanceof String) + { + return "\"" + item + "\""; + } + else if (item instanceof byte[]) + { + return "\"" + new String((byte[])item) + "\""; + } + else if (item instanceof UUID) + { + return "\"" + item.toString() + "\""; + } + else + { + return item.toString(); + } + } + } + + /** + * Encode the Map contents so we can use the same code for fromMap and fromQmfData as the latter also needs + * to encode _object_id and _schema_id. This returns a String that needs to be topped and tailed with braces. + * @param m the Map that we wish to serialise to JSON. + * @return the String encoding of the contents. + */ + @SuppressWarnings("unchecked") + private final static String encodeMapContents(final Map m) + { + Map<String, Object> map = (Map<String, Object>)m; + StringBuilder buffer = new StringBuilder(512); + int size = map.size(); + int count = 1; + for (Map.Entry<String, Object> entry : map.entrySet()) + { + String key = (String)entry.getKey(); + buffer.append("\"" + key + "\":"); + + Object value = entry.getValue(); + buffer.append(fromObject(value)); + if (count++ < size) + { + buffer.append(","); + } + } + return buffer.toString(); + } + + /** + * Serialise a Map to JSON. + * @param m the Map that we wish to serialise to JSON. + * @return the JSON String encoding. + */ + @SuppressWarnings("unchecked") + public final static String fromMap(final Map m) + { + return "{" + encodeMapContents(m) + "}"; + } + + /** + * Serialise a List to JSON. + * @param list the List that we wish to serialise to JSON. + * @return the JSON String encoding. + */ + public final static String fromList(final List list) + { + StringBuilder buffer = new StringBuilder(512); + buffer.append("["); + int size = list.size(); + int count = 1; + for (Object item : list) + { + buffer.append(fromObject(item)); + if (count++ < size) + { + buffer.append(","); + } + } + buffer.append("]"); + return buffer.toString(); + } + + /** + * Serialise a QmfData Object to JSON. If the Object is a QmfConsoleData we serialise the ObjectId as a String + * which is the same encoding used for the various "ref" properies in fromObject(). + * @param data the QmfData that we wish to serialise to JSON. + * @return the JSON String encoding. + */ + public final static String fromQmfData(final QmfData data) + { + String consoleDataInfo = ""; + + if (data instanceof QmfConsoleData) + { + QmfConsoleData consoleData = (QmfConsoleData)data; + SchemaClassId sid = consoleData.getSchemaClassId(); + long[] ts = consoleData.getTimestamps(); + + String objectId = "\"_object_id\":\"" + consoleData.getObjectId().toString() + "\","; + String schemaId = "\"_schema_id\":{" + + "\"_package_name\":\"" + sid.getPackageName() + + "\",\"_class_name\":\"" + sid.getClassName() + + "\",\"_type\":\"" + sid.getType() + + "\",\"_hash\":\"" + sid.getHashString() + + "\"},"; + + String timestamps = "\"_update_ts\":" + ts[0] + "," + + "\"_create_ts\":" + ts[1] + "," + + "\"_delete_ts\":" + ts[2] + ","; + + consoleDataInfo = objectId + schemaId + timestamps; + } + + return "{" + consoleDataInfo + encodeMapContents(data.mapEncode()) + "}"; + } + + /** + * Serialise a WorkItem Object to JSON. + * @param data the WorkItem that we wish to serialise to JSON. + * @return the JSON String encoding. + */ + public final static String fromWorkItem(final WorkItem data) + { + // TODO There are a couple of WorkItem types that won't serialise correctly - SubscriptionIndicationWorkItem + // and MethodCallWorkItem. Their params require a custom serialiser - though they probably won't be used + // from a REST API so they've been parked for now. + String type = "\"_type\":\"" + data.getType() + "\","; + Handle handle = data.getHandle(); + String handleString = handle == null ? "" : "\"_handle\":\"" + handle.getCorrelationId() + "\","; + String params = "\"_params\":" + fromObject(data.getParams()); + + return "{" + type + handleString + params + "}"; + } + + /** + * Create a Map from a JSON String. + * The JSONMapParser class used here is largely a direct copy of org.apache.qpid.messaging.util.AddressParser + * as it provides a handy mechanism to parse a JSON String into a Map which is the only JSON requirement that + * we really need for QMF. Originally this code simply did "import org.apache.qpid.messaging.util.AddressParser;" + * but there's a restriction/bug on the core AddressParser whereby it serialises integers into Java Integer + * which means that long integer values aren't correctly stored. It's this restriction that gives Java Address + * Strings a defacto 2GB queue size. I should really provide a patch for the *real* AddressParser but it's better + * to add features covering "shorthand" forms for large values (e.g. k/K, m/M, g/G for kilo, mega, giga etc.) + * to both the Java and C++ AddressParser to ensure maximum consistency. + * @param json the JSON String that we wish to decode into a Map. + * @return the Map encoding of the JSON String. + */ + public final static Map toMap(final String json) + { + if (json == null || json.equals("")) + { + return Collections.EMPTY_MAP; + } + else + { + return new JSONMapParser(json).map(); + } + } + + /** + * Create a QmfData from a JSON String. + * @param json the JSON String that we wish to decode into a QmfData. + * @return the QmfData encoding of the JSON String. + */ + public final static QmfData toQmfData(final String json) + { + return new QmfData(toMap(json)); + } +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/JSONMapParser.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/JSONMapParser.java new file mode 100644 index 0000000000..9a6354e099 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/JSONMapParser.java @@ -0,0 +1,402 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.qpid.messaging.Address; +import org.apache.qpid.messaging.util.Lexer; +import org.apache.qpid.messaging.util.Lexicon; +import org.apache.qpid.messaging.util.ParseError; +import org.apache.qpid.messaging.util.Token; + +/** + * AddressParser + * + * This JSONMapParser class used is mostly just a direct copy of org.apache.qpid.messaging.util.AddressParser + * as it provides a handy mechanism to parse a JSON String into a Map which is the only JSON requirement that + * we really need for QMF. + * + * Unfortunately there's a restriction/bug on the core AddressParser whereby it serialises integers into Java Integer + * which means that long integer values aren't correctly stored. It's this restriction that gives Java Address + * Strings a defacto 2GB queue size. I should really provide a patch for the *real* AddressParser but it's better + * to add features covering "shorthand" forms for large values (e.g. k/K, m/M, g/G for kilo, mega, giga etc.) + * to both the Java and C++ AddressParser to ensure maximum consistency. + * + * This AddressParser clone largely uses the classes from org.apache.qpid.messaging.util like the real AddressParser + * but unfortunately the Parser class was package scope rather than public, so I've done some "copy and paste reuse" + * to add the Parser methods into this version of AddressParser. I've also removed the bits that actually create + * an Address as all we need to do is to parse into a java.util.Map. + */ + +public class JSONMapParser +{ + + private static Lexicon lxi = new Lexicon(); + + private static Token.Type LBRACE = lxi.define("LBRACE", "\\{"); + private static Token.Type RBRACE = lxi.define("RBRACE", "\\}"); + private static Token.Type LBRACK = lxi.define("LBRACK", "\\["); + private static Token.Type RBRACK = lxi.define("RBRACK", "\\]"); + private static Token.Type COLON = lxi.define("COLON", ":"); + private static Token.Type SEMI = lxi.define("SEMI", ";"); + private static Token.Type SLASH = lxi.define("SLASH", "/"); + private static Token.Type COMMA = lxi.define("COMMA", ","); + private static Token.Type NUMBER = lxi.define("NUMBER", "[+-]?[0-9]*\\.?[0-9]+"); + // Make test for true and false case insensitive. N.B. org.apache.qpid.messaging.util.AddressParser test is + // case sensitive - not sure if that's a bug/oversight in the AddressParser?? + private static Token.Type TRUE = lxi.define("TRUE", "(?i)True"); + private static Token.Type FALSE = lxi.define("FALSE", "(?i)False"); + private static Token.Type ID = lxi.define("ID", "[a-zA-Z_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?"); + private static Token.Type STRING = lxi.define("STRING", "\"(?:[^\\\"]|\\.)*\"|'(?:[^\\']|\\.)*'"); + private static Token.Type ESC = lxi.define("ESC", "\\\\[^ux]|\\\\x[0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]"); + private static Token.Type SYM = lxi.define("SYM", "[.#*%@$^!+-]"); + private static Token.Type WSPACE = lxi.define("WSPACE", "[\\s]+"); + private static Token.Type EOF = lxi.eof("EOF"); + + private static Lexer LEXER = lxi.compile(); + +/********** Copied from org.apache.qpid.messaging.util.Parser as Parser was package scope **********/ + + private List<Token> tokens; + private int idx = 0; + + Token next() + { + return tokens.get(idx); + } + + boolean matches(Token.Type ... types) + { + for (Token.Type t : types) + { + if (next().getType() == t) + { + return true; + } + } + return false; + } + + Token eat(Token.Type ... types) + { + if (types.length > 0 && !matches(types)) + { + throw new ParseError(next(), types); + } + else + { + Token t = next(); + idx += 1; + return t; + } + } + +/***************************************************************************************************/ + + public static List<Token> lex(String input) + { + return LEXER.lex(input); + } + + static List<Token> wlex(String input) + { + List<Token> tokens = new ArrayList<Token>(); + for (Token t : lex(input)) + { + if (t.getType() != WSPACE) + { + tokens.add(t); + } + } + return tokens; + } + + static String unquote(String st, Token tok) + { + StringBuilder result = new StringBuilder(); + for (int i = 1; i < st.length() - 1; i++) + { + char ch = st.charAt(i); + if (ch == '\\') + { + char code = st.charAt(i+1); + switch (code) + { + case '\n': + break; + case '\\': + result.append('\\'); + break; + case '\'': + result.append('\''); + break; + case '"': + result.append('"'); + break; + case 'a': + result.append((char) 0x07); + break; + case 'b': + result.append((char) 0x08); + break; + case 'f': + result.append('\f'); + break; + case 'n': + result.append('\n'); + break; + case 'r': + result.append('\r'); + break; + case 't': + result.append('\t'); + break; + case 'u': + result.append(decode(st.substring(i+2, i+6))); + i += 4; + break; + case 'v': + result.append((char) 0x0b); + break; + case 'o': + result.append(decode(st.substring(i+2, i+4), 8)); + i += 2; + break; + case 'x': + result.append(decode(st.substring(i+2, i+4))); + i += 2; + break; + default: + throw new ParseError(tok); + } + i += 1; + } + else + { + result.append(ch); + } + } + + return result.toString(); + } + + static char[] decode(String hex) + { + return decode(hex, 16); + } + + static char[] decode(String code, int radix) + { + return Character.toChars(Integer.parseInt(code, radix)); + } + + /** + * This method is the the main place where this class differs from org.apache.qpid.messaging.util.AddressParser. + * If the token type is a STRING it checks for a number (with optional floating point) ending in K, M or G + * and if it is of this type it creates a Long out of the float value multiplied by 1000, 1000000 or 1000000000. + * If the token type is a NUMBER it tries to parse into an Integer like AddressParser, but if that fails it + * tries to parse onto a Long which allows much larger integer values to be used. + */ + static Object tok2obj(Token tok) + { + Token.Type type = tok.getType(); + String value = tok.getValue(); + if (type == STRING) + { + value = unquote(value, tok); + + // Initial regex to check for a number (with optional floating point) ending in K, M or G + if (value.matches("([0-9]*\\.[0-9]+|[0-9]+)\\s*[kKmMgG]")) + { + // If it's a numeric string perform the relevant multiplication and return as a Long. + int length = value.length(); + if (length > 1) + { + String end = value.substring(length - 1, length).toUpperCase(); + String start = value.substring(0, length - 1).trim(); + + if (end.equals("K")) + { + return Long.valueOf((long)(Float.parseFloat(start) * 1000.0)); + } + else if (end.equals("M")) + { + return Long.valueOf((long)(Float.parseFloat(start) * 1000000.0)); + } + else if (end.equals("G")) + { + return Long.valueOf((long)(Float.parseFloat(start) * 1000000000.0)); + } + } + + return value; + } + else + { + return value; + } + } + else if (type == NUMBER) + { + // This block extends the original AddressParser handling of NUMBER. It first attempts to parse the String + // into an Integer in order to be backwards compatible with AddressParser however if this causes a + // NumberFormatException it then attempts to parse into a Long. + if (value.indexOf('.') >= 0) + { + return Double.valueOf(value); + } + else + { + try + { + return Integer.decode(value); + } + catch (NumberFormatException nfe) + { + return Long.decode(value); + } + } + } + else if (type == TRUE) + { + return true; + } + else if (type == FALSE) + { + return false; + } + else + { + return value; + } + } + + public JSONMapParser(String input) + { + this.tokens = wlex(input); // Copied from org.apache.qpid.messaging.util.Parser + this.idx = 0; // Copied from org.apache.qpid.messaging.util.Parser + } + + public Map<Object,Object> map() + { + eat(LBRACE); + + Map<Object,Object> result = new HashMap<Object,Object>(); + while (true) + { + if (matches(NUMBER, STRING, ID, LBRACE, LBRACK)) + { + keyval(result); + if (matches(COMMA)) + { + eat(COMMA); + } + else if (matches(RBRACE)) + { + break; + } + else + { + throw new ParseError(next(), COMMA, RBRACE); + } + } + else if (matches(RBRACE)) + { + break; + } + else + { + throw new ParseError(next(), NUMBER, STRING, ID, LBRACE, LBRACK, + RBRACE); + } + } + + eat(RBRACE); + return result; + } + + void keyval(Map<Object,Object> map) + { + Object key = value(); + eat(COLON); + Object val = value(); + map.put(key, val); + } + + Object value() + { + if (matches(NUMBER, STRING, ID, TRUE, FALSE)) + { + return tok2obj(eat()); + } + else if (matches(LBRACE)) + { + return map(); + } + else if (matches(LBRACK)) + { + return list(); + } + else + { + throw new ParseError(next(), NUMBER, STRING, ID, LBRACE, LBRACK); + } + } + + List<Object> list() + { + eat(LBRACK); + + List<Object> result = new ArrayList<Object>(); + + while (true) + { + if (matches(RBRACK)) + { + break; + } + else + { + result.add(value()); + if (matches(COMMA)) + { + eat(COMMA); + } + else if (matches(RBRACK)) + { + break; + } + else + { + throw new ParseError(next(), COMMA, RBRACK); + } + } + } + + eat(RBRACK); + return result; + } + +} diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/QpidRestAPI.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/QpidRestAPI.java new file mode 100644 index 0000000000..b7ff7d9cfe --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/QpidRestAPI.java @@ -0,0 +1,206 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.concurrent.Executors; +import java.util.List; + +import com.sun.net.httpserver.HttpServer; + +import org.apache.qpid.qmf2.util.GetOpt; + +import org.apache.qpid.restapi.httpserver.Authenticator; +import org.apache.qpid.restapi.httpserver.Delegator; + +/** + * Note QpidRestAPI makes use of the Java 1.6 "Easter Egg" HttpServer and associated classes to create a simple + * HTTP REST interface to Qpid and QMF2 functionality. + * <p> + * Because HttpServer is in the com.sun.net.httpserver.HttpServer namespace it is technically not part of core Java + * and so may not be supported in all JREs, however it seems to be present in the OpenJDK Runtime Environment that is + * installed on many Linux variants. It is documented here <a href="http://docs.oracle.com/javase/6/docs/jre/api/net/httpserver/spec/overview-summary.html">com.sun.net.httpserver</a> + * <p> + * The reason for choosing com.sun.net.httpserver.HttpServer is simply in order to provide a very lightweight and + * dependency free approach to creating an HTTP Proxy. Clearly other approaches such as full-blown Servlet containers + * and the like might provide a better mechanism for some environments, but HttpServer really is very simple to use. + * <p> + * If there is a desire to use Servlets rather than HttpServer it should be fairly straightforward as two interfaces + * have been provided (Server and HttpTransaction) that abstract the key behaviour, so for example a concrete HttpTransaction + * implementation could be created by wrapping a com.sun.net.httpserver.HttpExchange, but equally another implementation + * could wrap javax.servlet.http.HttpServletRequest and javax.servlet.http.HttpServletResponse, so for example an + * HttpServlet could delegate to a Server instance passing the Conversation it constructed from the HttpServletRequest + * and HttpServletResponse in a similar way that our Delegator implementation of HttpHandler delegates to the Servers. + * + * <pre> + * Usage: QpidRestAPI [options] + * + * Options: + * -h, --help + * show this help message and exit + * -a <address>, --broker-addr=<address> + * broker-addr is in the form: [username/password@] + * hostname | ip-address [:<port>] ex: localhost, + * 10.1.1.7:10000, broker-host:10000, + * guest/guest@localhost + * Default is the host QpidRestAPI runs on & port 5672. + * -i <address>, --addr=<address> + * the hostname of the QpidRestAPI default is the wildcard address + * (Bind to a specific address on a multihomed host) + * -p <port>, --port=<port> + * the port the QpidRestAPI is bound to default is 8080 + * -b <backlog>, --backlog=<backlog> + * the socket backlog default is 10 + * -w <directory>, --webroot=<directory> + * the directory of the QpidRestAPI Web Site default is qpid-web + * </pre> + * @author Fraser Adams + */ +public class QpidRestAPI +{ + private static final String _usage = + "Usage: QpidRestAPI [options]\n"; + + private static final String _description = + "Creates an HTTP REST interface to enable us to send messages to Qpid brokers and use QMF2 via HTTP.\n"; + + private static final String _options = + "Options:\n" + + " -h, --help show this help message and exit.\n" + + " -a <address>, --broker-addr=<address>\n" + + " broker-addr is in the form: [username/password@]\n" + + " hostname | ip-address [:<port>] e.g.\n" + + " localhost, 10.1.1.7:10000, broker-host:10000,\n" + + " guest/guest@localhost, guest/guest@broker-host:10000\n" + + " default is the host QpidRestAPI runs on & port 5672.\n" + + " -i <address>, --addr=<address>\n" + + " the hostname of the QpidRestAPI.\n" + + " default is the wildcard address.\n" + + " (Bind to a specific address on a multihomed host).\n" + + " -p <port>, --port=<port>\n" + + " the port the QpidRestAPI is bound to.\n" + + " default is 8080.\n" + + " -b <backlog>, --backlog=<backlog>\n" + + " the socket backlog.\n" + + " default is 10\n" + + " -w <directory>, --webroot=<directory>\n" + + " the directory of the QpidRestAPI Web Site.\n" + + " default is qpid-web.\n"; + + + /** + * Construct and start an instance of QpidRestAPI. This class used a Delegator class to delegate to underlying + * Server instances that actually implement the business logic of the REST API. + * + * @param addr the the address the QpidRestAPI is bound to (null = default). + * @param port the port the QpidRestAPI is bound to. + * @param broker the address of the Qpid broker to connect to (null = default). + * @param backlog the socket backlog. + * @param webroot the directory of the QpidRestAPI Web Site. + */ + public QpidRestAPI(final String addr, final int port, String broker, final int backlog, final String webroot) + throws IOException + { + final InetSocketAddress inetaddr = (addr == null) ? new InetSocketAddress(port) : + new InetSocketAddress(addr, port); + final HttpServer server = HttpServer.create(inetaddr, backlog); + + broker = (broker == null) ? inetaddr.getAddress().getHostAddress() + ":5672" : broker; + + Delegator fileserver = new Delegator(new FileServer(webroot + "/web", true)); + Delegator qpidserver = new Delegator(new QpidServer(broker)); + + Authenticator authenticator = new Authenticator(this.getClass().getCanonicalName(), webroot + "/authentication"); + + server.setExecutor(Executors.newCachedThreadPool()); + server.createContext("/", fileserver); + server.createContext("/ui", fileserver).setAuthenticator(authenticator); + server.createContext("/qpid/connection", qpidserver).setAuthenticator(authenticator); + server.start(); + } + + /** + * Runs QpidRestAPI. + * @param args the command line arguments. + */ + public static void main(String[] args) throws IOException + { + String logLevel = System.getProperty("amqj.logging.level"); + logLevel = (logLevel == null) ? "FATAL" : logLevel; // Set default log level to FATAL rather than DEBUG. + System.setProperty("amqj.logging.level", logLevel); + + String[] longOpts = {"help", "host=", "port=", "backlog=", "webroot="}; + try + { + String addr = null; + int port = 8080; + String broker = null; + int backlog = 10; + String webroot = "qpid-web"; + + GetOpt getopt = new GetOpt(args, "ha:i:p:b:w:", longOpts); + List<String[]> optList = getopt.getOptList(); + String[] cargs = {}; + cargs = getopt.getEncArgs().toArray(cargs); + + for (String[] opt : optList) + { + if (opt[0].equals("-h") || opt[0].equals("--help")) + { + System.out.println(_usage); + System.out.println(_description); + System.out.println(_options); + System.exit(1); + } + else if (opt[0].equals("-a") || opt[0].equals("--broker-addr")) + { + broker = opt[1]; + } + else if (opt[0].equals("-i") || opt[0].equals("--addr")) + { + addr = opt[1]; + } + else if (opt[0].equals("-p") || opt[0].equals("--port")) + { + port = Integer.parseInt(opt[1]); + } + else if (opt[0].equals("-b") || opt[0].equals("--backlog")) + { + backlog = Integer.parseInt(opt[1]); + } + else if (opt[0].equals("-w") || opt[0].equals("--webroot")) + { + webroot = opt[1]; + } + } + + QpidRestAPI restAPI = new QpidRestAPI(addr, port, broker, backlog, webroot); + } + catch (IllegalArgumentException e) + { + System.out.println(_usage); + System.out.println(e.getMessage()); + System.exit(1); + } + } +} + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/QpidServer.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/QpidServer.java new file mode 100644 index 0000000000..f1bdcf85bc --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/QpidServer.java @@ -0,0 +1,654 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +// Simple Logging Facade 4 Java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// Misc Imports +import java.io.IOException; +import java.net.InetAddress; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +// QMF2 Imports +import org.apache.qpid.qmf2.common.ObjectId; +import org.apache.qpid.qmf2.common.QmfData; +import org.apache.qpid.qmf2.common.QmfException; +import org.apache.qpid.qmf2.console.Agent; +import org.apache.qpid.qmf2.console.Console; +import org.apache.qpid.qmf2.console.MethodResult; +import org.apache.qpid.qmf2.console.QmfConsoleData; + +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; + + +/** + * This class implements the REST API proper, it is an implementation of the Server interface which allows us to + * abstract the "business logic" from the Web Server implementation technology (HttpServer/Servlet etc.). + * <p> + * The REST API is as follows: + * <pre> + * PUT: <host>:<port>/qpid/connection/<name> + * HTTP body: {"url":<url>,"connectionOptions":<connectionOptions>[,"disableEvents":true;]} + * <url>: A string containing an AMQP connection URL as used in the qpid::messaging API. + * <connectionOptions>: A JSON string containing connectionOptions in the form specified in the + * qpid::messaging API. + * + * This method creates a Qpid Connection Object with the name <name> using the specified url and options. + * + * The optional disableEvents property is used to start up a QMF Connection which can only + * do synchronous calls such as getObjects() and can't receive Agent updates or QMF2 Events. + * + * DELETE: <host>:<port>/qpid/connection/<name> + * + * This method deletes the Qpid Connection Object with the name <name>. + * + * POST: <host>:<port>/qpid/connection/<name>/object/<ObjectId> + * HTTP body: {"_method_name":<method>,"_arguments":<inArgs>} + * <method>: A string containing the QMF2 method name e.g. "getLogLevel", "setLogLevel", "create", "delete". + * <inArgs>: A JSON string containing the method arguments e.g. {"level":"debug+:Broker"} for setLogLevel. + * HTTP response: A JSON string containing the response e.g. {"level":"notice+"} for getLogLevel (may be empty). + * + * This method invokes the QMF2 method <method> with arguments <inArgs> on the object <ObjectId> + * + * GET: <host>:<port>/qpid/connection; + * + * This method retrieves (as a JSON string) the complete set of connections currently enabled on the Server. + * The returned JSON string represents a Map keyed by the connection name/handle. The value part is itself a + * Map containing the Connection URL and Connection Options used to create the Qpid Connection. e.g. + * {"8c5116d6-46a1-489b-93d8-fde525e0d76d":{"url":"0.0.0.0:5672","connectionOptions":{}}} + * + * GET: <host>:<port>/qpid/connection/<name> + * + * This method retrieves (as a JSON string) a Map containing the Connection URL and Connection Options used to + * create the Qpid Connection with the specified <name>. e.g. + * {"url":"0.0.0.0:5672","connectionOptions":{}} + * + * GET: <host>:<port>/qpid/connection/<name>/console/objects/<className> + * + * This method retrieves (as a JSON string) the list of QmfConsoleData objects with the specified <className> + * using the QMF2 Console associated with the Qpid Connection Object with the name <name>. + * This is the REST equivalent of Console.getObjects(className) which searches across all packages and all Agents + * for the specified className. + * + * GET: <host>:<port>/qpid/connection/<name>/console/objects/<packageName>/<className> + * !!not yet implemented!! + * This method retrieves (as a JSON string) the list of QmfConsoleData objects with the specified + * <packageName> and <className> using the QMF2 Console associated with the Qpid Connection Object + * with the name <name>. + * This is the REST equivalent of Console.getObjects(packageName, className) which searches across all Agents + * for the specified className in the package packageName. + * + * GET: <host>:<port>/qpid/connection/<name>/object/<ObjectId> + * This method retrieves (as a JSON string) the QmfConsoleData object with the specified <ObjectId> + * using the QMF2 Console associated with the Qpid Connection Object with the name <name>. + * This is the REST implementation of getObjects(oid) it is also the equivalent of the QmfConsoleData refresh() + * method where an object can update its state. + * Note that there's a slight variance on the QMF2 API here as that returns an array/list of QmfConsoledData + * objects for all queries, however as the call with ObjectId will only return one or zero objects this + * implementation returns the single QmfConsoleData object found or a 404 Not Found response. + * + * N.B. that the ManagementAgent on the broker appears not to set the timestamp properties in the response to this + * call, which means that they get set to current time in the QmfConsoleData, this is OK for _update_ts but not + * for _create_ts and _delete_ts. Users of this call should be aware of that in their own code. + * + * GET: <host>:<port>/qpid/connection/<name>/console/objects + * GET: <host>:<port>/qpid/connection/<name>/console/classes (synonyms) + * + * This method retrieves (as a JSON string) the list of SchemaClassId for all available Schema for all Agents. + * This is the REST equivalent of Console.getClasses() which searches across all Agents. + * + * GET: <host>:<port>/qpid/connection/<name>/console/classes/<agentName> + * + * This method retrieves (as a JSON string) the list of SchemaClassId for all available Schema on the + * Agent named <agentName>. + * This is the REST equivalent of Console.getClasses(agent) which searches across a specified Agent. + * + * GET: <host>:<port>/qpid/connection/<name>/console/packages + * + * This method retrieves (as a JSON string) the list of all known Packages for all Agents. + * This is the REST equivalent of Console.getPackages() which searches across all Agents. + * + * GET: <host>:<port>/qpid/connection/<name>/console/packages/<agentName> + * + * This method retrieves (as a JSON string) the list of all known Packages on the Agent named <agentName>. + * This is the REST equivalent of Console.getPackages(agent) which searches across a specified Agent. + * + * GET: <host>:<port>/qpid/connection/<name>/console/agents + * GET: <host>:<port>/qpid/connection/<name>/console/agent (synonyms) + * + * This method retrieves (as a JSON string) the list of all known Agents. + * This is the REST equivalent of Console.getAgents(). + * + * GET: <host>:<port>/qpid/connection/<name>/console/agent/<agentName> + * + * This method retrieves (as a JSON string) the Agent named <agentName>. + * This is the REST equivalent of Console.getAgent(agentName). + * + * GET: <host>:<port>/qpid/connection/<name>/console/address + * + * This method retrieves (as a JSON string) the AMQP address this Console is listening to. + * This is the REST equivalent of Console.getAddress(). + * + * GET: <host>:<port>/qpid/connection/<name>/console/workItemCount + * + * This method retrieves (as a plain text string) the count of pending WorkItems that can be retrieved + * from this Console. + * This is the REST equivalent of Console.getWorkItemCount(). + * + * GET: <host>:<port>/qpid/connection/<name>/console/nextWorkItem + * + * This method retrieves (as a JSON string) the next pending work item from this Console (N.B. this method + * blocks until a WorkItem is available so should only be called asynchronously e.g. via AJAX). + * This is the REST equivalent of Console.getNextWorkitem(). + * </pre> + * @author Fraser Adams + */ +public final class QpidServer implements Server +{ + private static final Logger _log = LoggerFactory.getLogger(QpidServer.class); + + private ConnectionStore _connections = new ConnectionStore(); + private String _defaultBroker = null; + + public QpidServer(final String broker) + { + _defaultBroker = broker; + } + + /** + * Handle a "/qpid/connection/<connectionName>/console/objects" or + * "/qpid/connection/<connectionName>/console/objects/" request, + * in other words a request for information about an object resource specified by the remaining path. + * Only the GET method is valid for this resource and it is in effect the REST mapping for Console.getObjects(). + */ + private void sendGetObjectsResponse(final HttpTransaction tx, final Console console, final String path) throws IOException + { + String[] params = path.split("/"); + if (params.length == 1) + { // With one parameter we call getObjects(className) + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getObjects(params[0]))); + } + else if (params.length == 2) + { // With two parameters we call getObjects(packageName, className) + //System.out.println("params = " + params[0] + ", " + params[1]); + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getObjects(params[0], params[1]))); + } + else if (params.length == 3) + { // TODO With three parameters we call getObjects(packageName, className, agent) + //System.out.println("params = " + params[0] + ", " + params[1] + ", " + params[2]); + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Too many parameters for objects GET request."); + } else { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Too many parameters for objects GET request."); + } + } + + /** + * Called by the Web Server to allow a Server to handle a GET request. + * The HTTP GET URL structure for the REST API is specified above in the overall class documentation. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doGet(final HttpTransaction tx) throws IOException + { + String path = tx.getRequestURI(); + + //System.out.println(); + //System.out.println("QpidServer doGet " + path); + //tx.logRequest(); + + if (path.startsWith("/qpid/connection/")) + { + path = path.substring(17); + + String user = tx.getPrincipal(); // Using the principal lets different users use the default connection. + if (path.length() == 0) + { // handle "/qpid/connection/" request with unspecified connection (returns list of available connections). + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(_connections.getAll(user))); + } + else + { // if path.length() > 0 we're dealing with a specified Connection so extract the name and look it up. + String connectionName = path; + int i = path.indexOf("/"); + if (i > 0) // Can use > rather than >= as we've already tested for "/qpid/connection/" above. + { + connectionName = path.substring(0, i); + path = path.substring(i + 1); + } + else + { + path = ""; + } + + connectionName = user + "." + connectionName; + + // TODO what if we don't want a default connection. + // If necessary we create a new "default" Connection associated with the user. The default connection + // attempts to connect to a broker specified in the QpidRestAPI config (or default 0.0.0.0:5672). + if (connectionName.equals(user + ".default")) + { + ConnectionProxy defaultConnection = _connections.get(connectionName); + if (defaultConnection == null) + { + defaultConnection = _connections.create(connectionName, _defaultBroker, "", false); + + // Wait a maximum of 1000ms for the underlying Qpid Connection to become available. If we + // don't do this the first call using the default will return 404 Not Found. + defaultConnection.waitForConnection(1000); + } + } + + // Find the Connection with the name extracted from the URI. + ConnectionProxy connection = _connections.get(connectionName); + + if (connection == null) + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + else if (!connection.isConnected()) + { + tx.sendResponse(HTTP_INTERNAL_ERROR, "text/plain", "500 Broker Disconnected."); + } + else + { + if (path.length() == 0) + { // handle request for information about a specified Console + tx.sendResponse(HTTP_OK, "application/json", connection.toString()); + } + else + { // In this block we are dealing with resources associated with a specified connectionName. + // path describes the resources specifically related to "/qpid/connection/<connectionName>" + Console console = connection.getConsole(); + + if (path.startsWith("console/objects/")) + { // Get information about specified objects. + path = path.substring(16); + sendGetObjectsResponse(tx, console, path); + } + else if (path.startsWith("console/objects") && path.length() == 15) + { // If objects is unspecified treat as a synonym for classes. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getClasses())); + } + else if (path.startsWith("console/address/")) + { // Get the Console AMQP Address + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getAddress())); + } + else if (path.startsWith("console/address") && path.length() == 15) + { // Get the Console AMQP Address + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getAddress())); + } + else if (path.startsWith("console/workItemCount/")) + { // Returns the count of pending WorkItems that can be retrieved. + tx.sendResponse(HTTP_OK, "text/plain", "" + console.getWorkitemCount()); + } + else if (path.startsWith("console/workItemCount") && path.length() == 21) + { // Returns the count of pending WorkItems that can be retrieved. + tx.sendResponse(HTTP_OK, "text/plain", "" + console.getWorkitemCount()); + } + else if (path.startsWith("console/nextWorkItem/")) + { // Obtains the next pending work item, or null if none available. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getNextWorkitem())); + } + else if (path.startsWith("console/nextWorkItem") && path.length() == 20) + { // Obtains the next pending work item, or null if none available. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getNextWorkitem())); + } + else if (path.startsWith("console/agents") && path.length() == 14) + { // Get information about all available Agents. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getAgents())); + } + else if (path.startsWith("console/agent/")) + { // Get information about a specified Agent. + Agent agent = console.getAgent(path.substring(14)); + if (agent == null) + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + else + { + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(agent)); + } + } + else if (path.startsWith("console/agent") && path.length() == 13) + { // If agent is unspecified treat as a synonym for agents. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getAgents())); + } + else if (path.startsWith("console/classes/")) + { // Get information about the classes for a specified Agent + path = path.substring(16); // Get Agent name + + // TODO handle getClasses() for specified Agent + tx.sendResponse(HTTP_NOT_IMPLEMENTED, "text/plain", "501 getClasses() for specified Agent not yet implemented."); + } + else if (path.startsWith("console/classes") && path.length() == 15) + { // Get information about all the classes for all Agents + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getClasses())); + } + else if (path.startsWith("console/packages/")) + { // Get information about the packages for a specified Agent + path = path.substring(17); // Get Agent name + + // TODO handle getPackages() for specified Agent. + tx.sendResponse(HTTP_NOT_IMPLEMENTED, "text/plain", "501 getPackages() for specified Agent not yet implemented."); + } + else if (path.startsWith("object/")) + { + /** + * This is the REST implementation of getObjects(oid) it is also the equivalent of + * the QmfConsoleData refresh() method where an object can update its state. + * N.B. that the ManagementAgent on the broker appears not to set the timestamp properties + * in the response to this call, which means that they get set to current time in the + * QmfConsoleData, this is OK for _update_ts but not for _create_ts and _delete_ts + * users of this call should be aware of that in their own code. + */ + path = path.substring(7); + + // The ObjectId has been passed in the URI, create a real ObjectId + ObjectId oid = new ObjectId(path); + + List<QmfConsoleData> objects = console.getObjects(oid); + if (objects.size() == 0) + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + else + { + // Not that in a departure from the QMF2 API this returns the QmfConsoleData object + // rather than a list of size one. Perhaps the APIs should be completely consistent + // but this response seems more convenient. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(objects.get(0))); + } + } + else if (path.startsWith("console/packages") && path.length() == 16) + { // Get information about all the packages for all Agents + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(console.getPackages())); + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } + } + } + } + else if (path.startsWith("/qpid/connection")) + { // handle "/qpid/connection" request with unspecified connection (returns list of available connections). + String user = tx.getPrincipal(); // Using the principal lets different users use the default connection. + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(_connections.getAll(user))); + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } + + /** + * Called by the Web Server to allow a Server to handle a POST request. + * <pre> + * POST: <host>:<port>/qpid/connection/<name>/object/<ObjectId> + * HTTP body: {"_method_name":<method>,"_arguments":<inArgs>} + * <method>: A string containing the QMF2 method name e.g. "getLogLevel", "setLogLevel", "create", "delete". + * <inArgs>: A JSON string containing the method arguments e.g. {"level":"debug+:Broker"} for setLogLevel. + * HTTP response: A JSON string containing the response e.g. {"level":"notice+"} for getLogLevel (may be empty). + * + * This method invokes the QMF2 method <method> with arguments <inArgs> on the object <ObjectId> + * </pre> + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + @SuppressWarnings("unchecked") + public void doPost(final HttpTransaction tx) throws IOException + { + String path = tx.getRequestURI(); + + //System.out.println(); + //System.out.println("QpidServer doPost " + path); + //System.out.println("thread = " + Thread.currentThread().getId()); + + if (path.startsWith("/qpid/connection/")) + { + path = path.substring(17); + String user = tx.getPrincipal(); + + String connectionName = path; + int i = path.indexOf("/"); + if (i > 0) // Can use > rather than >= as we've already tested for "/qpid/connection/" above. + { + connectionName = user + "." + path.substring(0, i); + path = path.substring(i + 1); + + // Find the Connection with the name extracted from the URI. + ConnectionProxy connection = _connections.get(connectionName); + + if (connection == null) + { + _log.info("QpidServer.doPost path: {} Connection not found.", tx.getRequestURI()); + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + else if (!connection.isConnected()) + { + tx.sendResponse(HTTP_INTERNAL_ERROR, "text/plain", "500 Broker Disconnected."); + } + else + { // If we get this far we should have found a Qpid Connection so retrieve the QMF2 Console Object. + Console console = connection.getConsole(); + + if (path.startsWith("object/")) + { + path = path.substring(7); + + // The ObjectId has been passed in the URI create an ObjectId and retrieve the Agent Name. + ObjectId oid = new ObjectId(path); + String agentName = oid.getAgentName(); + + // The qpidd ManagementAgent doesn't populate AgentName, if it's empty assume it's the broker. + agentName = agentName.equals("") ? "broker" : agentName; + + Agent agent = console.getAgent(agentName); // Find the Agent we got the QmfData from. + if (agent == null) + { + _log.info("QpidServer.doPost path: {} Agent: {} not found.", tx.getRequestURI(), agentName); + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + else + { // If we get this far we can create the Object and invoke the method. + // We can create a QmfConsoleData with nothing but an ObjectId and the agent. + QmfConsoleData object = new QmfConsoleData(Collections.EMPTY_MAP, agent); + object.setObjectId(oid); + + String request = tx.getRequestString(); + _log.info("QpidServer.doPost path: {} body: {}", tx.getRequestURI(), request); + + //System.out.println(request); + String method = ""; + try + { + Map<String, Object> reqMap = JSON.toMap(request); + + method = (String)reqMap.get("_method_name"); + Object arguments = reqMap.get("_arguments"); + + Map args = (arguments instanceof Map) ? (Map)arguments : null; + //System.out.println("method: " + method + ", args: " + args); + + // Parse the args if present into a QmfData (needed by invokeMethod). + QmfData inArgs = (args == null) ? new QmfData() : new QmfData(args); + + // Invoke the specified method on the QmfConsoleData we've created. + MethodResult results = null; + + _log.info("invokeMethod: {}", request); + results = object.invokeMethod(method, inArgs); + tx.sendResponse(HTTP_OK, "application/json", JSON.fromObject(results)); + } + catch (QmfException qmfe) + { + _log.info("QpidServer.doPost() caught Exception {}", qmfe.getMessage()); + tx.sendResponse(HTTP_INTERNAL_ERROR, "text/plain", "invokeMethod(" + + method + ") -> " + qmfe.getMessage()); + } + catch (Exception e) + { + _log.info("QpidServer.doPost() caught Exception {}", e.getMessage()); + tx.sendResponse(HTTP_INTERNAL_ERROR, "text/plain", "500 " + e.getMessage()); + } + } + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } + + /** + * <pre> + * PUT: <host>:<port>/qpid/connection/<name> + * HTTP body: {"url":<url>,"connectionOptions":<connectionOptions>[,"disableEvents":true;]} + * <url>: A string containing an AMQP connection URL as used in the qpid::messaging API. + * <connectionOptions>: A JSON string containing connectionOptions in the form specified in the + * qpid::messaging API. + * </pre> + * Called by the Web Server to allow a Server to handle a PUT request. + * This method creates a Qpid Connection Object with the name <name> using the specified url and options. + * <p> + * The optional disableEvents property is used to start up a QMF Connection which can only + * do synchronous calls such as getObjects() and can't receive Agent updates or QMF2 Events. + * <p> + * N.B. It is possible for the Qpid broker to be unavailable when this method is called so it is actually a + * ConnectionProxy that is created. This method waits for up to 1000ms for the underlying Qpid Connection to + * become available and then returns. Clients should be aware that this method successfully returning only + * implies that the ConnectionProxy is in place and the underlying Qpid Connection may not be available. + * If the broker is down the ConnectionProxy will periodically attempt to reconnect, but whilst it is down + * the REST API will return a 500 Broker Disconnected response to any PUT or POST call. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + @SuppressWarnings("unchecked") + public void doPut(final HttpTransaction tx) throws IOException + { + String path = tx.getRequestURI(); + //tx.logRequest(); + + if (path.startsWith("/qpid/connection/")) + { + path = path.substring(17); + String user = tx.getPrincipal(); + String request = tx.getRequestString(); + _log.info("QpidServer.doPut path: {} body: {}", tx.getRequestURI(), request); + + String name = user + "." + path; + + try + { + // The PUT request is a JSON string containing a url String property and a connectionOptions + // property which is itself a JSON String. + Map<String, String> reqMap = JSON.toMap(request); + + String url = reqMap.get("url"); + url = url.equals("") ? _defaultBroker : url; + + // Extract the connectionOptions property and check its type is a MAP + Object options = reqMap.get("connectionOptions"); + Map optionsMap = (options instanceof Map) ? (Map)options : null; + + // Turn the connectionOptions Map back into a JSON String. Note that we can't just get + // connectionOptions as a String from reqMap as it is sent as JSON and the JSON.toMap() + // call on the POST request will fully parse it. + String connectionOptions = JSON.fromObject(optionsMap); + + boolean disableEvents = false; + String disableEventsString = reqMap.get("disableEvents"); + if (disableEventsString != null) + { + disableEvents = disableEventsString.equalsIgnoreCase("true"); + } + + ConnectionProxy proxy = _connections.create(name, url, connectionOptions, disableEvents); + + // Wait a maximum of 1000ms for the underlying Qpid Connection to become available then return. + proxy.waitForConnection(1000); + tx.sendResponse(HTTP_CREATED, "text/plain", "201 Created."); + } + catch (Exception e) + { + _log.info("QpidServer.doPut() caught Exception {}", e.getMessage()); + tx.sendResponse(HTTP_INTERNAL_ERROR, "text/plain", "500 " + e.getMessage()); + } + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } + + /** + * Called by the Web Server to allow a Server to handle a DELETE request. + * + * DELETE: <host>:<port>/qpid/connection/<name> + * + * This method deletes the Qpid Connection Object with the name <name>. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doDelete(final HttpTransaction tx) throws IOException + { + String path = tx.getRequestURI(); + //tx.logRequest(); + + if (path.startsWith("/qpid/connection/")) + { + path = path.substring(17); + String user = tx.getPrincipal(); + String name = user + "." + path; + + //System.out.println("Deleting " + name); + _log.info("QpidServer.doDelete path: {}", tx.getRequestURI()); + + _connections.delete(name); + tx.sendResponse(HTTP_OK, "text/plain", "200 Deleted."); + } + else + { + tx.sendResponse(HTTP_NOT_FOUND, "text/plain", "404 Not Found."); + } + } +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/Server.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/Server.java new file mode 100644 index 0000000000..e7f5d61219 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/Server.java @@ -0,0 +1,95 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi; + +import java.io.IOException; + +/** + * A Server represents a handler that runs within a Web server, which is invoked to process HTTP exchanges. + * Servers receive and respond to requests from Web clients that are encapsulated into HttpTransactions. + * <p> + * The Server and HttpTransaction interfaces are intended to provide abstractions to enable the "business logic" to + * be isolated from the actual Web Server implementation choice, so for example a concrete HttpTransaction implementation + * could be created by wrapping a com.sun.net.httpserver.HttpExchange, but equally another implementation could wrap + * javax.servlet.http.HttpServletRequest and javax.servlet.http.HttpServletResponse so for example an HttpServlet + * could delegate to a Server instance passing the HttpTransaction it constructed from the HttpServletRequest and + * HttpServletResponse. + * + * @author Fraser Adams + */ +public interface Server +{ + /** + * Called by the Web Server to allow a Server to handle a GET request. + * <p> + * The GET method should be safe, that is, without any side effects for which users are held responsible. For + * example, most form queries have no side effects. If a client request is intended to change stored data, the + * request should use some other HTTP method. + * <p> + * The GET method should also be idempotent, meaning that it can be safely repeated. Sometimes making a method safe + * also makes it idempotent. For example, repeating queries is both safe and idempotent, but buying a product online + * or modifying data is neither safe nor idempotent. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doGet(HttpTransaction tx) throws IOException; + + /** + * Called by the Web Server to allow a Server to handle a POST request. + * <p> + * The HTTP POST method allows the client to send data of unlimited length to the Web server a single time and is + * useful when posting information such as credit card numbers. + * <p> + * This method does not need to be either safe or idempotent. Operations requested through POST can have side + * effects for which the user can be held accountable, for example, updating stored data or buying items online. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doPost(HttpTransaction tx) throws IOException; + + /** + * Called by the Web Server to allow a Server to handle a PUT request. + * <p> + * The PUT operation allows a client to place a file on the server and is similar to sending a file by FTP. + * <p> + * This method does not need to be either safe or idempotent. Operations that doPut performs can have side effects + * for which the user can be held accountable. When using this method, it may be useful to save a copy of the + * affected URL in temporary storage. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doPut(HttpTransaction tx) throws IOException; + + /** + * Called by the Web Server to allow a Server to handle a DELETE request. + * <p> + * The DELETE operation allows a client to remove a document or Web page from the server. + * <p> + * This method does not need to be either safe or idempotent. Operations requested through DELETE can have side + * effects for which users can be held accountable. When using this method, it may be useful to save a copy of the + * affected URL in temporary storage. + * + * @param tx the HttpTransaction containing the request from the client and used to send the response. + */ + public void doDelete(HttpTransaction tx) throws IOException; +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/Authenticator.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/Authenticator.java new file mode 100644 index 0000000000..1083cb48df --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/Authenticator.java @@ -0,0 +1,151 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi.httpserver; + +// Simple Logging Facade 4 Java +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Properties; +import java.util.Timer; +import java.util.TimerTask; + +import com.sun.net.httpserver.BasicAuthenticator; + +/** + * This class implements a simple com.sun.net.httpserver.BasicAuthenticator. Clearly it's not very secure being a + * BasicAuthenticator that takes a plain (well Base64 encoded) username/password. + * TODO Clearly something more secure needs to be implemented..... + * + * This class creates a Timer used to schedule regular checks on the account.properties file and if it has changed + * it reloads the cache used for looking up the credentials. The poll period is every 10 seconds which should be OK + * for checking account updates. The class only updates the cache if the account.properties file has actually changed. + * Polling isn't ideal, but for this application it's probably no big deal. With Java 7 there is a Watch Service API + * https://blogs.oracle.com/thejavatutorials/entry/watching_a_directory_for_changes that allows asynchronous + * notification of changes, but it's an unnecessary dependency on Java 7 for this application. + * + * @author Fraser Adams + */ +public class Authenticator extends BasicAuthenticator +{ + private static final int CHECK_PERIOD = 10000; // Check every 10 seconds if the account properties have been changed. + private static final String ACCOUNT_FILENAME = "account.properties"; + private static final Logger _log = LoggerFactory.getLogger(Authenticator.class); + + private File _file; + private Properties _accountCache; + private long _accountFileLastModified = 0; // Used to check for updates to the account properties. + + /** + * Create a Timer used to schedule regular checks on the account.properties file. + */ + private Timer _timer = new Timer(true); + + /** + * This private inner class is a fairly trivial TimerTask whose run() method simply calls checkAccountFile() + * in the main Authenticator class to check for account changes. + */ + private final class CacheUpdater extends TimerTask + { + public void run() + { + checkAccountFile(); + } + } + + /** + * Construct the Authenticator. This fires up the CacheUpdater TimerTask to periodically check for changes. + * @param realm the authentication realm to use. + * @param path the path of the directory holding the account properties file. + */ + public Authenticator(final String realm, final String path) + { + super(realm); + + String accountPathname = path + "/" + ACCOUNT_FILENAME; + _file = new File(accountPathname); + + if (_file.exists()) + { + CacheUpdater updater = new CacheUpdater(); + _timer.schedule(updater, 0, CHECK_PERIOD); + } + else + { + System.out.println("Authentication file " + accountPathname + " is missing.\nCannot continue - exiting."); + System.exit(1); + } + } + + /** + * Checks if the account properties file has been updated, if it has it calls loadCache() to reload the cache. + */ + public void checkAccountFile() + { + long mtime = _file.lastModified(); + if (mtime != _accountFileLastModified) + { + _accountFileLastModified = mtime; + loadCache(); + } + } + + /** + * Load the account properties file into the account cache. + */ + private void loadCache() + { + try + { + Properties properties = new Properties(); + properties.load(new FileInputStream(_file)); + + // Set the cache to be the newly loaded one. + _accountCache = properties; + } + catch (IOException ex) + { + _log.info("loadCache failed with {}.", ex.getMessage()); + } + } + + @Override + public boolean checkCredentials(final String username, final String password) + { + //System.out.println("username = " + username); + //System.out.println("password = " + password); + + // The original version of this forgot to check check for a null username Property. Thanks to + // Bruno Matos for picking that up and supplying the fix below. + if (_accountCache.getProperty(username) != null && + _accountCache.getProperty(username).equals(password)) + { + return true; + } + else + { + return false; + } + } +} diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/Delegator.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/Delegator.java new file mode 100644 index 0000000000..9dfd1fea0c --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/Delegator.java @@ -0,0 +1,89 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi.httpserver; + +import java.io.IOException; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import org.apache.qpid.restapi.HttpTransaction; +import org.apache.qpid.restapi.Server; + +/** + * Delegator is an implementation of com.sun.net.httpserver.HttpHandler that is used to delegate to Server objects + * and thus provide an abstraction between the Web Server implementation (e.g. HttpServer or Servlets) and the + * implementation neutral Server and HttpTransaction interfaces. + * <p> + * In order to replace the HttpServer implementation with a Servlet based implementation all that should be necessary + * is a concrete implementation of HttpTransaction that wraps HttpServletRequest and HttpServletResponse and subclasses + * of HttpServlet that delegate to the appropriate Server instances from the appropriate context in a similar way + * to the Delegator class here. + * + * @author Fraser Adams + */ +public class Delegator implements HttpHandler +{ + private final Server _server; + + /** + * Construct a Delegator instance that delegates to the specified Server instance. + * @param server the Server instance that this Delegator delegates to. + */ + public Delegator(Server server) + { + _server = server; + } + + /** + * Implements the HttpHandler handle interface and delegates to the Server instance. + * @param exchange the HttpExchange exchange object passed by the HttpServer. This will be used to construct + * an HttpTransaction instance that will be passed to the Server. + */ + public void handle(final HttpExchange exchange) throws IOException + { + HttpTransaction tx = new HttpExchangeTransaction(exchange); + String method = tx.getMethod(); + if (method.equals("GET")) + { + _server.doGet(tx); + } + else if (method.equals("POST")) + { + _server.doPost(tx); + } + else if (method.equals("PUT")) + { + _server.doPut(tx); + } + else if (method.equals("DELETE")) + { + _server.doDelete(tx); + } + else + { + tx.sendResponse(HTTP_BAD_METHOD, "text/plain", "405 Bad Method."); + } + } +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/HttpExchangeTransaction.java b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/HttpExchangeTransaction.java new file mode 100644 index 0000000000..78932ec399 --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/httpserver/HttpExchangeTransaction.java @@ -0,0 +1,311 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.qpid.restapi.httpserver; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +import org.apache.qpid.restapi.HttpTransaction; + +/** + * This class provides an implementation of the HttpTransaction interface that wraps com.sun.net.httpserver.HttpExchange + * in order to provide an implementation neutral facade to the Server classes. + * + * @author Fraser Adams + */ +public final class HttpExchangeTransaction implements HttpTransaction +{ + final HttpExchange _exchange; + + /** + * Construct an HttpExchangeTransaction from an HttpExchange object. + */ + public HttpExchangeTransaction(final HttpExchange exchange) + { + _exchange = exchange; + } + + /** + * Log the HTTP request information (primarily for debugging purposes) + */ + public void logRequest() + { + System.out.println(_exchange.getRequestMethod() + " " + _exchange.getRequestURI()); + for (Map.Entry<String, List<String>> header : _exchange.getRequestHeaders().entrySet()) + { + System.out.println(header); + } + System.out.println("From: " + getRemoteHost() + ":" + getRemotePort()); + } + + /** + * Return the content passed in the request from the client as a Stream. + * @return the content passed in the request from the client as a Stream. + */ + public InputStream getRequestStream() throws IOException + { + return _exchange.getRequestBody(); + } + + /** + * Return the content passed in the request from the client as a String. + * @return the content passed in the request from the client as a String. + */ + public String getRequestString() throws IOException + { + return new String(getRequest()); + } + + + /** + * Return the content passed in the request from the client as a byte[]. + * @return the content passed in the request from the client as a byte[]. + */ + public byte[] getRequest() throws IOException + { + InputStream is = _exchange.getRequestBody(); + + // Convert InputStream to byte[]. + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer, 0, 1024)) != -1) + { + bos.write(buffer, 0, len); + } + return bos.toByteArray(); + } + + /** + * Send the content passed as a String as an HTTP response back to the client. + * @param status the HTTP status code e.g. 200 for OK. + * @param mimeType the mimeType of the response content e.g. text/plain, text/xml, image/jpeg etc. + * @param content the content of the response passed as a String. + */ + public void sendResponse(final int status, final String mimeType, final String content) throws IOException + { + if (content == null) + { // If response length has the value -1 then no response body is being sent. + _exchange.getResponseHeaders().set("Content-Type", mimeType); + _exchange.sendResponseHeaders(status, -1); + _exchange.close(); + } + else + { + sendResponse(status, mimeType, content.getBytes()); + } + } + + /** + * Send the content passed as a byte[] as an HTTP response back to the client. + * @param status the HTTP status code e.g. 200 for OK. + * @param mimeType the mimeType of the response content e.g. text/plain, text/xml, image/jpeg etc. + * @param content the content of the response passed as a byte[]. + */ + public void sendResponse(final int status, final String mimeType, final byte[] content) throws IOException + { + _exchange.getResponseHeaders().set("Content-Type", mimeType); + if (content == null) + { // If response length has the value -1 then no response body is being sent. + _exchange.sendResponseHeaders(status, -1); + _exchange.close(); + } + else + { + _exchange.sendResponseHeaders(status, content.length); + OutputStream os = _exchange.getResponseBody(); + os.write(content); + os.flush(); + os.close(); + _exchange.close(); + } + } + + /** + * Send the content passed as an InputStream as an HTTP response back to the client. + * @param status the HTTP status code e.g. 200 for OK. + * @param mimeType the mimeType of the response content e.g. text/plain, text/xml, image/jpeg etc. + * @param is the content of the response passed as an InputStream. + */ + public void sendResponse(final int status, final String mimeType, final InputStream is) throws IOException + { + _exchange.getResponseHeaders().set("Content-Type", mimeType); + if (is == null) + { // If response length has the value -1 then no response body is being sent. + _exchange.sendResponseHeaders(status, -1); + _exchange.close(); + } + else + { + _exchange.sendResponseHeaders(status, 0); // For a stream we set to zero to force chunked transfer encoding. + OutputStream os = _exchange.getResponseBody(); + + byte[] buffer = new byte[8192]; + while (true) + { + int read = is.read(buffer, 0, buffer.length); + if (read == -1) // Loop until EOF is reached + { + break; + } + os.write(buffer, 0, read); + } + + os.flush(); + os.close(); + _exchange.close(); + } + } + + /** + * Returns the Internet Protocol (IP) address of the client or last proxy that sent the request. + * @return the Internet Protocol (IP) address of the client or last proxy that sent the request. + */ + public String getRemoteAddr() + { + return _exchange.getRemoteAddress().getAddress().getHostAddress(); + } + + /** + * Returns the fully qualified name of the client or the last proxy that sent the request. + * @return the fully qualified name of the client or the last proxy that sent the request. + */ + public String getRemoteHost() + { + return _exchange.getRemoteAddress().getHostName(); + } + + /** + * Returns the Internet Protocol (IP) source port of the client or last proxy that sent the request. + * @return the Internet Protocol (IP) source port of the client or last proxy that sent the request. + */ + public int getRemotePort() + { + return _exchange.getRemoteAddress().getPort(); + } + + /** + * Returns a String containing the name of the current authenticated user. If the user has not been authenticated, + * the method returns null. + * @return a String containing the name of the user making this request; null if the user has not been authenticated. + */ + public String getPrincipal() + { + HttpPrincipal principal = _exchange.getPrincipal(); + return principal == null ? null : principal.getUsername(); + } + + /** + * Returns the name of the HTTP method with which this request was made, for example, GET, POST, or PUT. + * @return a String specifying the name of the method with which this request was made. + */ + public String getMethod() + { + return _exchange.getRequestMethod(); + } + + /** + * Returns the part of this request's URL from the protocol name up to the query string in the first line of + * the HTTP request. + * @return a String containing the part of the URL from the protocol name up to the query string. + */ + public String getRequestURI() + { + return _exchange.getRequestURI().getPath(); + } + + /** + * Sets a response header with the given name and value. If the header had already been set, the new value + * overwrites the previous one. + * @param name a String specifying the header name. + * @param value a String specifying the header value. If it contains octet string, it should be encoded according + * to RFC 2047. + */ + public void setHeader(final String name, final String value) + { + _exchange.getResponseHeaders().set(name, value); + } + + /** + * Returns the value of the specified request header as a String. If the request did not include a header of the + * specified name, this method returns null. If there are multiple headers with the same name, this method returns + * the first head in the request. The header name is case insensitive. You can use this method with any request + * header. + * @param name a String specifying the header name. + * @return a String containing the value of the requested header, or null if the request does not have a header of + * that name. + */ + public String getHeader(final String name) + { + return _exchange.getRequestHeaders().getFirst(name); + } + + /** + * Returns the String value of the specified cookie. + * @param name a String specifying the cookie name. + */ + public String getCookie(final String name) + { + Headers headers = _exchange.getRequestHeaders(); + if (!headers.containsKey("Cookie")) + { + return null; + } + + List<String> values = headers.get("cookie"); + for (String value : values) + { + String[] cookies = value.split(";"); + for (String cookie : cookies) + { + String[] cdata = cookie.split("="); + if (cdata[0].trim().equals(name)) + { + //return URLDecode(cdata[1]); + return cdata[1]; + } + } + } + return null; + } + + /** + * Adds the specified cookie to the response. This method can be called multiple times to set more than one cookie. + * @param name a String specifying the cookie name. + * @param value a String specifying the cookie value. + */ + public void addCookie(final String name, final String value) + { + //String data = name + "=" + URLEncode(value) + "; path=/"; + String data = name + "=" + value + "; path=/"; + _exchange.getResponseHeaders().add("Set-Cookie", data); + } +} + + diff --git a/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/servlet/TODO b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/servlet/TODO new file mode 100644 index 0000000000..9da5ee871a --- /dev/null +++ b/qpid/tools/src/java/qpid-qmf2-rest/src/main/java/org/apache/qpid/restapi/servlet/TODO @@ -0,0 +1,25 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * + */ + +The org.apache.qpid.restapi.HttpTransaction and org.apache.qpid.restapi.Server interfaces are intended to +provide abstractions to the underlying HTTP Server technology and thus should make it fairly easy to do +a Servlet based implementation without having to rewrite the core REST API code. A Servlet implementation +is still on the "TODO" list though. |