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, "("
198           + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + RestConstants.HTTP_CONTEXT_ID + ")");
199       props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_NAME, "/");
200       props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/*");
201 
202       servletServiceRegistration = componentContext.getBundleContext().registerService(Servlet.class, cxf, props);
203     } catch (Exception e) {
204       logger.info("Problem registering REST endpoint {} : {}", "/", e.getMessage());
205       return;
206     }
207 
208     try {
209       jaxRsTracker = new JaxRsServiceTracker();
210       bundleTracker = new StaticResourceBundleTracker(componentContext.getBundleContext());
211     } catch (InvalidSyntaxException e) {
212       throw new IllegalStateException(e);
213     }
214     jaxRsTracker.open();
215     bundleTracker.open();
216   }
217 
218   /**
219    * Deactivates the rest publisher
220    */
221   @Deactivate
222   protected void deactivate() {
223     logger.debug("deactivate()");
224     jaxRsTracker.close();
225     bundleTracker.close();
226     busServiceRegistration.unregister();
227     servletServiceRegistration.unregister();
228   }
229 
230   @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
231   public void bindHttpService(HttpService httpService) {
232     logger.debug("HttpService registered");
233     rewire();
234   }
235 
236   public void unbindHttpService(HttpService httpService) {
237     logger.debug("HttpService unregistered");
238   }
239 
240   /**
241    * Creates a REST endpoint for the JAX-RS annotated service.
242    *
243    * @param ref
244    *          the osgi service reference
245    * @param service
246    *          The service itself
247    */
248   protected synchronized void createEndpoint(ServiceReference<?> ref, Object service) {
249     String servicePath = (String) ref.getProperty(SERVICE_PATH_PROPERTY);
250     serviceBeans.add(service);
251     rewire();
252     logger.info("Registered REST endpoint at " + servicePath);
253   }
254 
255   /**
256    * Removes an endpoint
257    *
258    * @param alias
259    *          The URL space to reclaim
260    * @param service
261    *          The service reference
262    */
263   protected void destroyEndpoint(String alias, Object service) {
264     ServiceRegistration<?> reg = servletRegistrationMap.remove(alias);
265     serviceBeans.remove(service);
266     if (reg != null) {
267       reg.unregister();
268     }
269 
270     rewire();
271   }
272 
273   private synchronized void rewire() {
274 
275     if (serviceBeans.isEmpty()) {
276       logger.info("No resource classes skip JAX-RS server recreation");
277       return;
278     }
279 
280     // Set up cxf
281     JAXRSServerFactoryBean sf = new JAXRSServerFactoryBean();
282     sf.setBus(bus);
283     sf.setProviders(providers);
284 
285     sf.setAddress("/");
286 
287 
288     sf.setProperties(new HashMap<>());
289     BindingFactoryManager manager = sf.getBus().getExtension(BindingFactoryManager.class);
290     JAXRSBindingFactory factory = new JAXRSBindingFactory();
291     factory.setBus(bus);
292     manager.registerBindingFactory(JAXRSBindingFactory.JAXRS_BINDING_ID, factory);
293 
294     if (server != null) {
295       logger.debug("Destroying JAX-RS server");
296       server.stop();
297       server.destroy();
298     }
299 
300     // Open API config
301     final OpenApiFeature openApiFeature = getOpenApiFeature();
302     sf.getFeatures().add(openApiFeature);
303 
304     sf.setServiceBeans(serviceBeans);
305     server = sf.create();
306   }
307 
308   private OpenApiFeature getOpenApiFeature() {
309     final OpenApiFeature openApiFeature = new OpenApiFeature();
310     openApiFeature.setContactEmail("dev@opencast.org");
311     openApiFeature.setLicense("Educational Community License, Version 2.0");
312     openApiFeature.setLicenseUrl("https://opensource.org/licenses/ecl2.txt");
313     openApiFeature.setScan(false);
314     openApiFeature.setUseContextBasedConfig(true);
315     // This is a workaround to turn off class scanning as the classgraph dependency has a bug.
316     // The defined class acts as a dummy, is available to the classloader, and has no Jax-rs annotations.
317     openApiFeature.setResourceClasses(Collections.singleton("io.swagger.v3.jaxrs2.Reader"));
318     OpenApiCustomizer customizer = new OpenApiCustomizer();
319     customizer.setDynamicBasePath(false);
320     openApiFeature.setCustomizer(customizer);
321     openApiFeature.setSupportSwaggerUi(false);
322     return openApiFeature;
323   }
324 
325   /**
326    * Extends the CXF JSONProvider for the grand purpose of removing '@' symbols from json and padded jsonp.
327    */
328   protected static class OpencastJSONProvider<T> extends JSONProvider<T> {
329     private static final Charset UTF8 = StandardCharsets.UTF_8;
330 
331     /**
332      * {@inheritDoc}
333      */
334     @Override
335     protected XMLStreamWriter createWriter(Object actualObject, Class<?> actualClass, Type genericType, String enc,
336             OutputStream os, boolean isCollection) throws Exception {
337       Configuration c = new Configuration(NAMESPACE_MAP);
338       c.setSupressAtAttributes(true);
339       MappedNamespaceConvention convention = new MappedNamespaceConvention(c);
340       return new MappedXMLStreamWriter(convention, new OutputStreamWriter(os, UTF8)) {
341         @Override
342         public void writeStartElement(String prefix, String local, String uri) throws XMLStreamException {
343           super.writeStartElement("", local, "");
344         }
345 
346         @Override
347         public void writeStartElement(String uri, String local) throws XMLStreamException {
348           super.writeStartElement("", local, "");
349         }
350 
351         @Override
352         public void setPrefix(String pfx, String uri) throws XMLStreamException {
353         }
354 
355         @Override
356         public void setDefaultNamespace(String uri) throws XMLStreamException {
357         }
358       };
359     }
360   }
361 
362   /**
363    * A custom ServiceTracker that published JAX-RS annotated services with the
364    * {@link RestPublisher#SERVICE_PATH_PROPERTY} property set to some non-null value.
365    */
366   public class JaxRsServiceTracker extends ServiceTracker<Object, Object> {
367 
368     JaxRsServiceTracker() throws InvalidSyntaxException {
369       super(componentContext.getBundleContext(),
370               componentContext.getBundleContext().createFilter(JAX_RS_SERVICE_FILTER), null);
371     }
372 
373     @Override
374     public void removedService(ServiceReference<Object> reference, Object service) {
375       String servicePath = (String) reference.getProperty(SERVICE_PATH_PROPERTY);
376       destroyEndpoint(servicePath, service);
377       super.removedService(reference, service);
378     }
379 
380     @Override
381     public Object addingService(ServiceReference<Object> reference) {
382       logger.trace("Adding jaxrs service {}", reference);
383       Object service = super.addingService(reference);
384       if (service == null) {
385         logger.info("JAX-RS service {} has not been instantiated yet, or has already been unregistered. Skipping "
386                 + "endpoint creation.", reference);
387       } else {
388         Path pathAnnotation = getAnnotationFromType(service.getClass(), Path.class);
389         if (pathAnnotation == null) {
390           logger.warn(
391                   "{} was registered with '{}={}', but the service is not annotated with the JAX-RS "
392                           + "@Path annotation",
393                   service, SERVICE_PATH_PROPERTY, reference.getProperty(SERVICE_PATH_PROPERTY));
394         } else {
395           createEndpoint(reference, service);
396         }
397       }
398       return service;
399     }
400   }
401 
402   private <A extends Annotation> A getAnnotationFromType(Class<?> classType, final Class<A> annotationClass) {
403     A annotation;
404     do {
405       annotation = classType.getAnnotation(annotationClass);
406       classType = classType.getSuperclass();
407     } while (annotation == null && !classType.equals(Object.class));
408     return annotation;
409   }
410 
411   /**
412    * A classloader that delegates to an OSGI bundle for loading resources.
413    */
414   static class StaticResourceClassLoader extends ClassLoader {
415     private Bundle bundle = null;
416 
417     StaticResourceClassLoader(Bundle bundle) {
418       super();
419       this.bundle = bundle;
420     }
421 
422     @Override
423     public URL getResource(String name) {
424       URL url = bundle.getResource(name);
425       logger.debug("{} found resource {} from name {}", this, url, name);
426       return url;
427     }
428   }
429 
430   /**
431    * Tracks bundles containing static resources to be exposed via HTTP URLs.
432    */
433   class StaticResourceBundleTracker extends BundleTracker<Object> {
434 
435     private final HashMap<Bundle, ServiceRegistration<?>> servlets = new HashMap<>();
436 
437     /**
438      * Creates a new StaticResourceBundleTracker.
439      *
440      * @param context
441      *          the bundle context
442      */
443     StaticResourceBundleTracker(BundleContext context) {
444       super(context, Bundle.ACTIVE, null);
445     }
446 
447     /**
448      * {@inheritDoc}
449      *
450      * @see org.osgi.util.tracker.BundleTracker#addingBundle(org.osgi.framework.Bundle, org.osgi.framework.BundleEvent)
451      */
452     @Override
453     public Object addingBundle(Bundle bundle, BundleEvent event) {
454       String classpath = bundle.getHeaders().get(RestConstants.HTTP_CLASSPATH);
455       String alias = bundle.getHeaders().get(RestConstants.HTTP_ALIAS);
456       String welcomeFile = bundle.getHeaders().get(RestConstants.HTTP_WELCOME);
457       // Always false if not set to true
458       boolean spaRedirect = Boolean.parseBoolean(bundle.getHeaders().get(RestConstants.HTTP_SPA_REDIRECT));
459 
460       if (classpath != null && alias != null) {
461         Dictionary<String, String> props = new Hashtable<>();
462 
463         props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT, "("
464             + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=" + RestConstants.HTTP_CONTEXT_ID + ")");
465         props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_NAME, alias);
466         if ("/".equals(alias)) {
467           props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, alias);
468         } else {
469           props.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, alias + "/*");
470         }
471         StaticResource servlet = new StaticResource(new StaticResourceClassLoader(bundle), classpath, alias,
472                 welcomeFile, spaRedirect);
473 
474         // We use the newly added bundle's context to register this service, so when that bundle shuts down, it brings
475         // down this servlet with it
476         logger.info("Registering servlet with alias {}", alias + "/*");
477 
478         ServiceRegistration<?> serviceRegistration = componentContext.getBundleContext()
479                 .registerService(Servlet.class.getName(), servlet, props);
480         servlets.put(bundle, serviceRegistration);
481       }
482 
483       return super.addingBundle(bundle, event);
484     }
485 
486     @Override
487     public void removedBundle(Bundle bundle, BundleEvent event, Object object) {
488       String classpath = bundle.getHeaders().get(RestConstants.HTTP_CLASSPATH);
489       String alias = bundle.getHeaders().get(RestConstants.HTTP_ALIAS);
490       if (classpath != null && alias != null) {
491         ServiceRegistration<?> serviceRegistration = servlets.get(bundle);
492         if (serviceRegistration != null) {
493           serviceRegistration.unregister();
494           servlets.remove(bundle);
495         }
496       }
497 
498       super.removedBundle(bundle, event, object);
499     }
500   }
501 
502   /**
503    * An HttpServlet that uses a JAX-RS service to handle requests.
504    */
505   public static class RestServlet extends CXFNonSpringServlet {
506     /** Serialization UID */
507     private static final long serialVersionUID = -8963338160276371426L;
508 
509     /**
510      * Default constructor needed by Jetty
511      */
512     public RestServlet() {
513 
514     }
515 
516     @Override
517     public void destroyBus() {
518       // Do not destroy bus if servlet gets unregistered
519     }
520 
521     @Override
522     protected void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException {
523       if (request.getRequestURI().endsWith("/docs")) {
524         try {
525           response.sendRedirect("/docs.html?path=" + request.getServletPath());
526         } catch (IOException e) {
527           logger.error("Unable to redirect to rest docs:", e);
528         }
529       } else {
530         super.handleRequest(request, response);
531       }
532     }
533 
534   }
535 
536 }