-The [OSGi enRoute][enroute] project provides a programming model of OSGi applications. The OSGi specifications provide a powerful and solid platform for component oriented programming but by their nature lack ease of use, especially for newcomers to get started.
+The [OSGi enRoute][enroute] project provides a programming model of OSGi applications using [Bndtools][1]. The OSGi specifications provide a powerful and solid platform for component oriented programming but by their nature lack ease of use, especially for newcomers to get started. Using Bndtools, an Eclipse plugin, and v2Archive OSGi enRoute makes it really easy to get started.
This repository contains bundles providing the API for the OSGi enRoute base profile
the bundles that had to be developed for OSGi enRoute because such bundles did not exist in any open source project.
The base profile establishes a runtime that contains a minimal set of services that can be used as a base for applications.
-These bundles implement services defined in the [OSGi enRoute APIs] and/or provide common functions.
+These bundles implement services defined in the [OSGi enRoute APIs][2] and/or provide common functions.
## Contributing
@@ -20,4 +20,6 @@ wrong or incomplete.
The contents of this repository are made available to the public under the terms of the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
Bundles may depend on non Apache Licensed code.
-[enroute]: http://enroute.osgi.org
+[enroute]: http://v2archive.enroute.osgi.org
+[1]: http://bndtools.org
+[2]: https://github.com/osgi/v2archive.osgi.enroute/tree/master/osgi.enroute.base.api/src/osgi/enroute
diff --git a/osgi.enroute.base.api/src/osgi/enroute/rest/api/CORS.java b/osgi.enroute.base.api/src/osgi/enroute/rest/api/CORS.java
new file mode 100644
index 0000000..bad4658
--- /dev/null
+++ b/osgi.enroute.base.api/src/osgi/enroute/rest/api/CORS.java
@@ -0,0 +1,121 @@
+package osgi.enroute.rest.api;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Enable an HTTP method of a REST implementation for cross-scripting. To apply
+ * CORS to a specific REST endpoint, annotate the endpoint method in the REST
+ * implementation with this annotation. To apply CORS to all endpoints in a REST
+ * implementation, annotate the REST class. If both are applied, method
+ * annotations (when existing) take precedence over the class annotation.
+ * Dynamic resolution of the allowed origin, allowed methods, and allowed
+ * headers can be provided by implementing a method within the REST
+ * implementation class. The method has the naming scheme
+ * config ([+ method] + impl) [+cardinality] where
+ * config = allowOrigin | allowMethods | allowHeaders,
+ * method is the HTTP method, impl is the name of the
+ * REST implementation method, cardinality is the number of
+ * arguments (see REST implementation). Use All to match all
+ * methods. The method name is written in camel case. Dynamic value resolution
+ * is attempted at runtime using the following algorithm:
+ *
+ *
+ * Look for the most specific method. Example: allowOriginPostUser0()
+ *
+ *
+ * Look for the most specific method excluding cardinality. Example:
+ * allowOriginPostUser()
+ *
+ *
+ * Look for the most specific method for any HTTP method. Example:
+ * allowOriginAllUser0()
+ *
+ *
+ * Look for the most specific method for any HTTP method, excluding cardinality.
+ * Example: allowOriginAllUser()
+ *
+ *
+ * Look for the catch-all method. Example: allowOrigin()
+ *
+ *
+ * allowOrigin must have the signature
+ * java.util.function.Function> allowOrigin[method][impl][cardinality]()
+ * as specified above. allowMethods must have the signature
+ * java.util.function.Function allowMethods[method][impl][cardinality]()
+ * as specified as described above. allowHeaders must have the
+ * signature
+ * BiFunction, String> allowHeaders[method][impl][cardinality]()
+ * as specified as described above. Example:
+ * java.util.Function> allowOrigin() {
+ * return origin -> {
+ * ... // dynamic code here
+ * };
+ * }
+ *
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({
+ ElementType.TYPE, ElementType.METHOD
+})
+public @interface CORS {
+
+ /**
+ * The marker value to indicate use of dynamic value resolution.
+ */
+ public static final String DYNAMIC = "DYNAMIC";
+
+ /**
+ * Accept all origins or headers.
+ */
+ public static final String ALL = "*";
+
+ /**
+ * The value to use for the Access-Control-Allow-Origin header. Set as
+ * "DYNAMIC" to use dynamic resolution. For security reasons, by default no
+ * origins are accepted. To accept an origin this value must be configured.
+ */
+ String origin() default "";
+
+ /**
+ * The value to use for the Access-Control-Allow-Methods header. Set as a
+ * single value "DYNAMIC" to use dynamic resolution. When the value is
+ * static, the provided allowed methods will be used for all origins. When
+ * the value "*" is provided, the allowed methods will be the same as those
+ * provided in the Allow header (i.e. all methods are allowed) for all
+ * origins. To provide per-origin allowed methods, use dynamic resolution.
+ * For security purposes, by default no methods are accepted.
+ */
+ String[] allowMethods() default {};
+
+ /**
+ * The value to use for the Access-Control-Allow-Headers header. Set as a
+ * single value "DYNAMIC" to use dynamic resolution.
+ */
+ String[] allowHeaders() default {
+ "Content-Type"
+ };
+
+ /**
+ * Determines whether or not the Access-Control-Allow-Credentials header
+ * gets set. When configured to {@code true}, the CORS header will be set to
+ * "true". Otherwise, the header is not set.
+ */
+ boolean allowCredentials() default false;
+
+ /**
+ * Custom headers to expose to the cross-site requestor. If the list is not
+ * empty, sets the CORS header Access-Control-Expose-Headers to the values
+ * provided.
+ */
+ String[] exposeHeaders() default {
+ "X-Powered-By"
+ };
+
+ /**
+ * Sets the Access-Control-Max-Age to the value provided.
+ */
+ int maxAge() default 86400;
+}
diff --git a/osgi.enroute.polymer.app.webresource/1.0.0/pouchdb-find/package.json b/osgi.enroute.polymer.app.webresource/1.0.0/pouchdb-find/package.json
index 3e9ed8c..2d835c7 100644
--- a/osgi.enroute.polymer.app.webresource/1.0.0/pouchdb-find/package.json
+++ b/osgi.enroute.polymer.app.webresource/1.0.0/pouchdb-find/package.json
@@ -62,7 +62,7 @@
"mkdirp": "^0.5.0",
"mocha": "~1.18",
"phantomjs": "^1.9.7-5",
- "pouchdb": "^5.2.0",
+ "pouchdb": "~> 6.0.5",
"query-string": "^2.4.2",
"request": "^2.36.0",
"sauce-connect-launcher": "^0.4.2",
diff --git a/osgi.enroute.pom.distro/pom.xml b/osgi.enroute.pom.distro/pom.xml
new file mode 100644
index 0000000..f7c8878
--- /dev/null
+++ b/osgi.enroute.pom.distro/pom.xml
@@ -0,0 +1,495 @@
+
+ 4.0.0
+
+
+ UTF-8
+
+
+ org.osgi
+ osgi.enroute.pom.distro
+ 2.1.0-SNAPSHOT
+ jar
+
+ OSGi enRoute Distro POM
+
+
+
+
+
+
+
+ biz.aQute.bnd
+ biz.aQute.bndlib
+ 4.0.0
+ runtime
+
+
+ biz.aQute.bnd
+ biz.aQute.junit
+ 4.0.0
+ runtime
+
+
+ biz.aQute.bnd
+ biz.aQute.launcher
+ 4.0.0
+ runtime
+
+
+ biz.aQute.bnd
+ biz.aQute.remote.agent
+ 4.0.0
+ runtime
+
+
+ biz.aQute.bnd
+ biz.aQute.remote.launcher
+ 4.0.0
+ runtime
+
+
+ biz.aQute.bnd
+ biz.aQute.remote.main
+ 4.0.0
+ runtime
+
+
+ biz.aQute.bnd
+ biz.aQute.tester
+ 4.0.0
+ runtime
+
+
+
+
+
+ org.json
+ json
+ 20160212
+ runtime
+
+
+
+
+
+ net.sourceforge.htmlunit
+ htmlunit
+ 2.22
+ runtime
+
+
+
+
+
+ org.osgi
+ org.amdatu.remote.admin.http
+ 0.1.2
+ runtime
+
+
+ org.osgi
+ org.amdatu.remote.discovery.bonjour
+ 0.1.2
+ runtime
+
+
+ org.osgi
+ org.amdatu.remote.topology.promiscuous
+ 0.1.2
+ runtime
+
+
+
+
+
+
+ commons-fileupload
+ commons-fileupload
+ 1.3.2
+ runtime
+
+
+
+ commons-io
+ commons-io
+ 2.5
+ runtime
+
+
+
+ commons-codec
+ commons-codec
+ 1.9
+ runtime
+
+
+
+ org.apache.felix
+ org.apache.felix.configadmin
+ 1.8.8
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.dependencymanager
+ 3.2.0
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.gogo.command
+ 0.16.0
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.gogo.runtime
+ 0.16.2
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.http.api
+ 3.0.0
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.http.jetty
+ 3.2.0
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.http.servlet-api
+ 1.1.2
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.log
+ 1.0.1
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.scr
+ 2.0.2
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.webconsole
+ 4.2.16
+ runtime
+
+
+
+
+
+ org.osgi
+ org.eclipse.equinox.coordinator
+ 1.3.100.v20150410-1453
+
+
+ org.osgi
+ org.eclipse.equinox.event
+ 1.3.100.v20140115-1647
+ runtime
+
+
+ org.osgi
+ org.eclipse.equinox.metatype
+ 1.4.100.v20150408-1437
+ runtime
+
+
+ org.osgi
+ org.eclipse.osgi
+ 3.10.100.v20150529-1857
+ runtime
+
+
+
+
+
+ org.osgi
+ org.knopflerfish.bundle.useradmin
+ 4.1.1
+ runtime
+
+
+
+
+
+ org.osgi
+ org.osgi.namespace.contract
+ 1.0.0
+ runtime
+
+
+ org.osgi
+ org.osgi.namespace.extender
+ 1.0.1
+ runtime
+
+
+ org.osgi
+ org.osgi.namespace.implementation
+ 1.0.0
+ runtime
+
+
+ org.osgi
+ org.osgi.namespace.service
+ 1.0.0
+ runtime
+
+
+ org.osgi
+ org.osgi.service.async
+ 1.0.0
+ runtime
+
+
+ org.osgi
+ org.osgi.service.event
+ 1.3.1
+ runtime
+
+
+ org.osgi
+ org.osgi.service.metatype
+ 1.3.0
+ runtime
+
+
+ org.osgi
+ org.osgi.service.remoteserviceadmin
+ 1.1.0
+ runtime
+
+
+ org.osgi
+ osgi.promise
+ 6.0.0
+ runtime
+
+
+
+
+
+
+
+ org.osgi
+ osgi.enroute.base.api
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.base.guard
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.base.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.base.debug.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.base.test
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.bndtools.templates
+ 2.1.0-SNAPSHOT
+ compile
+
+
+ org.osgi
+ osgi.enroute.bostock.d3.webresource
+ 3.5.6
+ runtime
+
+
+ org.osgi
+ osgi.enroute.dtos.bndlib.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.easse.simple.adapter
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.equinox.log.adapter
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.google.angular.webresource
+ 1.5.7
+ runtime
+
+
+ org.osgi
+ osgi.enroute.github.angular-ui.webresource
+ 0.13.3
+ runtime
+
+
+ org.osgi
+ osgi.enroute.hamcrest.wrapper
+ 1.3.0
+ runtime
+
+
+ org.osgi
+ osgi.enroute.iot.circuit.application
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.iot.circuit.command
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.iot.circuit.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.iot.lego.adapter
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.iot.pi.command
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.jsplumb.webresource
+ 1.7.6
+ runtime
+
+
+ org.osgi
+ osgi.enroute.junit.wrapper
+ 4.12.0
+ runtime
+
+
+ org.osgi
+ osgi.enroute.scheduler.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.stackexchange.pagedown.webresource
+ 1.1.1
+ runtime
+
+
+ org.osgi
+ osgi.enroute.twitter.bootstrap.webresource
+ 3.3.5
+ runtime
+
+
+ org.osgi
+ osgi.enroute.authenticator.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.authorization.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.configurer.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.executor.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.apache.felix
+ org.apache.felix.gogo.shell
+ 1.0.0
+ runtime
+
+
+ org.osgi
+ osgi.enroute.jsonrpc.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.logger.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.rest.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.web.simple.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.webconsole.xray.provider
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.osgi
+ osgi.enroute.websecurity.adapter
+ 2.1.0-SNAPSHOT
+ runtime
+
+
+ org.apache.zookeeper
+ zookeeper
+ 3.4.9
+
+
+
+
diff --git a/osgi.enroute.rest.simple.provider/bnd.bnd b/osgi.enroute.rest.simple.provider/bnd.bnd
index 4efdfda..2e05b53 100644
--- a/osgi.enroute.rest.simple.provider/bnd.bnd
+++ b/osgi.enroute.rest.simple.provider/bnd.bnd
@@ -12,6 +12,8 @@ Bundle-Description: \
endpoint. The remaining path is mapped to the arguments of the method. The \
returned object is returned as JSON.
+Bundle-Version: 2.2.0
+
Export-Package: \
osgi.enroute.rest.api;provide:=true,\
osgi.enroute.rest.jsonschema.api;provide:=true,\
@@ -25,6 +27,7 @@ Conditional-Package: aQute.lib*
-workingset bundles,provider
+
-buildpath: \
osgi.enroute.base.api;version=latest,\
biz.aQute.bndlib;packages=*
@@ -47,4 +50,4 @@ Conditional-Package: aQute.lib*
org.eclipse.equinox.metatype;version='[1.4.100,1.4.101)',\
org.osgi.service.event;version='[1.3.1,1.3.2)',\
org.osgi.service.metatype;version='[1.3.0,1.3.1)',\
- osgi.enroute.rest.simple.provider;version=snapshot
+ osgi.enroute.rest.simple.provider;version=snapshot
\ No newline at end of file
diff --git a/osgi.enroute.rest.simple.provider/readme.md b/osgi.enroute.rest.simple.provider/readme.md
index bb96088..b3c438a 100644
--- a/osgi.enroute.rest.simple.provider/readme.md
+++ b/osgi.enroute.rest.simple.provider/readme.md
@@ -9,16 +9,22 @@ summary: Provides REST endpoints that are based on method naming pattern with ty
## Introduction
-The OSGi enRoute REST API specifies a service contract for components to provide REST _endpoints_. Representational State Transfer (REST) is an architectural style that allows the interchange of information between elements of a distributed system. A REST endpoint has an HTTP(S) URI and can thus be accessed from all modern computing environments. An endpoint defines the meaning of the segments of this URI, any specified parameters in the URI, as well as the used HTTP verb (GET, PUT, POST, etc). For example:
+The OSGi enRoute REST API specifies a service contract for components to provide REST _endpoints_. Representational State Transfer (REST) is an architectural style that allows the interchange of information between elements of a distributed system. A REST endpoint has an HTTPS URI and can thus be accessed from all modern computing environments. An endpoint defines the meaning of the segments of this URI, any specified parameters in the URI, as well as the used HTTP verb (GET, PUT, POST, etc). For example:
GET /rest/upper/?alphaonly=true
This endpoint is mapped to the URI `/rest/upper` and the next segment specifies the word to translate to upper case. The `alphaonly` is a _parameter_ on the URI, in this case a boolean.
-A HTTP request can specify a _payload_, a payload can be associated with the `POST` or the `PUT` verb. in the response, the HTTP request can return a _body_.
+A HTTPS request can specify a _payload_, a payload can be associated with the `POST` or the `PUT` verb. in the response, the HTTPS request can return a _body_.
Since having REST capability is of such major importance for many modern systems, it is crucial that the overhead for the programmer to support this interface be absolutely minimized. This specification leverages the Java type system to provide REST endpoints. It provides a deterministic mapping from a URI request to a REST method name of a restricted set of methods. Adding a method that is named according to a defined pattern is all that is required to add a new REST endpoint.
+## HTTP vs. HTTPS
+
+Today with free tools available for generating SSL/TLS certificates (https://letsencrypt.org/), there is no reason *not* to require HTTPS.
+Therefore, unless configured otherwise, HTTPS is required for each request. The default return method for an HTTP call is a 404, considering
+that `http://example.com/some_uri` and `https://example.com/some_uri` are actually different URIs. This error can be configured. For backwards compatibility, you can allow allow non-SSL requests as well.
+
## REST Methods
A REST class will make available as a REST endpoint every public method that starts with `get`, `post`, `put`, `option`, or `delete`. The remainder of the method name and the parameters define the remainder of the URI. These methods can be POJO or take a special object providing the context. For example:
@@ -33,7 +39,7 @@ The first parameter of a REST method can be be an interface that is or extends t
String upper();
}
- public String getUpper( UpperRequest rq ) {
+ public String getUpper(UpperRequest rq) {
return rq.upper().toUpperCase();
}
@@ -65,6 +71,21 @@ Therefore, the previous example can be defined as:
Assuming a default root of `/rest`, this will provide a REST endpoint URI for the earlier example of `GET /rest/upper/?alphaonly=true`.
+## Handling OPTIONS
+
+Except for OPTIONS requests, making a call to a non-implemented method on an existing endpoint will return a 405 error.
+Making a call on a non-existing endpoint will return a 404. Only OPTIONS requests are handled a little differently by default
+(i.e. unless implemented in the REST implementation as described above).
+
+Making an OPTIONS call against a non-existing endpoint will still return a 404, but making it against an existing endpoint will
+return a successful call (return value 204 no content) with the "Allow" header set to whichever methods are implemented
+for that endpoint in the REST implementation. Any GET method will also return an allowed HEAD value.
+
+Using the above example of the `UpperApplication`:
+
+ * A request to `GET /rest/upper/` will return a 204 with "Allow: OPTIONS, GET, HEAD"
+ * A request to `GET /rest/lower/` will return a 404
+
## Extra Conversions for the Body
Certain return types of the REST method are not mapped to JSON but are treated differently. These are the special conversions:
@@ -115,25 +136,12 @@ In REST protocols, the `PUT` verb would be used to store a new person. To create
Since the REST methods provide full type safe access to the parameters and remaining URI segments a significant amount of validation is executed by the implementation of this service. These validations will result in the appropriate HTTP error and status code. Implementation should also add explanatory texts to the response.
-Additionally, the REST methods may throw any exception. You have two approaches: a "DDD-ish" approach, using a limited set of Exceptions that correspond directly to the most commonly used HTTP errors, or by using regular Java Exceptions, which get translated.
-
-Example of a "DDD-ish" approach:
-
- Person putPerson( PersonRequest request ) throws BadRequest400Exception, NotFound404Exception {
- ...
- }
-
-Using regular Exceptions would look more like this:
-
- Person putPerson( PersonRequest request ) throws IllegalStateException, FileNotFoundException {
- ...
- }
-
Regular Java Exceptions are translated to an HTTP error code. The conversions from exception to status code is as follows:
* IllegalStateException <80><93> 400 BAD REQUEST
* SecurityException <80><93> 403 FORBIDDEN
* FileNotFoundException <80><93> 404 NOT FOUND
+* NoSuchMethodException <80><93> 405 METHOD NOT ALLOWED
* UnsupportedOperationException <80><93> 501 NOT IMPLEMENTED
* All other exceptions <80><93> 500 SERVER ERROR
@@ -153,29 +161,17 @@ The issue is that you cannot just convert a service since a service in general h
## CORS
-It is possible to configure the service for CORS.
-
-| Field | Type | Description |
-|----------|:-------------:|:-------------:|
-|corsEnabled | Boolean | Whether to enable or disable CORS headers, default is '__false__' |
-| allowOrigin | String | The allowed origin hosts header '__Access-Control-Allow-Origin__', default '__*__' |
-| allowMethods | String | Allowed HTTP client methods header '__Access-Control-Allow-Methods__' as comma seperated values, default '__GET, POST, PUT__' |
-| allowHeaders | String | Allowed HTTP headers '__Access-Control-Allow-Headers__' as comma separated values, default '__Content-Type__'|
-| maxAge | int | The Max Age for Access-Control* header '__Access-Control-Max-Age__' in seconds, default '__86400__' (24 hrs) |
-| allowedMethods | String | The methods that are allowed from client, Header '__Allow__', default '__GET, HEAD, POST, TRACE, OPTIONS__' |
-
-e.g. to configure a REST service with CORS headers put the following in the configuration/configuration.json
-```
-{
- "service.pid": "osgi.enroute.rest.simple",
- "corsEnabled": true,
- "allowOrigin": "*",
- "allowMethods": "GET, POST, PUT, DELETE",
- "allowHeaders": "Content-Type",
- "maxAge": 86400,
- "allowedMethods": "GET, HEAD, POST, TRACE, OPTIONS"
-}
-```
+CORS support is provided for all endpoints. It is configured via the `@CORS` annotation. The REST implementation class can be annotated (in which case the configuration applies to all methods in that class), or each individual method can annotated. In the case that both are annotated, the method annotation takes priority. If no annotation exists, then CORS is disabled for that method call.
+
+For public APIs, the simplest approach is to annotate the REST implementation class with a static
+configuration (i.e. `Allow: *`).
+
+For private APIs, it should usually be sufficient to provide a static configuration either on the
+class level, or in a more fine-grained matter on the method level (i.e. `Allow: https://example.com, https://example.org`).
+
+For more demanding cases, is possible to provide a dynamic configuration. This is done by implementing
+a method (like "allowOrigin()") in the REST implementation. The method will be invoked as necessary, thereby permitting the dynamic resolution on a request-by-request basis. See the CORS API
+javadoc for details about how to implement these dynamic methods.
## Namespaces
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/CORSUtil.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/CORSUtil.java
new file mode 100644
index 0000000..30a851e
--- /dev/null
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/CORSUtil.java
@@ -0,0 +1,343 @@
+package osgi.enroute.rest.simple.provider;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import osgi.enroute.rest.api.CORS;
+
+/**
+ * Utility for processing cross-origin requests (CORS).
+ */
+public class CORSUtil {
+
+ private final static Pattern ACCEPTED_METHOD_NAMES_P = Pattern
+ .compile("(?.*)(?All|Get|Post|Put|Delete|Option|Head)(?.*)(?\\d*)");
+
+ static class CORSConfig extends org.osgi.dto.DTO {
+ /**
+ * The Verb to which this configuration corresponds.
+ */
+ Verb verb;
+
+ /**
+ * Given an origin, return an Optional containing the value
+ * of the allowed origin.
+ */
+ java.util.function.Function> allowOrigin;
+
+ /**
+ * True if the allowed origin is determined dynamically, false otherwise.
+ */
+ boolean dynamicOrigin;
+
+ /**
+ * Given an origin, return the allowed methods.
+ */
+ java.util.function.Function allowMethods;
+
+ /**
+ * Given an origin, a verb, and a list of requested headers, return the allowed headers.
+ * The Function + BiFunction provides the equivalent of a TriFunction.
+ */
+ BiFunction, String> allowHeaders;
+ boolean allowCredentials;
+ Optional exposeHeaders;
+ Optional maxAge;
+ }
+
+ /**
+ * A Request is identified as a CORS request simply by determining if there is an
+ * "Origin" header. The presence of an "Origin" header indicates that the client is
+ * making a cross-origin request.
+ */
+ static boolean isCORSRequest(HttpServletRequest rq) {
+ Enumeration e = rq.getHeaderNames();
+ while (e.hasMoreElements()) {
+ if("Origin".equals( e.nextElement() ))
+ return true;
+ }
+
+ return false;
+ }
+
+ static void doCORS(HttpServletRequest rq, HttpServletResponse rsp, CORSConfig config, FunctionGroup fg) {
+ Map headers = processHeaders( rq, config, fg );
+ headers.entrySet().stream()
+ .forEach(e -> rsp.setHeader(e.getKey(), e.getValue()));
+ }
+
+ /**
+ * Main processing algorithm for CORS. Attempts to follow the CORS specification on a per-request,
+ * per-endpoint basis.
+ */
+ private static Map processHeaders(HttpServletRequest rq, CORSConfig config, FunctionGroup fg) {
+ Map corsHeaders = new HashMap<>();
+ String origin = rq.getHeader("Origin");
+ Optional allowOrigin = config.allowOrigin.apply(origin);
+ if (allowOrigin != null && allowOrigin.isPresent()) {
+ // This header is set only if the provided Origin is allowed.
+ corsHeaders.put("Access-Control-Allow-Origin", allowOrigin.get());
+ } else {
+ // If the Origin header is not allowed, then the CORS request must fail with a 403
+ // TODO: write to log
+ throw new SecurityException();
+ }
+
+ if (config.allowCredentials) {
+ corsHeaders.put("Access-Control-Allow-Credentials", "true");
+ }
+
+ if (isPreflight(rq)) {
+ String configuredAllowMethods = config.allowMethods.apply(origin);
+ String allowMethods = CORS.ALL.equals(configuredAllowMethods) ? fg.getOptions() : configuredAllowMethods;
+ corsHeaders.put("Access-Control-Allow-Methods", allowMethods);
+ String requestedHeadersString = rq.getHeader("Access-Control-Request-Headers");
+ String[] requestedHeadersArray = requestedHeadersString != null ? rq.getHeader("Access-Control-Request-Headers").split(", ") : new String[]{};
+ List requestedHeaders = Arrays.asList(requestedHeadersArray);
+ corsHeaders.put("Access-Control-Allow-Headers", config.allowHeaders.apply(origin, requestedHeaders));
+ if (config.maxAge !=null && config.maxAge.isPresent())
+ corsHeaders.put("Access-Control-Max-Age", config.maxAge.get());
+ } else if (config.exposeHeaders != null && config.exposeHeaders.isPresent()) {
+ // Do this only in the case that a request is not a pre-flight request and there are headers to expose
+ corsHeaders.put("Access-Control-Expose-Headers", config.exposeHeaders.get());
+ }
+
+ return corsHeaders;
+ }
+
+ private static boolean isPreflight(HttpServletRequest rq) {
+ // A pre-flight request must be made using the "OPTION" method.
+ // TODO: Here we are using string matching ("OPTIONS" and not "Options" or "options").
+ // Consider if we should allow use of non-compliant versions as well.
+ if (!"OPTIONS".equals(rq.getMethod()))
+ return false;
+
+ // A pre-flight request must have a non-empty Access-Control-Request-Method header
+ String acrm = rq.getHeader("Access-Control-Request-Method");
+ if (acrm == null || acrm.isEmpty())
+ return false;
+
+ // The above two conditions are met, so this qualifies as a pre-flight request.
+ return true;
+ }
+
+ /**
+ * The Method config takes precedence over the class config.
+ */
+ static CORSConfig parseCORS(Object target, Method method, String name, Verb verb, int cardinality) {
+ CORS classCORS = target.getClass().getAnnotation(CORS.class);
+ CORS methodCORS = method.getAnnotation(CORS.class);
+
+ if (classCORS != null && methodCORS == null)
+ return convert(target, name, verb, cardinality, classCORS);
+
+ return convert(target, name, verb, cardinality, methodCORS);
+ }
+
+ @SuppressWarnings( "unchecked" )
+ private static CORSConfig convert(Object target, String name, Verb verb, int cardinality, CORS cors) {
+
+ if (cors == null)
+ return null;
+
+ CORSConfig config = new CORSConfig();
+
+ // Process "Origin"
+ if (CORS.DYNAMIC.equals(cors.origin())) {
+ config.dynamicOrigin = true;
+ Method m = findMethod("allowOrigin", name, verb, cardinality, target);
+
+ if (m != null) {
+ try {
+ m.setAccessible(true);
+ config.allowOrigin = (java.util.function.Function>)m.invoke(target);
+ } catch (Exception e) {
+ // TODO Auto-generated catch block
+ e.printStackTrace(); // Log this properly
+ }
+ } else {
+ // CORS is configured as DYNAMIC, but no method was found
+ // TODO: log this
+ config.dynamicOrigin = false;
+ config.allowOrigin = origin -> Optional.empty();
+ }
+ } else {
+ config.dynamicOrigin = false;
+ config.allowOrigin =
+ cors.origin().isEmpty() ?
+ origin -> Optional.empty() :
+ origin -> Optional.ofNullable(cors.origin());
+ }
+
+ // Process "Access-Control-Allow-Methods"
+ if (CORS.DYNAMIC.equals(cors.origin())) {
+ Method m = findMethod("allowMethods", name, verb, cardinality, target);
+
+ if (m != null) {
+ try {
+ m.setAccessible(true);
+ config.allowMethods = (java.util.function.Function)m.invoke(target);
+ } catch (Exception e) {
+ // TODO Auto-generated catch block
+ e.printStackTrace(); // Log this properly
+ }
+ } else {
+ // CORS is configured as DYNAMIC, but no method was found
+ // TODO: log this
+ config.allowMethods = origin -> "";
+ }
+ } else {
+ config.allowMethods = methods -> Arrays.stream(cors.allowMethods()).collect(Collectors.joining(", "));
+ }
+
+ // Process "Access-Control-Allow-Headers"
+ if (CORS.DYNAMIC.equals(cors.origin())) {
+ Method m = findMethod("allowHeaders", name, verb, cardinality, target);
+
+ if (m != null) {
+ try {
+ m.setAccessible(true);
+ config.allowHeaders = (BiFunction,String>)m.invoke(target);
+ } catch (Exception e) {
+ // TODO Auto-generated catch block
+ e.printStackTrace(); // Log this properly
+ }
+ } else {
+ // CORS is configured as DYNAMIC, but no method was found
+ // TODO: log this
+ config.allowHeaders = (origin, headers) -> "";
+ }
+ } else {
+ config.allowHeaders = (origin, headers) -> Arrays.stream(cors.allowHeaders()).collect(Collectors.joining(", "));
+ }
+
+ config.allowCredentials = cors.allowCredentials();
+ if (cors.exposeHeaders() != null && cors.exposeHeaders().length > 0)
+ config.exposeHeaders = Optional.of(Arrays.stream(cors.exposeHeaders()).collect( Collectors.joining(", ")));
+ if (cors.maxAge() >= 0)
+ config.maxAge = Optional.ofNullable(String.valueOf(cors.maxAge()));
+
+ config.verb = verb;
+
+ return config;
+ }
+
+ private static class CORSMethod {
+ Optional verb;
+ Optional cardinality;
+ String name;
+
+ @Override
+ public String toString() {
+ return new StringBuilder()
+ .append(verb.isPresent() ? verb.get() : "")
+ .append(name)
+ .append(cardinality.isPresent() ? "/" + cardinality.toString() : "")
+ .toString();
+ }
+ }
+
+ private static Method findMethod(String name, String endpoint, Verb verb, int cardinality, Object target) {
+ Method foundMethod = null;
+
+ try {
+ // For example: user (could include getuser/0, postuser/2 etc.)
+ Method groupGeneralMethod = null;
+ // For example: user/0 (could include getuser/0, postuser/0 etc.)
+ Method groupSpecificMethod = null;
+ // For example: getuser (could include getuser/0, getuser/1 etc.)
+ Method endpointGeneralMethod = null;
+ // For example: getuser/0
+ Method endpointSpecificMethod = null;
+
+ for (Method m : target.getClass().getDeclaredMethods()) {
+ if (!m.getName().startsWith(name))
+ continue;
+
+ // Is it a general one for the set of groups?
+ // Is it one very specific to that group?
+ CORSMethod cm = parseMethodName(m.getName(), verb, cardinality);
+ if (cm != null) {
+ if (!cm.verb.isPresent() && !cm.cardinality.isPresent())
+ groupGeneralMethod = m;
+ else if (!cm.verb.isPresent() && cm.cardinality.isPresent())
+ groupSpecificMethod = m;
+ else if (cm.verb.isPresent() && !cm.cardinality.isPresent())
+ endpointGeneralMethod = m;
+ else
+ endpointSpecificMethod = m;
+ }
+ }
+
+ if (endpointSpecificMethod != null )
+ return endpointSpecificMethod;
+
+ if (endpointGeneralMethod != null)
+ return endpointGeneralMethod;
+
+ if (groupSpecificMethod != null)
+ return groupSpecificMethod;
+
+ if (groupGeneralMethod != null )
+ return groupGeneralMethod;
+
+ if (foundMethod == null)
+ foundMethod = target.getClass().getDeclaredMethod(name);
+
+ } catch (Exception e) {
+ // Do nothing
+ e.toString();
+ }
+
+ return foundMethod;
+ }
+
+ private static CORSMethod parseMethodName(String methodName, Verb verb, int cardinality) {
+ CORSMethod m = new CORSMethod();
+ Matcher matcher = ACCEPTED_METHOD_NAMES_P.matcher(methodName);
+ if (!matcher.lookingAt())
+ return null;
+
+ m.verb = Optional.ofNullable(matcher.group("verb"))
+ .filter(v -> !v.isEmpty() && !"All".equals(v))
+ .map(v -> v.toLowerCase())
+ .map(v -> Verb.valueOf(v));
+ m.cardinality = Optional.ofNullable(matcher.group("cardinality"))
+ .filter(c -> !c.isEmpty())
+ .map(c -> Integer.valueOf(c));
+ m.name = matcher.group("path").toLowerCase();
+
+ // TODO: Remove this block if the regex can be fixed to actually work properly.
+ // Currently it is not capturing the last numeric block.
+ {
+ String card = "";
+ while (Character.isDigit(m.name.charAt(m.name.length() - 1))) {
+ card = m.name.charAt(m.name.length() - 1) + card;
+ m.name = m.name.substring(0, m.name.length() - 1);
+ }
+
+ if (!card.isEmpty())
+ m.cardinality = Optional.of(card).map(c -> Integer.valueOf(c));
+ }
+
+ if (m.verb.isPresent() && m.verb.get() != verb)
+ return null;
+
+ if (m.cardinality.isPresent() && m.cardinality.get().intValue() != cardinality)
+ return null;
+
+ return m;
+ }
+}
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Config.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Config.java
index 1eb943f..72104d9 100644
--- a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Config.java
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Config.java
@@ -8,27 +8,15 @@
String osgi_http_whiteboard_servlet_pattern();
/**
- * Configuration used to enable or disable CORS.
+ * By default, all REST calls require SSL/TLS. For backwards compatibility, this
+ * requirement can be removed by setting this field to "false".
*/
- boolean corsEnabled() default false;
+ boolean requireSSL() default true;
/**
- * CORS header Access-Control-Allow-Origin
+ * By default, if a non-secure request is made (i.e. the resource https://example.com/resource is
+ * requested via http://example.com/resource), a 404 error is returned. To return a different
+ * error, set this field to the http status code value.
*/
- String allowOrigin() default "*";
-
- /**
- * CORS header Access-Control-Allow-Headers
- */
- String allowHeaders() default "Content-Type";
-
- /**
- * CORS Access-Control-Max-Age
- */
- int maxAge() default 86400;
-
- /**
- * CORS Allow methods
- */
- String allowedMethods() default "GET, HEAD, POST, TRACE, OPTIONS";
+ int notSecureError() default 404;
}
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Function.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Function.java
index 5d04047..f1891fd 100644
--- a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Function.java
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/Function.java
@@ -15,7 +15,7 @@
/**
* Cache for information about the discovered methods to speed up the mapping
- * process.
+ * process. Includes CORS configuration as well, if available.
*/
class Function {
final Method method;
@@ -32,6 +32,7 @@ class Function {
final String path;
final int ranking;
Map args = Collections.emptyMap();
+ final CORSUtil.CORSConfig cors;
static Converter converter = new Converter();
static EnumSet PAYLOAD = EnumSet.of(Verb.post,
@@ -146,6 +147,8 @@ public Object convert(Type dest, Object o) throws Exception {
this.parameters = new java.lang.reflect.Parameter[len];
System.arraycopy(method.getParameters(), offset, this.parameters, 0,
len);
+
+ cors = CORSUtil.parseCORS(target, method, name, verb, cardinality);
}
Type getPayloadType() {
@@ -218,4 +221,8 @@ Verb getVerb() {
String getPath() {
return path;
}
+
+ CORSUtil.CORSConfig getCORS() {
+ return cors;
+ }
}
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/FunctionGroup.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/FunctionGroup.java
new file mode 100644
index 0000000..3c59d38
--- /dev/null
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/FunctionGroup.java
@@ -0,0 +1,278 @@
+package osgi.enroute.rest.simple.provider;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import osgi.enroute.rest.api.CORS;
+
+/**
+ * Groups Functions by "endpoint", i.e. excludes the method.
+ *
+ * Used as an index for responding quickly to OPTIONS requests.
+ */
+public class FunctionGroup {
+ final String name;
+ final Function get;
+ final Function post;
+ final Function put;
+ final Function delete;
+ final java.util.function.Function optionsConfig;
+ final String options;
+
+ FunctionGroup(
+ String name,
+ Function get,
+ Function post,
+ Function put,
+ Function delete) {
+ this.name = name;
+ this.get = get;
+ this.post = post;
+ this.put = put;
+ this.delete = delete;
+ this.options = "OPTIONS";
+ this.optionsConfig = v -> cors(get(), put(), post(), delete(), options, v);
+ }
+
+ FunctionGroup(FunctionGroup existing, Function f, Verb verb) {
+ this.name = existing.name;
+ this.get = verb == Verb.get ? f : existing.get;
+ this.post = verb == Verb.post ? f : existing.post;
+ this.put = verb == Verb.put ? f : existing.put;
+ this.delete = verb == Verb.delete ? f : existing.delete;
+
+ List allowed = new ArrayList<>();
+ allowed.add("OPTIONS");
+ this.get().ifPresent(h -> {allowed.add("GET"); allowed.add("HEAD");});
+ this.post().ifPresent(h -> allowed.add("POST"));
+ this.put().ifPresent(h -> allowed.add("PUT"));
+ this.delete().ifPresent(h -> allowed.add("DELETE"));
+ this.options = allowed.stream().collect( Collectors.joining(", "));
+ this.optionsConfig = v -> cors(get(), put(), post(), delete(), options, v);
+ }
+
+ public Optional get() {
+ return Optional.ofNullable(get);
+ }
+
+ public Optional put() {
+ return Optional.ofNullable(put);
+ }
+
+ public Optional post() {
+ return Optional.ofNullable(post);
+ }
+
+ public Optional delete() {
+ return Optional.ofNullable(delete);
+ }
+
+ public String getGroupName() {
+ return name;
+ }
+
+ public String getOptions() {
+ return options;
+ }
+
+ /**
+ * Helper method to locate a Function corresponding to a Verb.
+ */
+ public Optional hasVerb(String httpMethod) {
+ Verb v;
+ try {
+ v = Verb.valueOf(httpMethod.toLowerCase());
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+
+ switch (v) {
+ case get :
+ return get();
+
+ case put :
+ return put();
+
+ case post :
+ return post();
+
+ case delete :
+ return delete();
+
+ default :
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Helper method to help locate a Function quicker.
+ * Given a Function, returns the Verb for that function
+ * if it exists in this FunctionGroup.
+ */
+ public Verb hasFunction(Function f) {
+ if (get == f)
+ return Verb.get;
+
+ if (post == f)
+ return Verb.post;
+
+ if (put == f)
+ return Verb.put;
+
+ if (delete == f)
+ return Verb.delete;
+
+ return null;
+ }
+
+ public java.util.function.Function optionsConfig() {
+ return verb -> optionsConfig.apply(verb);
+ }
+
+ /**
+ * Helper method to show if this FunctionGroup has any Functions
+ * included.
+ */
+ public boolean hasFunction() {
+ return
+ ((get != null) ||
+ (post != null) ||
+ (put != null) ||
+ (delete != null));
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ /**
+ * Preflight requests are a bit tricker than "normal" requests, requiring this
+ * extra bit of complexity, unfortunately.
+ */
+ private static CORSUtil.CORSConfig cors(
+ Optional get,
+ Optional put,
+ Optional post,
+ Optional delete,
+ String options,
+ Verb verb) {
+ CORSUtil.CORSConfig config = new CORSUtil.CORSConfig();
+ config.verb = Verb.option;
+ config.dynamicOrigin =
+ dynamicOrigin(get) ||
+ dynamicOrigin(put) ||
+ dynamicOrigin(post) ||
+ dynamicOrigin(delete);
+ // Perhaps not the "right" thing to do, but too complicated otherwise.
+ config.allowOrigin = origin -> Optional.of("*");
+ config.allowMethods = origin -> {
+ List allowed = Stream.of(get, put, post, delete)
+ .map(m -> allowedMethodFor(origin, m))
+ .filter(o -> !o.isEmpty())
+ .collect(Collectors.toList());
+ if (!allowed.isEmpty())
+ allowed.add(0, "OPTIONS");
+ return allowed.stream().collect(Collectors.joining(", "));
+ };
+ config.allowHeaders = allowedHeaders(get, put, post, delete, verb);
+ config.maxAge = maxAge( get, put, post, delete, verb );
+ return config;
+ }
+
+ private static boolean dynamicOrigin(Optional f) {
+ return f
+ .filter(m -> m.cors != null)
+ .map(m -> m.cors.dynamicOrigin)
+ .orElse(false);
+ }
+
+ private static String allowedMethodFor(String origin, Optional f) {
+ return f
+ .filter(m -> m.cors != null)
+ .map(m -> m.cors)
+ .map(c -> c.allowOrigin.apply(origin))
+ .flatMap(opt -> opt)
+ .filter(o -> CORS.ALL.equals(o) || origin.equals(o))
+ .map(o -> f.get().verb.name().toUpperCase())
+ .map(v -> "GET".equals(v) ? "GET, HEAD" : v)
+ .orElse("");
+ }
+
+ private static BiFunction, String> allowedHeaders(
+ Optional get,
+ Optional put,
+ Optional post,
+ Optional delete,
+ Verb v) {
+ Optional f = null;
+ switch(v)
+ {
+ case get :
+ f = get;
+ break;
+
+ case put :
+ f = put;
+ break;
+
+ case post :
+ f = post;
+ break;
+
+ case delete :
+ f = delete;
+ break;
+
+ default :
+ f = Optional.empty();
+ break;
+ }
+
+ return f.
+ filter(m -> m.cors != null)
+ .map(m -> m.cors)
+ .map(c -> c.allowHeaders)
+ .orElse((origin, requested) -> "");
+ }
+
+ private static Optional maxAge(
+ Optional get,
+ Optional put,
+ Optional post,
+ Optional delete,
+ Verb v) {
+ Optional f = null;
+ switch(v)
+ {
+ case get :
+ f = get;
+ break;
+
+ case put :
+ f = put;
+ break;
+
+ case post :
+ f = post;
+ break;
+
+ case delete :
+ f = delete;
+ break;
+
+ default :
+ f = Optional.empty();
+ break;
+ }
+
+ return f
+ .filter(m -> m.cors != null)
+ .map(m -> m.cors.maxAge)
+ .orElse(Optional.empty());
+ }
+}
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/ResponseException.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/ResponseException.java
index 20f56cf..cad76ee 100644
--- a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/ResponseException.java
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/ResponseException.java
@@ -23,8 +23,10 @@ private ResponseException(Class extends Throwable> throwable,
}
static ResponseException[] MATCH = {
- new ResponseException(FileNotFoundException.class,
- HttpServletResponse.SC_NOT_FOUND),
+ new ResponseException(FileNotFoundException.class,
+ HttpServletResponse.SC_NOT_FOUND),
+ new ResponseException(NoSuchMethodException.class,
+ HttpServletResponse.SC_METHOD_NOT_ALLOWED),
new ResponseException(SecurityException.class,
HttpServletResponse.SC_FORBIDDEN),
new ResponseException(UnsupportedOperationException.class,
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestControllerService.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestControllerService.java
index 2c1fe8f..c4db63b 100644
--- a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestControllerService.java
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestControllerService.java
@@ -83,6 +83,9 @@ public REST addingService(ServiceReference reference) {
RestServlet restServlet = servlets.computeIfAbsent(
namespace,
RestControllerService.this::createServlet);
+ if (restServlet.closed.get()) {
+ reregister(restServlet, namespace);
+ }
restServlet.add(resourceManager, ranking);
}
return resourceManager;
@@ -101,6 +104,9 @@ public void removedService(ServiceReference reference,
log.trace("removing REST {} on {}", resourceManager, namespace);
RestServlet rs = servlets.get(namespace);
rs.remove(resourceManager);
+ rs.close();
+ if (rs.count() == 0)
+ servlets.remove(rs);
// we never clean them up. Seems to much work
// since it is likely that the namespace is reused.
@@ -143,4 +149,13 @@ private RestServlet createServlet(String namespace) {
return rs;
}
+ private void reregister(RestServlet rs, String namespace) {
+ Hashtable p = new Hashtable<>();
+ p.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN,
+ namespace);
+ ServiceRegistration reg = context
+ .registerService(Servlet.class, rs, p);
+ rs.setCloseable(() -> reg.unregister());
+ rs.closed.set(false);
+ }
}
diff --git a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestMapper.java b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestMapper.java
index bea5137..0d817b9 100644
--- a/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestMapper.java
+++ b/osgi.enroute.rest.simple.provider/src/osgi/enroute/rest/simple/provider/RestMapper.java
@@ -114,14 +114,16 @@
public class RestMapper {
static Logger log = LoggerFactory
.getLogger(RestMapper.class);
- final static Pattern METHOD_NAME_P = Pattern
- .compile("(?get|post|put|delete|option|head)(?.*)");
+ // TODO: seems to be a duplication of ACCEPTED_METHOD_NAMES_P
+// final static Pattern METHOD_NAME_P = Pattern
+// .compile("(?get|post|put|delete|option|head)(?.*)");
final List endpoints = new CopyOnWriteArrayList<>();
final static JSONCodec codec = new JSONCodec();
final static JSONCodec codecNoNull = new JSONCodec();
static {
codecNoNull.setIgnorenull(true);
}
+ Map functionGroups = new HashMap();
MultiMap functions = new MultiMap();
boolean diagnostics;
@@ -155,11 +157,16 @@ public synchronized void addResource(REST resource, int ranking) {
String path = "/" + decode(matcher.group("path"));
Function function = new Function(resource, m, verb, path, ranking);
+ String groupName = function.getName().substring(function.getVerb().name().length());
+ if (!functionGroups.containsKey(groupName))
+ functionGroups.put(groupName, new FunctionGroup(groupName, null, null, null, null));
+ functionGroups.put(groupName, new FunctionGroup(functionGroups.get(groupName), function, verb));
functions.add(function.getName(), function);
Collections.sort(functions.get(function.getName()),
(a, b) -> Integer.compare(a.ranking, b.ranking));
}
+
endpoints.add(resource);
}
@@ -208,8 +215,18 @@ public synchronized void removeResource(REST resource) {
Iterator i = fs.iterator();
while (i.hasNext()) {
Function f = i.next();
- if (f.target == resource)
+ if (f.target == resource) {
i.remove();
+ for (FunctionGroup fg : functionGroups.values()) {
+ Verb v = fg.hasFunction(f);
+ if (v != null) {
+ FunctionGroup fg2 = new FunctionGroup(fg, null, v);
+ functionGroups.put(fg.name, fg2);
+ if (!fg2.hasFunction())
+ functionGroups.remove(fg.name);
+ }
+ }
+ }
}
}
}
@@ -227,22 +244,30 @@ public synchronized void removeResource(REST resource) {
public boolean execute(HttpServletRequest rq, HttpServletResponse rsp)
throws IOException {
try {
- String path = rq.getPathInfo();
+ String path = rq.getPathInfo();
if (path == null)
path = "";
else if (path.startsWith("/"))
path = path.substring(1);
if (path.equals("openapi.json")) {
+ // TODO: is this true for all method types? Or should this be only for GET?
+ // TODO: how do we treat CORS for this special response?
return doOpenAPI(rq, rsp);
}
+
//
// Find the method's arguments embedded in the url
//
String[] segments = path.split("/");
ExtList list = new ExtList(segments);
- String name = (rq.getMethod() + list.remove(0)).toLowerCase();
+ String first = list.remove(0).toLowerCase();
+ String actualMethod = rq.getMethod().toLowerCase();
+ boolean isHead = "head".equals(actualMethod);
+ String effectiveMethod = isHead ? "get" : actualMethod;
+ String name = (effectiveMethod + first);
int cardinality = list.size();
+ String group = first + "/" + cardinality;
//
// We register methods with their cardinality to not have
@@ -252,13 +277,25 @@ else if (path.startsWith("/"))
if (candidates == null)
candidates = functions.get(name);
- //
+ // TODO: Consider implementing strict adherence (i.e. case-sensitivity) checks to methods.
+ // (Methods are supposed to be all upper case.)
+ boolean isOptions = "OPTIONS".equalsIgnoreCase(rq.getMethod());
+
+ //
// check if we found a suitable candidate
//
-
- if (candidates == null || candidates.isEmpty())
- throw new FileNotFoundException("No such method " + name + "/"
- + cardinality + ". Available: " + functions);
+ FunctionGroup fg = functionGroups.get(group);
+ if ((candidates == null || candidates.isEmpty()) && !isOptions) {
+ if (fg != null) {
+ // The URI exists, but not with this method. Return a 405.
+ rsp.setHeader("Allow", fg.getOptions());
+ throw new NoSuchMethodException();
+ } else {
+ // Return a 404.
+ throw new FileNotFoundException("No such method " + name + "/"
+ + cardinality + ". Available: " + functions);
+ }
+ }
//
// All values are arrays, turn them into singletons when
@@ -292,10 +329,23 @@ else if (path.startsWith("/"))
args.put("_host", rq.getHeader("Host"));
args.put("_response", rsp);
- if (candidates.isEmpty())
+ if (!isOptions && candidates.isEmpty())
return false;
- Function f = candidates.get(0);
+ // The function should be null if and only if it is an OPTIONS request
+ Function f = isOptions ? null : candidates.get(0);
+
+ // Do OPTIONS before CORS
+ if (isOptions)
+ doOptions(rq, rsp, group);
+
+ // CORS needs to be processed for all method types, so call before exiting from OPTIONS request
+ doCORS(rq, rsp, f, fg, isOptions);
+
+ // Don't need to do any more processing for OPTIONS requests
+ if (isOptions)
+ return true;
+
Object[] parameters = f.match(args, list);
if (parameters != null) {
@@ -328,13 +378,13 @@ else if (path.startsWith("/"))
}
try {
- Object result = f.invoke(parameters);
- if (result != null) {
- if ( result instanceof RESTResponse)
- doRestResponse(rq, rsp, (RESTResponse) result);
- else
- printOutput(rq, rsp, result);
- }
+ Object result = f.invoke(parameters);
+ if (result != null) {
+ if ( result instanceof RESTResponse)
+ doRestResponse(rq, rsp, (RESTResponse) result, isHead);
+ else
+ printOutput(rq, rsp, result, isHead);
+ }
} catch (InvocationTargetException e1) {
throw e1.getTargetException();
}
@@ -342,7 +392,7 @@ else if (path.startsWith("/"))
} catch (RESTResponse e) {
- doRestResponse(rq, rsp, e);
+ doRestResponse(rq, rsp, e, false);
} catch (Throwable e) {
int code = ResponseException.getStatusCode(e.getClass());
@@ -352,8 +402,7 @@ else if (path.startsWith("/"))
return true;
}
- private void doRestResponse(HttpServletRequest rq, HttpServletResponse rsp,
- RESTResponse e) {
+ private void doRestResponse(HttpServletRequest rq, HttpServletResponse rsp, RESTResponse e, boolean isHead) {
doResponseHeaders(rsp, e);
if (e.getContentType() != null)
@@ -365,18 +414,56 @@ private void doRestResponse(HttpServletRequest rq, HttpServletResponse rsp,
if (e.getValue() != null)
try {
- printOutput(rq, rsp, e.getValue());
+ printOutput(rq, rsp, e.getValue(), isHead);
} catch (Exception e1) {
log.error("failed to marshall value", e1);
rsp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
- private boolean doOpenAPI(HttpServletRequest rq, HttpServletResponse rsp) throws Exception {
+ private void doOptions(HttpServletRequest rq, HttpServletResponse rsp, String groupName) throws FileNotFoundException {
+ rsp.setStatus(204);
+ FunctionGroup group = functionGroups.get(groupName);
+ if (group == null)
+ throw new FileNotFoundException();
+ rsp.setHeader("Allow", group.getOptions());
+ }
+
+ private void doCORS(HttpServletRequest rq, HttpServletResponse rsp, Function f, FunctionGroup fg, boolean isOptions) {
+ CORSUtil.CORSConfig cors = null;
+ // If it is an OPTIONS request, things are a bit more complicated.
+ // The configuration we actually use depends in part on the method requested.
+ if (isOptions) {
+ String requestedMethod = rq.getHeader("Access-Control-Request-Method");
+ Verb v = null;
+ try {
+ v = Verb.valueOf(requestedMethod.toLowerCase());
+ cors = fg.optionsConfig().apply(v);
+ } catch (Exception e) {
+ // Bad verb requested. Let the CORS request play out.
+ }
+
+ } else {
+ cors = f.getCORS();
+ }
+ if (cors != null) {
+ // If Access-Control-Allow-Origin is determined dynamically, set "Vary: Origin" as a hint to proxy servers
+ if (cors.dynamicOrigin)
+ rsp.addHeader( "Vary", "Origin");
+ if (CORSUtil.isCORSRequest(rq))
+ CORSUtil.doCORS(rq, rsp, cors, fg);
+ } else if (CORSUtil.isCORSRequest(rq)) {
+ // If this is CORS request, but there is no CORS configuration, return a 403
+ // TODO: write to log
+ throw new SecurityException();
+ }
+ }
+
+ private boolean doOpenAPI(HttpServletRequest rq, HttpServletResponse rsp) throws Exception {
OpenAPI openAPI =
new OpenAPI(this,
new URI(rq.getRequestURL().toString()));
- printOutput(rq, rsp, openAPI);
+ printOutput(rq, rsp, openAPI, false);
return true;
}
@@ -429,14 +516,12 @@ public void setDiagnostics(boolean on) {
this.diagnostics = true;
}
- private void printOutput(HttpServletRequest rq, HttpServletResponse rsp,
- Object result) throws Exception {
- printOutput(rq, rsp, result, codec.enc());
+ private void printOutput(HttpServletRequest rq, HttpServletResponse rsp, Object result, boolean isHead) throws Exception {
+ printOutput(rq, rsp, result, codec.enc(), isHead);
}
@SuppressWarnings("unchecked")
- private void printOutput(HttpServletRequest rq, HttpServletResponse rsp,
- Object result, Encoder enc) throws Exception {
+ private void printOutput(HttpServletRequest rq, HttpServletResponse rsp, Object result, Encoder enc, boolean isHead) throws Exception {
//
// Check if we can compress the result
//
@@ -470,21 +555,26 @@ private void printOutput(HttpServletRequest rq, HttpServletResponse rsp,
// are written with json
//
- if (result instanceof InputStream) {
+ if (result instanceof InputStream && !isHead) {
IO.copy((InputStream) result, out);
} else if (result instanceof byte[]) {
byte[] data = (byte[]) result;
rsp.setContentLength(data.length);
- out.write(data);
+ if (!isHead)
+ out.write(data);
} else if (result instanceof File) {
File fresult = (File) result;
rsp.setContentLength((int) fresult.length());
- IO.copy(fresult, out);
+ if (!isHead)
+ IO.copy(fresult, out);
} else {
rsp.setContentType("application/json;charset=UTF-8");
if (result instanceof Iterable)
result = new ExtList