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}