Search code examples
jsonspring-mvccorssmartgwtsame-origin-policy

Smartgwt RestDataSource with SpringMVC and cross-client


After a lot of work, I have an existing back-end web services application that is powered by Spring-RS, Spring MVC, Spring controllers, and these controllers user Jackson within the Spring framework to convert responses to JSON.

Here is part of the WEB-INF/myproject-servlet.xml

<mvc:annotation-driven>
    <mvc:message-converters register-defaults="true">
        <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
          <property name="objectMapper">
             <bean class="com.fasterxml.jackson.databind.ObjectMapper">

                     <property name="dateFormat">
                        <bean class="java.text.SimpleDateFormat">
                            <constructor-arg type="java.lang.String" value="yyyy-MM-dd"></constructor-arg>
                        </bean>
                     </property>
             </bean>
          </property>
        </bean>
    </mvc:message-converters>
</mvc:annotation-driven>

<bean id="jsonHttpMessageConverter" class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
    <property name="supportedMediaTypes" value="application/json"/>
</bean>

<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter">
  <property name="messageConverters">
      <list>
        <ref bean="jsonHttpMessageConverter" />
      </list>
  </property>
</bean>

This web-services app works great! I can deploy the WAR to my local tomcat, and it deploys fine. I can unit test he controller to make sure the URL is correct and the web-app is correct configured within Spring. I can hit the URL and get JSON data back exactly as I expected it to. The url is:

http://mylocalhost/myproject/invoices/invoiceId/1

returns 1 invoice.

Now, I am running a SmartGWT web-app, the free version, and I have a RestDataScource controller. I have written many SmartGWT web-apps before, and these apps were all inclusive: entities, dao's, service layer, controllers, and datasources. With this, there was no cross-client issues at all provided the controllers and the datasources were within the same app. And I am not against doing that again, but I want to try to separate them apart.

I just recently saw that this does not work!!! With my SmartGWT web-app running locally within Jetty for development mode. The starting URL is:

     http://mylocalhost:8888/myapp

And when this tries to call the back-end on

    http://mylocalhost:8080/my-ws, then my listgrid gives me a warning message.

If I can just add the one line: RPCManager.setAllowCrossDomainCalls(true); Do I add this within my RESTDataSource? Where do I add this to? And will it really just make everything work? Is there anything else I need to add?

So, I was looking at XJSONDataSource and I saw that I needed to make a few changes to my RestDataSource to convert it to a XJsonDataSource. There is some great information here with another posting and they suggested adding:

   // Where do I put this line?   the controller or the datasource
   String callbackString = request.getParameter("callback");

   // Where do I put this code?  the controller or the datasource
   response.setContentType("text/X-JSON");
   response.getWriter().write( callbackString + " ( " + JSONstring + " ) " );
   response.setStatus(HttpServletResponse.SC_OK);  

I am not sure where this code goes, so I need some extra help there. As far as the controller goes, here is part of what it looks like:

    @RequestMapping(value = "/invoiceId", method = RequestMethod.GET, headers = "Accept=application/json")
    public @ResponseBody
        InvoiceDetailDTO getContactTypeByUserId(@RequestBody String invoiceNumber)
     {
         InvoiceDetailDTO invoiceDetailDto = invoiceService.getInvoiceDetail(invoiceNumber);

        // invoiceDetailDto is automatically converted to JSON by Spring
        return invoiceDetailDto;
     }

In the code above with "request" and "response" have to go into the controller, how do I go about that?

Ultimately, I'd love to just use my RestDataSource and tweak it to work the way I want it to, and ignore any of these cross-site issues. If I do need to use XJSONDataSource, I just need some real good examples, and an example on how to tweak my controllers if needed.

Thanks!


