001/* 002 * Copyright 2008-2011 Thomas Nichols. http://blog.thomnichols.org 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * You are receiving this code free of charge, which represents many hours of 017 * effort from other individuals and corporations. As a responsible member 018 * of the community, you are encouraged (but not required) to donate any 019 * enhancements or improvements back to the community under a similar open 020 * source license. Thank you. -TMN 021 */ 022package groovyx.net.http; 023 024import groovy.lang.Closure; 025import groovy.lang.GString; 026import groovy.lang.Writable; 027import groovy.xml.StreamingMarkupBuilder; 028import groovyx.net.http.HTTPBuilder.RequestConfigDelegate; 029 030import java.io.BufferedReader; 031import java.io.ByteArrayInputStream; 032import java.io.ByteArrayOutputStream; 033import java.io.IOException; 034import java.io.InputStream; 035import java.io.PrintWriter; 036import java.io.Reader; 037import java.io.StringWriter; 038import java.io.UnsupportedEncodingException; 039import java.nio.charset.Charset; 040import java.util.ArrayList; 041import java.util.Collection; 042import java.util.HashMap; 043import java.util.Iterator; 044import java.util.List; 045import java.util.Map; 046 047import net.sf.json.JSON; 048import net.sf.json.JSONArray; 049import net.sf.json.JSONObject; 050import net.sf.json.groovy.JsonGroovyBuilder; 051 052import org.apache.http.HttpEntity; 053import org.apache.http.HttpEntityEnclosingRequest; 054import org.apache.http.NameValuePair; 055import org.apache.http.client.entity.UrlEncodedFormEntity; 056import org.apache.http.entity.InputStreamEntity; 057import org.apache.http.entity.StringEntity; 058import org.apache.http.message.BasicNameValuePair; 059import org.codehaus.groovy.runtime.DefaultGroovyMethods; 060import org.codehaus.groovy.runtime.MethodClosure; 061 062 063/** 064 * <p>This class handles creation of the request body (i.e. for a 065 * PUT or POST operation) based on content-type. When a 066 * {@link RequestConfigDelegate#setBody(Object) body} is set from the builder, it is 067 * processed based on the {@link RequestConfigDelegate#getRequestContentType() 068 * request content-type}. For instance, the {@link #encodeForm(Map)} method 069 * will be invoked if the request content-type is form-urlencoded, which will 070 * cause the following:<code>body=[a:1, b:'two']</code> to be encoded as 071 * the equivalent <code>a=1&b=two</code> in the request body.</p> 072 * 073 * <p>Most default encoders can handle a closure as a request body. In this 074 * case, the closure is executed and a suitable 'builder' passed to the 075 * closure that is used for constructing the content. In the case of 076 * binary encoding this would be an OutputStream; for TEXT encoding it would 077 * be a PrintWriter, and for XML it would be an already-bound 078 * {@link StreamingMarkupBuilder}. See each <code>encode...</code> method 079 * for details for each particular content-type.</p> 080 * 081 * <p>Contrary to its name, this class does not have anything to do with the 082 * <code>content-encoding</code> HTTP header. </p> 083 * 084 * @see RequestConfigDelegate#setBody(Object) 085 * @see RequestConfigDelegate#send(Object, Object) 086 * @author <a href='mailto:tomstrummer+httpbuilder@gmail.com'>Tom Nichols</a> 087 */ 088public class EncoderRegistry implements Iterable<Map.Entry<String,Closure>> { 089 090 Charset charset = Charset.defaultCharset(); // 1.5 091 private Map<String,Closure> registeredEncoders = buildDefaultEncoderMap(); 092 093 /** 094 * Set the charset used in the content-type header of all requests that send 095 * textual data. This must be a chaset supported by the Java platform 096 * @see Charset#forName(String) 097 * @param charset 098 */ 099 public void setCharset( String charset ) { 100 this.charset = Charset.forName(charset); 101 } 102 103 /** 104 * Default request encoder for a binary stream. Acceptable argument 105 * types are: 106 * <ul> 107 * <li>InputStream</li> 108 * <li>byte[] / ByteArrayOutputStream</li> 109 * <li>Closure</li> 110 * </ul> 111 * If a closure is given, it is executed with an OutputStream passed 112 * as the single closure argument. Any data sent to the stream from the 113 * body of the closure is used as the request content body. 114 * @param data 115 * @return an {@link HttpEntity} encapsulating this request data 116 * @throws UnsupportedEncodingException 117 */ 118 public InputStreamEntity encodeStream( Object data, Object contentType ) 119 throws UnsupportedEncodingException { 120 InputStreamEntity entity = null; 121 122 if ( data instanceof ByteArrayInputStream ) { 123 // special case for ByteArrayIS so that we can set the content length. 124 ByteArrayInputStream in = ((ByteArrayInputStream)data); 125 entity = new InputStreamEntity( in, in.available() ); 126 } 127 else if ( data instanceof InputStream ) { 128 entity = new InputStreamEntity( (InputStream)data, -1 ); 129 } 130 else if ( data instanceof byte[] ) { 131 byte[] out = ((byte[])data); 132 entity = new InputStreamEntity( new ByteArrayInputStream( 133 out), out.length ); 134 } 135 else if ( data instanceof ByteArrayOutputStream ) { 136 ByteArrayOutputStream out = ((ByteArrayOutputStream)data); 137 entity = new InputStreamEntity( new ByteArrayInputStream( 138 out.toByteArray()), out.size() ); 139 } 140 else if ( data instanceof Closure ) { 141 ByteArrayOutputStream out = new ByteArrayOutputStream(); 142 ((Closure)data).call( out ); // data is written to out 143 entity = new InputStreamEntity( new ByteArrayInputStream( 144 out.toByteArray()), out.size() ); 145 } 146 147 if ( entity == null ) throw new IllegalArgumentException( 148 "Don't know how to encode " + data + " as a byte stream" ); 149 150 if ( contentType == null ) contentType = ContentType.BINARY; 151 entity.setContentType( contentType.toString() ); 152 return entity; 153 } 154 155 /** 156 * Default handler used for a plain text content-type. Acceptable argument 157 * types are: 158 * <ul> 159 * <li>Closure</li> 160 * <li>Writable</li> 161 * <li>Reader</li> 162 * </ul> 163 * For Closure argument, a {@link PrintWriter} is passed as the single 164 * argument to the closure. Any data sent to the writer from the 165 * closure will be sent to the request content body. 166 * @param data 167 * @return an {@link HttpEntity} encapsulating this request data 168 * @throws IOException 169 */ 170 public HttpEntity encodeText( Object data, Object contentType ) throws IOException { 171 if ( data instanceof Closure ) { 172 StringWriter out = new StringWriter(); 173 PrintWriter writer = new PrintWriter( out ); 174 ((Closure)data).call( writer ); 175 writer.close(); 176 out.flush(); 177 data = out; 178 } 179 else if ( data instanceof Writable ) { 180 StringWriter out = new StringWriter(); 181 ((Writable)data).writeTo(out); 182 out.flush(); 183 data = out; 184 } 185 else if ( data instanceof Reader && ! (data instanceof BufferedReader) ) 186 data = new BufferedReader( (Reader)data ); 187 if ( data instanceof BufferedReader ) { 188 StringWriter out = new StringWriter(); 189 DefaultGroovyMethods.leftShift( out, (BufferedReader)data ); 190 191 data = out; 192 } 193 // if data is a String, we are already covered. 194 if ( contentType == null ) contentType = ContentType.TEXT; 195 return createEntity( contentType, data.toString() ); 196 } 197 198 /** 199 * Set the request body as a url-encoded list of parameters. This is 200 * typically used to simulate a HTTP form POST. 201 * For multi-valued parameters, enclose the values in a list, e.g. 202 * <pre>[ key1 : ['val1', 'val2'], key2 : 'etc.' ]</pre> 203 * @param params 204 * @return an {@link HttpEntity} encapsulating this request data 205 * @throws UnsupportedEncodingException 206 */ 207 public UrlEncodedFormEntity encodeForm( Map<?,?> params ) 208 throws UnsupportedEncodingException { 209 return encodeForm( params, null ); 210 } 211 212 public UrlEncodedFormEntity encodeForm( Map<?,?> params, Object contentType ) 213 throws UnsupportedEncodingException { 214 List<NameValuePair> paramList = new ArrayList<NameValuePair>(); 215 216 for ( Object key : params.keySet() ) { 217 Object val = params.get( key ); 218 if ( val instanceof List<?> ) 219 for ( Object subVal : (List<?>)val ) 220 paramList.add( new BasicNameValuePair( key.toString(), 221 ( subVal == null ) ? "" : subVal.toString() ) ); 222 223 else paramList.add( new BasicNameValuePair( key.toString(), 224 ( val == null ) ? "" : val.toString() ) ); 225 } 226 227 UrlEncodedFormEntity e = new UrlEncodedFormEntity( paramList, charset.name() ); 228 if ( contentType != null ) e.setContentType( contentType.toString() ); 229 return e; 230 231 } 232 233 /** 234 * Accepts a String as a url-encoded form post. This method assumes the 235 * String is an already-encoded POST string. 236 * @param formData a url-encoded form POST string. See 237 * <a href='http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1'> 238 * The W3C spec</a> for more info. 239 * @return an {@link HttpEntity} encapsulating this request data 240 * @throws UnsupportedEncodingException 241 */ 242 public HttpEntity encodeForm( String formData, Object contentType ) throws UnsupportedEncodingException { 243 if ( contentType == null ) contentType = ContentType.URLENC; 244 return this.createEntity( contentType, formData ); 245 } 246 247 /** 248 * Encode the content as XML. The argument may be either an object whose 249 * <code>toString</code> produces valid markup, or a Closure which will be 250 * interpreted as a builder definition. A closure argument is 251 * passed to {@link StreamingMarkupBuilder#bind(groovy.lang.Closure)}. 252 * @param xml data that defines the XML structure 253 * @return an {@link HttpEntity} encapsulating this request data 254 * @throws UnsupportedEncodingException 255 */ 256 public HttpEntity encodeXML( Object xml, Object contentType ) 257 throws UnsupportedEncodingException { 258 if ( xml instanceof Closure ) { 259 StreamingMarkupBuilder smb = new StreamingMarkupBuilder(); 260 xml = smb.bind( xml ); 261 } 262 if ( contentType == null ) contentType = ContentType.XML; 263 return createEntity( contentType, xml.toString() ); 264 } 265 266 /** 267 * <p>Accepts a Collection or a JavaBean object which is converted to JSON. 268 * A Map or POJO/POGO will be converted to a {@link JSONObject}, and any 269 * other collection type will be converted to a {@link JSONArray}. A 270 * String or GString will be interpreted as valid JSON and passed directly 271 * as the request body (with charset conversion if necessary.)</p> 272 * 273 * <p>If a Closure is passed as the model, it will be executed as if it were 274 * a JSON object definition passed to a {@link JsonGroovyBuilder}. In order 275 * for the closure to be interpreted correctly, there must be a 'root' 276 * element immediately inside the closure. For example:</p> 277 * 278 * <pre>builder.post( JSON ) { 279 * body = { 280 * root { 281 * first { 282 * one = 1 283 * two = '2' 284 * } 285 * second = 'some string' 286 * } 287 * } 288 * }</pre> 289 * <p> will return the following JSON string:<pre> 290 * {"root":{"first":{"one":1,"two":"2"},"second":"some string"}}</pre></p> 291 * 292 * @param model data to be converted to JSON, as specified above. 293 * @return an {@link HttpEntity} encapsulating this request data 294 * @throws UnsupportedEncodingException 295 */ 296 @SuppressWarnings("unchecked") 297 public HttpEntity encodeJSON( Object model, Object contentType ) throws UnsupportedEncodingException { 298 299 Object json; 300 if ( model instanceof Map ) { 301 json = new JSONObject(); 302 ((JSONObject)json).putAll( (Map)model ); 303 } 304 else if ( model instanceof Collection ) { 305 json = new JSONArray(); 306 ((JSONArray)json).addAll( (Collection)model ); 307 } 308 else if ( model instanceof Closure ) { 309 Closure closure = (Closure)model; 310 closure.setDelegate( new JsonGroovyBuilder() ); 311 json = (JSON)closure.call(); 312 } 313 else if ( model instanceof String || model instanceof GString ) 314 json = model; // assume string is valid JSON already. 315 else json = JSONObject.fromObject( model ); // Assume object is a JavaBean 316 317 if ( contentType == null ) contentType = ContentType.JSON; 318 return this.createEntity( contentType, json.toString() ); 319 } 320 321 /** 322 * Helper method used by encoder methods to create an {@link HttpEntity} 323 * instance that encapsulates the request data. This may be used by any 324 * non-streaming encoder that needs to send textual data. It also sets the 325 * {@link #setCharset(String) charset} portion of the content-type header. 326 * 327 * @param ct content-type of the data 328 * @param data textual request data to be encoded 329 * @return an instance to be used for the 330 * {@link HttpEntityEnclosingRequest#setEntity(HttpEntity) request content} 331 * @throws UnsupportedEncodingException 332 */ 333 protected StringEntity createEntity( Object ct, String data ) 334 throws UnsupportedEncodingException { 335 StringEntity entity = new StringEntity( data, charset.toString() ); 336 entity.setContentType( ct.toString() ); 337 return entity; 338 } 339 340 /** 341 * Returns a map of default encoders. Override this method to change 342 * what encoders are registered by default. You can of course call 343 * <code>super.buildDefaultEncoderMap()</code> and then add or remove 344 * from that result as well. 345 */ 346 protected Map<String,Closure> buildDefaultEncoderMap() { 347 Map<String,Closure> encoders = new HashMap<String,Closure>(); 348 349 encoders.put( ContentType.BINARY.toString(), new MethodClosure(this,"encodeStream") ); 350 encoders.put( ContentType.TEXT.toString(), new MethodClosure( this, "encodeText" ) ); 351 encoders.put( ContentType.URLENC.toString(), new MethodClosure( this, "encodeForm" ) ); 352 353 Closure encClosure = new MethodClosure(this,"encodeXML"); 354 for ( String ct : ContentType.XML.getContentTypeStrings() ) 355 encoders.put( ct, encClosure ); 356 encoders.put( ContentType.HTML.toString(), encClosure ); 357 358 encClosure = new MethodClosure(this,"encodeJSON"); 359 for ( String ct : ContentType.JSON.getContentTypeStrings() ) 360 encoders.put( ct, encClosure ); 361 362 return encoders; 363 } 364 365 /** 366 * Retrieve a encoder for the given content-type. This 367 * is called by HTTPBuilder to retrieve the correct encoder for a given 368 * content-type. The encoder is then used to serialize the request data 369 * in the request body. 370 * @param contentType 371 * @return encoder that can interpret the given content type, 372 * or null. 373 */ 374 public Closure getAt( Object contentType ) { 375 String ct = contentType.toString(); 376 int idx = ct.indexOf( ';' ); 377 if ( idx > 0 ) ct = ct.substring( 0, idx ); 378 379 return registeredEncoders.get(ct); 380 } 381 382 /** 383 * Register a new encoder for the given content type. If any encoder 384 * previously existed for that content type it will be replaced. The 385 * closure must return an {@link HttpEntity}. It will also usually 386 * accept a single argument, which will be whatever is set in the request 387 * configuration closure via {@link RequestConfigDelegate#setBody(Object)}. 388 * @param contentType 389 * @param closure 390 */ 391 public void putAt( Object contentType, Closure value ) { 392 if ( contentType instanceof ContentType ) { 393 for ( String ct : ((ContentType)contentType).getContentTypeStrings() ) 394 this.registeredEncoders.put( ct, value ); 395 } 396 else this.registeredEncoders.put( contentType.toString(), value ); 397 } 398 399 /** 400 * Alias for {@link #getAt(Object)} to allow property-style access. 401 * @param key 402 * @return 403 */ 404 public Closure propertyMissing( Object key ) { 405 return this.getAt( key ); 406 } 407 408 /** 409 * Alias for {@link #putAt(Object, Closure)} to allow property-style access. 410 * @param key 411 * @param value 412 */ 413 public void propertyMissing( Object key, Closure value ) { 414 this.putAt( key, value ); 415 } 416 417 /** 418 * Iterate over the entire parser map 419 * @return 420 */ 421 public Iterator<Map.Entry<String,Closure>> iterator() { 422 return this.registeredEncoders.entrySet().iterator(); 423 } 424}