RestPublisher.java
/*
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community 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://opensource.org/licenses/ecl2.txt
*
* 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.opencastproject.kernel.rest;
import org.opencastproject.rest.RestConstants;
import org.opencastproject.rest.StaticResource;
import org.opencastproject.security.api.UnauthorizedException;
import org.opencastproject.systems.OpencastConstants;
import org.opencastproject.util.NotFoundException;
import org.apache.cxf.Bus;
import org.apache.cxf.BusFactory;
import org.apache.cxf.binding.BindingFactoryManager;
import org.apache.cxf.endpoint.Server;
import org.apache.cxf.jaxrs.JAXRSBindingFactory;
import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
import org.apache.cxf.jaxrs.openapi.OpenApiCustomizer;
import org.apache.cxf.jaxrs.openapi.OpenApiFeature;
import org.apache.cxf.jaxrs.provider.json.JSONProvider;
import org.apache.cxf.transport.servlet.CXFNonSpringServlet;
import org.apache.http.HttpStatus;
import org.codehaus.jettison.mapped.Configuration;
import org.codehaus.jettison.mapped.MappedNamespaceConvention;
import org.codehaus.jettison.mapped.MappedXMLStreamWriter;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleEvent;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceReference;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.osgi.service.http.HttpService;
import org.osgi.service.http.whiteboard.HttpWhiteboardConstants;
import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
import org.osgi.util.tracker.BundleTracker;
import org.osgi.util.tracker.ServiceTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
/**
* Listens for JAX-RS annotated services and publishes them to the global URL space using a single shared HttpContext.
*/
@Component(
immediate = true,
service = RestPublisher.class,
property = {
"service.description=Opencast REST Endpoint Publisher"
}
)
public class RestPublisher implements RestConstants {
/** The logger **/
protected static final Logger logger = LoggerFactory.getLogger(RestPublisher.class);
/** The rest publisher looks for any non-servlet with the 'opencast.service.path' property */
public static final String JAX_RS_SERVICE_FILTER = "(" + JaxrsWhiteboardConstants.JAX_RS_RESOURCE + "=true)";
/** A map that sets default xml namespaces in {@link XMLStreamWriter}s */
protected static final ConcurrentHashMap<String, String> NAMESPACE_MAP;
protected List<Object> providers = null;
static {
NAMESPACE_MAP = new ConcurrentHashMap<>();
NAMESPACE_MAP.put("http://www.w3.org/2001/XMLSchema-instance", "");
}
/** The rest publisher's OSGI declarative services component context */
protected ComponentContext componentContext;
/** A service tracker that monitors JAX-RS annotated services, (un)publishing servlets as they (dis)appear */
protected ServiceTracker<Object, Object> jaxRsTracker = null;
/**
* A bundle tracker that registers StaticResource servlets for bundles with the right headers.
*/
protected BundleTracker<Object> bundleTracker = null;
/** The base URL for this server */
protected String baseServerUri;
/** Holds references to servlets that this class publishes, so they can be unpublished later */
protected Map<String, ServiceRegistration<?>> servletRegistrationMap;
/** The JAX-RS Server */
private Server server;
/** The CXF Bus */
private Bus bus;
private ServiceRegistration<Servlet> servletServiceRegistration;
private ServiceRegistration<Bus> busServiceRegistration;
/** The List of JAX-RS resources */
private final List<Object> serviceBeans = new CopyOnWriteArrayList<>();
/** Activates this rest publisher */
@SuppressWarnings("unchecked")
@Activate
protected void activate(ComponentContext componentContext) {
logger.debug("activate()");
baseServerUri = componentContext.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
this.componentContext = componentContext;
servletRegistrationMap = new ConcurrentHashMap<>();
providers = new ArrayList<>();
JSONProvider jsonProvider = new OpencastJSONProvider();
jsonProvider.setIgnoreNamespaces(true);
jsonProvider.setNamespaceMap(NAMESPACE_MAP);
providers.add(jsonProvider);
providers.add(new ExceptionMapper<NotFoundException>() {
@Override
public Response toResponse(NotFoundException e) {
return Response
.status(HttpStatus.SC_NOT_FOUND)
.entity("The resource you requested does not exist.")
.type(MediaType.TEXT_PLAIN)
.build();
}
});
providers.add(new ExceptionMapper<UnauthorizedException>() {
@Override
public Response toResponse(UnauthorizedException e) {
return Response
.status(HttpStatus.SC_UNAUTHORIZED)
.entity("unauthorized")
.type(MediaType.TEXT_PLAIN)
.build();
}
});
this.bus = BusFactory.getDefaultBus();
busServiceRegistration = componentContext.getBundleContext().registerService(Bus.class, bus, new Hashtable<>());
RestServlet cxf = new RestServlet();
cxf.setBus(bus);
try {
Dictionary<String, Object> props = new Hashtable<>();
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT, "(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + RestConstants.HTTP_CONTEXT_ID + ")");
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_NAME, "/");
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/*");
servletServiceRegistration = componentContext.getBundleContext().registerService(Servlet.class, cxf, props);
} catch (Exception e) {
logger.info("Problem registering REST endpoint {} : {}", "/", e.getMessage());
return;
}
try {
jaxRsTracker = new JaxRsServiceTracker();
bundleTracker = new StaticResourceBundleTracker(componentContext.getBundleContext());
} catch (InvalidSyntaxException e) {
throw new IllegalStateException(e);
}
jaxRsTracker.open();
bundleTracker.open();
}
/**
* Deactivates the rest publisher
*/
@Deactivate
protected void deactivate() {
logger.debug("deactivate()");
jaxRsTracker.close();
bundleTracker.close();
busServiceRegistration.unregister();
servletServiceRegistration.unregister();
}
@Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
public void bindHttpService(HttpService httpService) {
logger.debug("HttpService registered");
rewire();
}
public void unbindHttpService(HttpService httpService) {
logger.debug("HttpService unregistered");
}
/**
* Creates a REST endpoint for the JAX-RS annotated service.
*
* @param ref
* the osgi service reference
* @param service
* The service itself
*/
protected synchronized void createEndpoint(ServiceReference<?> ref, Object service) {
String servicePath = (String) ref.getProperty(SERVICE_PATH_PROPERTY);
serviceBeans.add(service);
rewire();
logger.info("Registered REST endpoint at " + servicePath);
}
/**
* Removes an endpoint
*
* @param alias
* The URL space to reclaim
* @param service
* The service reference
*/
protected void destroyEndpoint(String alias, Object service) {
ServiceRegistration<?> reg = servletRegistrationMap.remove(alias);
serviceBeans.remove(service);
if (reg != null) {
reg.unregister();
}
rewire();
}
private synchronized void rewire() {
if (serviceBeans.isEmpty()) {
logger.info("No resource classes skip JAX-RS server recreation");
return;
}
// Set up cxf
JAXRSServerFactoryBean sf = new JAXRSServerFactoryBean();
sf.setBus(bus);
sf.setProviders(providers);
sf.setAddress("/");
sf.setProperties(new HashMap<>());
BindingFactoryManager manager = sf.getBus().getExtension(BindingFactoryManager.class);
JAXRSBindingFactory factory = new JAXRSBindingFactory();
factory.setBus(bus);
manager.registerBindingFactory(JAXRSBindingFactory.JAXRS_BINDING_ID, factory);
if (server != null) {
logger.debug("Destroying JAX-RS server");
server.stop();
server.destroy();
}
// Open API config
final OpenApiFeature openApiFeature = getOpenApiFeature();
sf.getFeatures().add(openApiFeature);
sf.setServiceBeans(serviceBeans);
server = sf.create();
}
private OpenApiFeature getOpenApiFeature() {
final OpenApiFeature openApiFeature = new OpenApiFeature();
openApiFeature.setContactEmail("dev@opencast.org");
openApiFeature.setLicense("Educational Community License, Version 2.0");
openApiFeature.setLicenseUrl("https://opensource.org/licenses/ecl2.txt");
openApiFeature.setScan(false);
openApiFeature.setUseContextBasedConfig(true);
// This is a workaround to turn off class scanning as the classgraph dependency has a bug.
// The defined class acts as a dummy, is available to the classloader, and has no Jax-rs annotations.
openApiFeature.setResourceClasses(Collections.singleton("io.swagger.v3.jaxrs2.Reader"));
OpenApiCustomizer customizer = new OpenApiCustomizer();
customizer.setDynamicBasePath(false);
openApiFeature.setCustomizer(customizer);
openApiFeature.setSupportSwaggerUi(false);
return openApiFeature;
}
/**
* Extends the CXF JSONProvider for the grand purpose of removing '@' symbols from json and padded jsonp.
*/
protected static class OpencastJSONProvider<T> extends JSONProvider<T> {
private static final Charset UTF8 = StandardCharsets.UTF_8;
/**
* {@inheritDoc}
*/
@Override
protected XMLStreamWriter createWriter(Object actualObject, Class<?> actualClass, Type genericType, String enc,
OutputStream os, boolean isCollection) throws Exception {
Configuration c = new Configuration(NAMESPACE_MAP);
c.setSupressAtAttributes(true);
MappedNamespaceConvention convention = new MappedNamespaceConvention(c);
return new MappedXMLStreamWriter(convention, new OutputStreamWriter(os, UTF8)) {
@Override
public void writeStartElement(String prefix, String local, String uri) throws XMLStreamException {
super.writeStartElement("", local, "");
}
@Override
public void writeStartElement(String uri, String local) throws XMLStreamException {
super.writeStartElement("", local, "");
}
@Override
public void setPrefix(String pfx, String uri) throws XMLStreamException {
}
@Override
public void setDefaultNamespace(String uri) throws XMLStreamException {
}
};
}
}
/**
* A custom ServiceTracker that published JAX-RS annotated services with the
* {@link RestPublisher#SERVICE_PATH_PROPERTY} property set to some non-null value.
*/
public class JaxRsServiceTracker extends ServiceTracker<Object, Object> {
JaxRsServiceTracker() throws InvalidSyntaxException {
super(componentContext.getBundleContext(),
componentContext.getBundleContext().createFilter(JAX_RS_SERVICE_FILTER), null);
}
@Override
public void removedService(ServiceReference<Object> reference, Object service) {
String servicePath = (String) reference.getProperty(SERVICE_PATH_PROPERTY);
destroyEndpoint(servicePath, service);
super.removedService(reference, service);
}
@Override
public Object addingService(ServiceReference<Object> reference) {
logger.trace("Adding jaxrs service {}", reference);
Object service = super.addingService(reference);
if (service == null) {
logger.info("JAX-RS service {} has not been instantiated yet, or has already been unregistered. Skipping "
+ "endpoint creation.", reference);
} else {
Path pathAnnotation = getAnnotationFromType(service.getClass(), Path.class);
if (pathAnnotation == null) {
logger.warn(
"{} was registered with '{}={}', but the service is not annotated with the JAX-RS "
+ "@Path annotation",
service, SERVICE_PATH_PROPERTY, reference.getProperty(SERVICE_PATH_PROPERTY));
} else {
createEndpoint(reference, service);
}
}
return service;
}
}
private <A extends Annotation> A getAnnotationFromType(Class<?> classType, final Class<A> annotationClass) {
A annotation;
do {
annotation = classType.getAnnotation(annotationClass);
classType = classType.getSuperclass();
} while (annotation == null && !classType.equals(Object.class));
return annotation;
}
/**
* A classloader that delegates to an OSGI bundle for loading resources.
*/
static class StaticResourceClassLoader extends ClassLoader {
private Bundle bundle = null;
StaticResourceClassLoader(Bundle bundle) {
super();
this.bundle = bundle;
}
@Override
public URL getResource(String name) {
URL url = bundle.getResource(name);
logger.debug("{} found resource {} from name {}", this, url, name);
return url;
}
}
/**
* Tracks bundles containing static resources to be exposed via HTTP URLs.
*/
class StaticResourceBundleTracker extends BundleTracker<Object> {
private final HashMap<Bundle, ServiceRegistration<?>> servlets = new HashMap<>();
/**
* Creates a new StaticResourceBundleTracker.
*
* @param context
* the bundle context
*/
StaticResourceBundleTracker(BundleContext context) {
super(context, Bundle.ACTIVE, null);
}
/**
* {@inheritDoc}
*
* @see org.osgi.util.tracker.BundleTracker#addingBundle(org.osgi.framework.Bundle, org.osgi.framework.BundleEvent)
*/
@Override
public Object addingBundle(Bundle bundle, BundleEvent event) {
String classpath = bundle.getHeaders().get(RestConstants.HTTP_CLASSPATH);
String alias = bundle.getHeaders().get(RestConstants.HTTP_ALIAS);
String welcomeFile = bundle.getHeaders().get(RestConstants.HTTP_WELCOME);
// Always false if not set to true
boolean spaRedirect = Boolean.parseBoolean(bundle.getHeaders().get(RestConstants.HTTP_SPA_REDIRECT));
if (classpath != null && alias != null) {
Dictionary<String, String> props = new Hashtable<>();
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT, "(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + RestConstants.HTTP_CONTEXT_ID + ")");
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_NAME, alias);
if ("/".equals(alias)) {
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, alias);
} else {
props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, alias + "/*");
}
StaticResource servlet = new StaticResource(new StaticResourceClassLoader(bundle), classpath, alias,
welcomeFile, spaRedirect);
// We use the newly added bundle's context to register this service, so when that bundle shuts down, it brings
// down this servlet with it
logger.info("Registering servlet with alias {}", alias + "/*");
ServiceRegistration<?> serviceRegistration = componentContext.getBundleContext()
.registerService(Servlet.class.getName(), servlet, props);
servlets.put(bundle, serviceRegistration);
}
return super.addingBundle(bundle, event);
}
@Override
public void removedBundle(Bundle bundle, BundleEvent event, Object object) {
String classpath = bundle.getHeaders().get(RestConstants.HTTP_CLASSPATH);
String alias = bundle.getHeaders().get(RestConstants.HTTP_ALIAS);
if (classpath != null && alias != null) {
ServiceRegistration<?> serviceRegistration = servlets.get(bundle);
if (serviceRegistration != null) {
serviceRegistration.unregister();
servlets.remove(bundle);
}
}
super.removedBundle(bundle, event, object);
}
}
/**
* An HttpServlet that uses a JAX-RS service to handle requests.
*/
public static class RestServlet extends CXFNonSpringServlet {
/** Serialization UID */
private static final long serialVersionUID = -8963338160276371426L;
/**
* Default constructor needed by Jetty
*/
public RestServlet() {
}
@Override
public void destroyBus() {
// Do not destroy bus if servlet gets unregistered
}
@Override
protected void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException {
if (request.getRequestURI().endsWith("/docs")) {
try {
response.sendRedirect("/docs.html?path=" + request.getServletPath());
} catch (IOException e) {
logger.error("Unable to redirect to rest docs:", e);
}
} else {
super.handleRequest(request, response);
}
}
}
}