Solution

  • RPCManager.setAllowCrossDomainCalls(true); should be called during early stages of initialization (e.g.- onModuleLoad()).

    getContactTypeByUserId might have to add Access-Control-Allow-Origin as a response header with proper value.
    Check http://en.wikipedia.org/wiki/Cross-origin_resource_sharing.
    Based on http://forums.smartclient.com/showthread.php?t=15487, SmartGWT should handle cross domain requests on its own.

    As a worst case scenario, you might have to send a JSONP style response along with required headers to get this working.
    In that case, its probably best to have a separate method, similar to following, to serve SmartGWT requests.
    I haven't worked with XJSONDataSource, so following is only a guideline.

    // use a slightly different URI to distinguish from other controller method
    @RequestMapping(value = "/invoiceId/sgwt", method = RequestMethod.GET, headers = "Accept=application/json")
    public @ResponseBody String getContactTypeByUserIdForSgwt(@RequestBody String invoiceNumber,
            HttpServletRequest request, HttpServletResponse response) {
    
         // can reuse normal controller method
         InvoiceDetailDTO invoiceDetailDto = getContactTypeByUserId(invoiceNumber);
    
         // use jackson or other tool to convert invoiceDetailDto to a JSON string
         String JSONstring = convertToJson(invoiceDetailDto);
    
        // will have to check SmartGWT request to make sure actual parameter name that send the callback name
        String callbackString = request.getParameter("callback"); 
    
        response.setContentType("text/X-JSON");
    
        return  callbackString + " ( " + JSONstring + " ) " ;
     }
    

    Update

    Probably a good idea to clean up code (or start from scratch/minimum) due to left overs from previous efforts.

    There are three phases in solving this:
    1. Get SmartGWT to work correctly, without using the service
    2. Get the service to work correctly with CORS requests
    3. Switch SmartGWT to use the service

    Phase 1 should be used to iron out any client side issues.
    Skip to phase 2, if client is working with the service when deployed in the same host/domain.

    Phase 1
    For this, its possible to use a data URL that provide a static response, as explained in RestDataSource JSON responses.
    Place the sample response in a file similar to test.json and make it accessible from client web application.
    Keep the DataSource code to a minimum and use setDataURL(); with test.json location.

    test.json - change (and add if needed) field names and values

    {    
     response:{
        status:0,
        startRow:0,
        endRow:3,
        totalRows:3,
        data:[
            {field1:"value", field2:"value"},
            {field1:"value", field2:"value"},
            {field1:"value", field2:"value"},
        ]
     }
    }
    

    DataSource

    public class TestDS extends RestDataSource {
    
        private static TestDS instance = new TestDS();
    
        public static TestDS getInstance() {
            return instance;
        }
    
        private TestDS() {
            setDataURL("data/test.json");       // => http://<client-app-host:port>/<context>/data/test.json
            setDataFormat(DSDataFormat.JSON);
            // setClientOnly(true);
    
            DataSourceTextField field1 = new DataSourceTextField("field1", "Field 1");
            DataSourceTextField field2 = new DataSourceTextField("field2", "Field 2");
    
            setFields(field1, field2);
        }
    }
    

    Phase 2
    Check references for additional details.

    Headers of a failed preflight CORS request made from a page hosted in localhost:8118, and service hosted in localhost:7117.
    Failed due to different port. Will fail on different scheme (https/ftp/file/etc.) or different host/domain as well.

    Host: localhost:7117
    User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:22.0) Gecko/20100101 Firefox/22.0
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
    Accept-Language: en-US,en;q=0.5
    Accept-Encoding: gzip, deflate
    Origin: http://localhost:8118                   <= indicates origin to which access should be granted
    Access-Control-Request-Method: GET              <= indicates the method that will be used in actual request
    Access-Control-Request-Headers: content-type    <= indicates the headers that will be used in actual request
    
    Server: Apache-Coyote/1.1
    Allow: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS
    Content-Length: 0
    

    Request/response header pairs of a successful request.

    Host: localhost:7117
    User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:22.0) Gecko/20100101 Firefox/22.0
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
    Accept-Language: en-US,en;q=0.5
    Accept-Encoding: gzip, deflate
    Origin: http://localhost:8118
    Access-Control-Request-Method: GET
    Access-Control-Request-Headers: content-type
    
    Server: Apache-Coyote/1.1
    Access-Control-Allow-Origin: http://localhost:8118
    Access-Control-Allow-Methods: GET
    Access-Control-Allow-Headers: Content-Type
    Allow: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS
    Content-Length: 0
    
    Host: localhost:7117
    User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:22.0) Gecko/20100101 Firefox/22.0
    Accept: application/json, text/javascript, */*; q=0.01
    Accept-Language: en-US,en;q=0.5
    Accept-Encoding: gzip, deflate
    Content-Type: application/json
    Referer: http://localhost:8118/cors-test.html
    Origin: http://localhost:8118
    
    Server: Apache-Coyote/1.1
    Access-Control-Allow-Origin: *
    Content-Type: application/json
    Transfer-Encoding: chunked
    

    In order to support CORS requests, service backend must respond correctly to the preflight OPTIONS request, not just the service call.
    This can be done using a ServletFilter.

    <filter>
        <filter-name>corsfilter</filter-name>
        <filter-class>test.CorsFilter</filter-class>
    </filter>
    
    <filter-mapping>
        <filter-name>corsfilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    
    public class CorsFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            if (request.getHeader("Access-Control-Request-Method") != null && "OPTIONS".equals(request.getMethod())) {
                response.addHeader("Access-Control-Allow-Origin", "http://localhost:8118");
    
                // list of allowed methods, Access-Control-Request-Method must be a subset of this
                response.addHeader("Access-Control-Allow-Methods", "GET");
                // list of allowed headers, Access-Control-Request-Headers must be a subset of this
                response.addHeader("Access-Control-Allow-Headers", "Content-Type, If-Modified-Since");
    
                // pre-flight request cache timeout
                // response.addHeader("Access-Control-Max-Age", "60");
            }
            filterChain.doFilter(request, response);
        }
    }
    
    @RequestMapping(method = RequestMethod.GET, value = "/values", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map> getValues() {
        List<Map<String, Object>> values = getValues(); // handle actual data processing and return a list suitable for response
    
        SgwtResponse sgwtResponse = new SgwtResponse(); // A POJO with basic (public) attributes
        sgwtResponse.status = 0L;
        sgwtResponse.startRow = 0L;
        sgwtResponse.endRow = Long.valueOf(values.size());
        sgwtResponse.totalRows = sgwtResponse.startRow + sgwtResponse.endRow;
        sgwtResponse.data = values; // java.util.List
    
        Map<String, SgwtResponse> jsonData = new HashMap<String, SgwtResponse>();
        jsonData.put("response", sgwtResponse);
    
        HttpHeaders headers = new HttpHeaders();
        headers.add("Access-Control-Allow-Origin", "*"); // required
    
        return new ResponseEntity<Map>(jsonData, headers, HttpStatus.OK);
    }
    

    A simple test page that use jQuery to retrieve a JSON response using XHR.
    Change URL and deploy in client web application to directly test service, without using SmartGWT.

    <!DOCTYPE html>
    <html>
        <head>
            <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
            <script>
                $(document).ready(function () {
                    $("#retrieve").click(function () {
                        $.ajax({
                            type: "GET",
                            contentType: "application/json",
                            url: "<URL-of-service>",
                            dataType: "json",
                            success: function (data, status, xhr) {
                                $("#content").text(JSON.stringify(data, null, 2));
                            },
                            error: function (xhr, status, error) {
                                $("#content").text("Unable to retrieve data");
                            }
                        });
                    });
                });
            </script>
        </head>
        <body>
            <input type="button" id="retrieve" value="Retrieve"/>
            <div id="content"/>
        </body>
    </html>
    

    If-Modified-Since header was required in Access-Control-Allow-Headers for SmartGWT.
    Use RPCManager.setAllowCrossDomainCalls(true); during SmartGWT initialization to avoid the warning.

    Most modern browsers (Browser compatibility1) and SmartGWT RestDataSource support CORS requests.
    Use XJSONDataSource only when you have to rely on JSONP, due to browser incompatibility with CORS requests.

    Sending Access-Control-Allow-Origin: * for pre-flight request will allow any site to make cross domain calls to the service, which could pose a security issue, plus * can not be used in certain CORS requests.
    Better approach is to specify the exact site to which cross domain requests are allowed - Access-Control-Allow-Origin: http://www.foo.com.
    Probably not required in this case, but check Access-Control-Allow-Origin Multiple Origin Domains? if needed, to find ways to allow multiple sites to make CORS requests.

    References:
    [1] https://developer.mozilla.org/en/docs/HTTP/Access_control_CORS
    [2] http://java-success.blogspot.com/2012/11/cors-and-jquery-with-spring-mvc-restful.html