View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.kernel.rest;
23  
24  import org.opencastproject.rest.RestConstants;
25  import org.opencastproject.rest.StaticResource;
26  import org.opencastproject.security.api.UnauthorizedException;
27  import org.opencastproject.systems.OpencastConstants;
28  import org.opencastproject.util.NotFoundException;
29  
30  import org.apache.cxf.Bus;
31  import org.apache.cxf.BusFactory;
32  import org.apache.cxf.binding.BindingFactoryManager;
33  import org.apache.cxf.endpoint.Server;
34  import org.apache.cxf.jaxrs.JAXRSBindingFactory;
35  import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
36  import org.apache.cxf.jaxrs.openapi.OpenApiCustomizer;
37  import org.apache.cxf.jaxrs.openapi.OpenApiFeature;
38  import org.apache.cxf.jaxrs.provider.json.JSONProvider;
39  import org.apache.cxf.transport.servlet.CXFNonSpringServlet;
40  import org.apache.http.HttpStatus;
41  import org.codehaus.jettison.mapped.Configuration;
42  import org.codehaus.jettison.mapped.MappedNamespaceConvention;
43  import org.codehaus.jettison.mapped.MappedXMLStreamWriter;
44  import org.osgi.framework.Bundle;
45  import org.osgi.framework.BundleContext;
46  import org.osgi.framework.BundleEvent;
47  import org.osgi.framework.InvalidSyntaxException;
48  import org.osgi.framework.ServiceReference;
49  import org.osgi.framework.ServiceRegistration;
50  import org.osgi.service.component.ComponentContext;
51  import org.osgi.service.component.annotations.Activate;
52  import org.osgi.service.component.annotations.Component;
53  import org.osgi.service.component.annotations.Deactivate;
54  import org.osgi.service.component.annotations.Reference;
55  import org.osgi.service.component.annotations.ReferenceCardinality;
56  import org.osgi.service.component.annotations.ReferencePolicy;
57  import org.osgi.service.http.HttpService;
58  import org.osgi.service.http.whiteboard.HttpWhiteboardConstants;
59  import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
60  import org.osgi.util.tracker.BundleTracker;
61  import org.osgi.util.tracker.ServiceTracker;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  import java.io.IOException;
66  import java.io.OutputStream;
67  import java.io.OutputStreamWriter;
68  import java.lang.annotation.Annotation;
69  import java.lang.reflect.Type;
70  import java.net.URL;
71  import java.nio.charset.Charset;
72  import java.nio.charset.StandardCharsets;
73  import java.util.ArrayList;
74  import java.util.Collections;
75  import java.util.Dictionary;
76  import java.util.HashMap;
77  import java.util.Hashtable;
78  import java.util.List;
79  import java.util.Map;
80  import java.util.concurrent.ConcurrentHashMap;
81  import java.util.concurrent.CopyOnWriteArrayList;
82  
83  import javax.servlet.Servlet;
84  import javax.servlet.ServletException;
85  import javax.servlet.http.HttpServletRequest;
86  import javax.servlet.http.HttpServletResponse;
87  import javax.ws.rs.Path;
88  import javax.ws.rs.core.MediaType;
89  import javax.ws.rs.core.Response;
90  import javax.ws.rs.ext.ExceptionMapper;
91  import javax.xml.stream.XMLStreamException;
92  import javax.xml.stream.XMLStreamWriter;
93  
94  /**
95   * Listens for JAX-RS annotated services and publishes them to the global URL space using a single shared HttpContext.
96   */
97  @Component(
98      immediate = true,
99      service = RestPublisher.class,
100     property = {
101         "service.description=Opencast REST Endpoint Publisher"
102     }
103 )
104 public class RestPublisher implements RestConstants {
105 
106   /** The logger **/
107   protected static final Logger logger = LoggerFactory.getLogger(RestPublisher.class);
108 
109   /** The rest publisher looks for any non-servlet with the 'opencast.service.path' property */
110   public static final String JAX_RS_SERVICE_FILTER = "(" + JaxrsWhiteboardConstants.JAX_RS_RESOURCE + "=true)";
111 
112   /** A map that sets default xml namespaces in {@link XMLStreamWriter}s */
113   protected static final ConcurrentHashMap<String, String> NAMESPACE_MAP;
114 
115   protected List<Object> providers = null;
116 
117   static {
118     NAMESPACE_MAP = new ConcurrentHashMap<>();
119     NAMESPACE_MAP.put("http://www.w3.org/2001/XMLSchema-instance", "");
120   }
121 
122   /** The rest publisher's OSGI declarative services component context */
123   protected ComponentContext componentContext;
124 
125   /** A service tracker that monitors JAX-RS annotated services, (un)publishing servlets as they (dis)appear */
126   protected ServiceTracker<Object, Object> jaxRsTracker = null;
127 
128   /**
129    * A bundle tracker that registers StaticResource servlets for bundles with the right headers.
130    */
131   protected BundleTracker<Object> bundleTracker = null;
132 
133   /** The base URL for this server */
134   protected String baseServerUri;
135 
136   /** Holds references to servlets that this class publishes, so they can be unpublished later */
137   protected Map<String, ServiceRegistration<?>> servletRegistrationMap;
138 
139   /** The JAX-RS Server */
140   private Server server;
141 
142   /** The CXF Bus */
143   private Bus bus;
144 
145   private ServiceRegistration<Servlet> servletServiceRegistration;
146 
147   private ServiceRegistration<Bus> busServiceRegistration;
148 
149   /** The List of JAX-RS resources */
150   private final List<Object> serviceBeans = new CopyOnWriteArrayList<>();
151 
152   /** Activates this rest publisher */
153   @SuppressWarnings("unchecked")
154   @Activate
155   protected void activate(ComponentContext componentContext) {
156     logger.debug("activate()");
157     baseServerUri = componentContext.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
158     this.componentContext = componentContext;
159     servletRegistrationMap = new ConcurrentHashMap<>();
160     providers = new ArrayList<>();
161 
162     JSONProvider jsonProvider = new OpencastJSONProvider();
163     jsonProvider.setIgnoreNamespaces(true);
164     jsonProvider.setNamespaceMap(NAMESPACE_MAP);
165     providers.add(jsonProvider);
166 
167     providers.add(new ExceptionMapper<NotFoundException>() {
168       @Override
169       public Response toResponse(NotFoundException e) {
170         return Response
171                 .status(HttpStatus.SC_NOT_FOUND)
172                 .entity("The resource you requested does not exist.")
173                 .type(MediaType.TEXT_PLAIN)
174                 .build();
175       }
176     });
177     providers.add(new ExceptionMapper<UnauthorizedException>() {
178       @Override
179       public Response toResponse(UnauthorizedException e) {
180         return Response
181                 .status(HttpStatus.SC_UNAUTHORIZED)
182                 .entity("unauthorized")
183                 .type(MediaType.TEXT_PLAIN)
184                 .build();
185       }
186     });
187 
188     this.bus = BusFactory.getDefaultBus();
189 
190     busServiceRegistration = componentContext.getBundleContext().registerService(Bus.class, bus, new Hashtable<>());
191 
192     RestServlet cxf = new RestServlet();
193     cxf.setBus(bus);
194     try {
195       Dictionary<String, Object> props = new Hashtable<>();
196 
197       props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT, "(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + RestConstants.HTTP_CONTEXT_ID + ")");
198       props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_NAME, "/");
199       props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/*");
200 
201       servletServiceRegistration = componentContext.getBundleContext().registerService(Servlet.class, cxf, props);
202     } catch (Exception e) {
203       logger.info("Problem registering REST endpoint {} : {}", "/", e.getMessage());
204       return;
205     }
206 
207     try {
208       jaxRsTracker = new JaxRsServiceTracker();
209       bundleTracker = new StaticResourceBundleTracker(componentContext.getBundleContext());
210     } catch (InvalidSyntaxException e) {
211       throw new IllegalStateException(e);
212     }
213     jaxRsTracker.open();
214     bundleTracker.open();
215   }
216 
217   /**
218    * Deactivates the rest publisher
219    */
220   @Deactivate
221   protected void deactivate() {
222     logger.debug("deactivate()");
223     jaxRsTracker.close();
224     bundleTracker.close();
225     busServiceRegistration.unregister();
226     servletServiceRegistration.unregister();
227   }
228 
229   @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
230   public void bindHttpService(HttpService httpService) {
231     logger.debug("HttpService registered");
232     rewire();
233   }
234 
235   public void unbindHttpService(HttpService httpService) {
236     logger.debug("HttpService unregistered");
237   }
238 
239   /**
240    * Creates a REST endpoint for the JAX-RS annotated service.
241    *
242    * @param ref
243    *          the osgi service reference
244    * @param service
245    *          The service itself
246    */
247   protected synchronized void createEndpoint(ServiceReference<?> ref, Object service) {
248     String servicePath = (String) ref.getProperty(SERVICE_PATH_PROPERTY);
249     serviceBeans.add(service);
250     rewire();
251     logger.info("Registered REST endpoint at " + servicePath);
252   }
253 
254   /**
255    * Removes an endpoint
256    *
257    * @param alias
258    *          The URL space to reclaim
259    * @param service
260    *          The service reference
261    */
262   protected void destroyEndpoint(String alias, Object service) {
263     ServiceRegistration<?> reg = servletRegistrationMap.remove(alias);
264     serviceBeans.remove(service);
265     if (reg != null) {
266       reg.unregister();
267     }
268 
269     rewire();
270   }
271 
272   private synchronized void rewire() {
273 
274     if (serviceBeans.isEmpty()) {
275       logger.info("No resource classes skip JAX-RS server recreation");
276       return;
277     }
278 
279     // Set up cxf
280     JAXRSServerFactoryBean sf = new JAXRSServerFactoryBean();
281     sf.setBus(bus);
282     sf.setProviders(providers);
283 
284     sf.setAddress("/");
285 
286 
287     sf.setProperties(new HashMap<>());
288     BindingFactoryManager manager = sf.getBus().getExtension(BindingFactoryManager.class);
289     JAXRSBindingFactory factory = new JAXRSBindingFactory();
290     factory.setBus(bus);
291     manager.registerBindingFactory(JAXRSBindingFactory.JAXRS_BINDING_ID, factory);
292 
293     if (server != null) {
294       logger.debug("Destroying JAX-RS server");
295       server.stop();
296       server.destroy();
297     }
298 
299     // Open API config
300     final OpenApiFeature openApiFeature = getOpenApiFeature();
301     sf.getFeatures().add(openApiFeature);
302 
303     sf.setServiceBeans(serviceBeans);
304     server = sf.create();
305   }
306 
307   private OpenApiFeature getOpenApiFeature() {
308     final OpenApiFeature openApiFeature = new OpenApiFeature();
309     openApiFeature.setContactEmail("dev@opencast.org");
310     openApiFeature.setLicense("Educational Community License, Version 2.0");
311     openApiFeature.setLicenseUrl("https://opensource.org/licenses/ecl2.txt");
312     openApiFeature.setScan(false);
313     openApiFeature.setUseContextBasedConfig(true);
314     // This is a workaround to turn off class scanning as the classgraph dependency has a bug.
315     // The defined class acts as a dummy, is available to the classloader, and has no Jax-rs annotations.
316     openApiFeature.setResourceClasses(Collections.singleton("io.swagger.v3.jaxrs2.Reader"));
317     OpenApiCustomizer customizer = new OpenApiCustomizer();
318     customizer.setDynamicBasePath(false);
319     openApiFeature.setCustomizer(customizer);
320     openApiFeature.setSupportSwaggerUi(false);
321     return openApiFeature;
322   }
323 
324   /**
325    * Extends the CXF JSONProvider for the grand purpose of removing '@' symbols from json and padded jsonp.
326    */
327   protected static class OpencastJSONProvider<T> extends JSONProvider<T> {
328     private static final Charset UTF8 = StandardCharsets.UTF_8;
329 
330     /**
331      * {@inheritDoc}
332      */
333     @Override
334     protected XMLStreamWriter createWriter(Object actualObject, Class<?> actualClass, Type genericType, String enc,
335             OutputStream os, boolean isCollection) throws Exception {
336       Configuration c = new Configuration(NAMESPACE_MAP);
337       c.setSupressAtAttributes(true);
338       MappedNamespaceConvention convention = new MappedNamespaceConvention(c);
339       return new MappedXMLStreamWriter(convention, new OutputStreamWriter(os, UTF8)) {
340         @Override
341         public void writeStartElement(String prefix, String local, String uri) throws XMLStreamException {
342           super.writeStartElement("", local, "");
343         }
344 
345         @Override
346         public void writeStartElement(String uri, String local) throws XMLStreamException {
347           super.writeStartElement("", local, "");
348         }
349 
350         @Override
351         public void setPrefix(String pfx, String uri) throws XMLStreamException {
352         }
353 
354         @Override
355         public void setDefaultNamespace(String uri) throws XMLStreamException {
356         }
357       };
358     }
359   }
360 
361   /**
362    * A custom ServiceTracker that published JAX-RS annotated services with the
363    * {@link RestPublisher#SERVICE_PATH_PROPERTY} property set to some non-null value.
364    */
365   public class JaxRsServiceTracker extends ServiceTracker<Object, Object> {
366 
367     JaxRsServiceTracker() throws InvalidSyntaxException {
368       super(componentContext.getBundleContext(),
369               componentContext.getBundleContext().createFilter(JAX_RS_SERVICE_FILTER), null);
370     }
371 
372     @Override
373     public void removedService(ServiceReference<Object> reference, Object service) {
374       String servicePath = (String) reference.getProperty(SERVICE_PATH_PROPERTY);
375       destroyEndpoint(servicePath, service);
376       super.removedService(reference, service);
377     }
378 
379     @Override
380     public Object addingService(ServiceReference<Object> reference) {
381       logger.trace("Adding jaxrs service {}", reference);
382       Object service = super.addingService(reference);
383       if (service == null) {
384         logger.info("JAX-RS service {} has not been instantiated yet, or has already been unregistered. Skipping "
385                 + "endpoint creation.", reference);
386       } else {
387         Path pathAnnotation = getAnnotationFromType(service.getClass(), Path.class);
388         if (pathAnnotation == null) {
389           logger.warn(
390                   "{} was registered with '{}={}', but the service is not annotated with the JAX-RS "
391                           + "@Path annotation",
392                   service, SERVICE_PATH_PROPERTY, reference.getProperty(SERVICE_PATH_PROPERTY));
393         } else {
394           createEndpoint(reference, service);
395         }
396       }
397       return service;
398     }
399   }
400 
401   private <A extends Annotation> A getAnnotationFromType(Class<?> classType, final Class<A> annotationClass) {
402     A annotation;
403     do {
404       annotation = classType.getAnnotation(annotationClass);
405       classType = classType.getSuperclass();
406     } while (annotation == null && !classType.equals(Object.class));
407     return annotation;
408   }
409 
410   /**
411    * A classloader that delegates to an OSGI bundle for loading resources.
412    */
413   static class StaticResourceClassLoader extends ClassLoader {
414     private Bundle bundle = null;
415 
416     StaticResourceClassLoader(Bundle bundle) {
417       super();
418       this.bundle = bundle;
419     }
420 
421     @Override
422     public URL getResource(String name) {
423       URL url = bundle.getResource(name);
424       logger.debug("{} found resource {} from name {}", this, url, name);
425       return url;
426     }
427   }
428 
429   /**
430    * Tracks bundles containing static resources to be exposed via HTTP URLs.
431    */
432   class StaticResourceBundleTracker extends BundleTracker<Object> {
433 
434     private final HashMap<Bundle, ServiceRegistration<?>> servlets = new HashMap<>();
435 
436     /**
437      * Creates a new StaticResourceBundleTracker.
438      *
439      * @param context
440      *          the bundle context
441      */
442     StaticResourceBundleTracker(BundleContext context) {
443       super(context, Bundle.ACTIVE, null);
444     }
445 
446     /**
447      * {@inheritDoc}
448      *
449      * @see org.osgi.util.tracker.BundleTracker#addingBundle(org.osgi.framework.Bundle, org.osgi.framework.BundleEvent)
450      */
451     @Override
452     public Object addingBundle(Bundle bundle, BundleEvent event) {
453       String classpath = bundle.getHeaders().get(RestConstants.HTTP_CLASSPATH);
454       String alias = bundle.getHeaders().get(RestConstants.HTTP_ALIAS);
455       String welcomeFile = bundle.getHeaders().get(RestConstants.HTTP_WELCOME);
456       // Always false if not set to true
457       boolean spaRedirect = Boolean.parseBoolean(bundle.getHeaders().get(RestConstants.HTTP_SPA_REDIRECT));
458 
459       if (classpath != null && alias != null) {
460         Dictionary<String, String> props = new Hashtable<>();
461 
462         props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT, "(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + RestConstants.HTTP_CONTEXT_ID + ")");
463         props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_NAME, alias);
464         if ("/".equals(alias)) {
465           props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, alias);
466         } else {
467           props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, alias + "/*");
468         }
469         StaticResource servlet = new StaticResource(new StaticResourceClassLoader(bundle), classpath, alias,
470                 welcomeFile, spaRedirect);
471 
472         // We use the newly added bundle's context to register this service, so when that bundle shuts down, it brings
473         // down this servlet with it
474         logger.info("Registering servlet with alias {}", alias + "/*");
475 
476         ServiceRegistration<?> serviceRegistration = componentContext.getBundleContext()
477                 .registerService(Servlet.class.getName(), servlet, props);
478         servlets.put(bundle, serviceRegistration);
479       }
480 
481       return super.addingBundle(bundle, event);
482     }
483 
484     @Override
485     public void removedBundle(Bundle bundle, BundleEvent event, Object object) {
486       String classpath = bundle.getHeaders().get(RestConstants.HTTP_CLASSPATH);
487       String alias = bundle.getHeaders().get(RestConstants.HTTP_ALIAS);
488       if (classpath != null && alias != null) {
489         ServiceRegistration<?> serviceRegistration = servlets.get(bundle);
490         if (serviceRegistration != null) {
491           serviceRegistration.unregister();
492           servlets.remove(bundle);
493         }
494       }
495 
496       super.removedBundle(bundle, event, object);
497     }
498   }
499 
500   /**
501    * An HttpServlet that uses a JAX-RS service to handle requests.
502    */
503   public static class RestServlet extends CXFNonSpringServlet {
504     /** Serialization UID */
505     private static final long serialVersionUID = -8963338160276371426L;
506 
507     /**
508      * Default constructor needed by Jetty
509      */
510     public RestServlet() {
511 
512     }
513 
514     @Override
515     public void destroyBus() {
516       // Do not destroy bus if servlet gets unregistered
517     }
518 
519     @Override
520     protected void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException {
521       if (request.getRequestURI().endsWith("/docs")) {
522         try {
523           response.sendRedirect("/docs.html?path=" + request.getServletPath());
524         } catch (IOException e) {
525           logger.error("Unable to redirect to rest docs:", e);
526         }
527       } else {
528         super.handleRequest(request, response);
529       }
530     }
531 
532   }
533 
534 }