The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
# Copyright (C) 2004  Joshua Hoblitt
#
# $Id: Range.pm,v 1.4 2004/07/22 07:42:35 jhoblitt Exp $

package HTTP::Range;

use strict;

use vars qw( $VERSION );
$VERSION = 0.02;

require IO::String;
require HTTP::Request;
require HTTP::Response;
require Set::Infinite;
use HTTP::Status qw( RC_OK );
use Params::Validate qw( :all );
use UNIVERSAL qw( isa can );
use Carp qw( croak );

my $DEBUG = 0;

sub split
{
    my $class = shift;
 
    my %args = validate( @_,
        {
            request => {
                type        => OBJECT,
                isa         => 'HTTP::Request',
            },
            length => {
                type        => SCALAR,
                callbacks   => {
                    'length is > 0'         => sub { $_[0] > 0 },
                    'length is + integer'   => sub { $_[0] =~ /^\d+$/ },
                },
            },
            segments => {
                type        => SCALAR,
                default     => 4,
                callbacks   => {
                    'segments is > 1'       => sub { $_[0] > 1 },
                    'segments is + integer' => sub { $_[0] =~ /^\d+$/ },
                    'segments is <= length' => sub { $_[0] <= $_[1]->{ 'length' } },
                },
            },
        },
    );

    # size of byte range per requested segment
    $args{ 'seg_size' } = int ( $args{ 'length' } / $args{ 'segments' } );

    # if the length is not evenly divisible by the number of segments we have to 
    # account for the leftover bytes
    $args{ 'seg_extras' } = $args{ 'length' } % $args{ 'segments' };

    # total number of bytes to process
    $args{ 'len_remain' } = $args{ 'length' };

    my @requests;
    while ( $args{ 'len_remain' } || $args{ 'seg_extras' } ) {
        # size of this segment
        my $seg_len = $args{ 'seg_size' };

        # do we have extra bytes?
        if ( $args{ 'seg_extras' } ) {
            $seg_len++;
            $args{ 'seg_extras' }--;
        }

        # offset into length
        $args{ 'len_index' } = $args{ 'length' } - $args{ 'len_remain' };
        
        # bytes remaining
        $args{ 'len_remain' } -= $seg_len;

        # copy the request object - this must be a deep clone
        my $req = $args{ 'request' }->clone;

        # start-end of byte offset for this segment
        $req->header( Range => "bytes=$args{ 'len_index' }-"
                . ( $args{ 'len_index' } + $seg_len - 1 ) );

        push( @requests, $req );
    }

    return( wantarray ? @requests : \@requests );
}

sub join
{
    my $class = shift;
 
    my %args = validate( @_,
        {
            responses => {
                type        => ARRAYREF,
            },
            length => {
                type        => SCALAR,
                optional    => 1,
                callbacks   => {
                    'length is > 0'         => sub { shift > 0 },
                    'length is + integer'   => sub { $_[0] =~ /^\d+$/ },
                },
            },
            segments => {
                type        => SCALAR,
                optional    => 1,
                callbacks   => {
                    'segments is > 1'           => sub { $_[0] > 1 },
                    'segments is + integer'     => sub { $_[0] =~ /^\d+$/ },
                    'segments is == responses'  => sub {
                        $_[0] == @{ $_[1]->{ 'responses' } };
                    },
                    'segments is <= length'     => sub {
                        if ( $_[1]->{ 'length' } ) {
                            return $_[0] <= $_[1]->{ 'length' };
                        } else {
                            return 1;
                        }
                    },
                },
            },
        },
    );

    # validate each object in the responses arrayref
    foreach my $res ( @{ $args{ 'responses' } } ) {
        croak "not isa HTTP::Response" unless isa( $res, 'HTTP::Response' );
        croak "not a successful HTTP status" unless HTTP::Status::is_success( $res->code );
        croak "multi-part messages are not supported" if @{[ $res->parts ]};
        croak "segment has invalid content length" unless length $res->content == $res->content_length;
    }

    # scalar w/ IO::Handle interface to hold the reassembled segments
    my $content = IO::String->new;

    # set of content ranges processed
    my @ranges;

    # put segments in order
    my @responses = sort _byrange @{ $args{ 'responses' } };

    foreach my $res ( @responses ) {
        # figure out the offset and size of the segment and write it to the file handle
        my ( $start, $end ) = _parse_range( $res );
        my $len = $end - $start + 1;

        # add a span per content range
        push( @ranges, Set::Infinite->new( [ $start, $end ] ) );

        # seek to the appropriate location and write the current segment
        # functions (instead of methods) are used for compatibility with IO::Handle
        unless ( defined sysseek( $content, $start, 0 ) ) {
            croak "sysseeking response content";
        }
        if ( syswrite( $content, $res->content, $res->header( 'Content-Length' ), 0 ) != $len ){
            croak "syswriting response content";
        }

        # free the contents memory
        $res->content( undef );
    }

    # if a content length was specified check it against what was received
    if ( defined $args{ 'length' } ) {
        if ( $args{ 'length'} != length ${ $content->string_ref } ) {
            croak "specified content length does not equal received content length";
        }

        # create a set of spans representing our segments
        my $set = Set::Infinite->new;
        $set = $set->union( $_ ) for @ranges;
        $set = $set->integer;
        # work around a bug in Set::Infinite
        $set->_cleanup;
        warn "ranges are @ranges\n" if $DEBUG;
        warn "range set is: $set\n" if $DEBUG;

        # create a span representing our content length
        my $len_set = Set::Infinite->new( [ 0, $args{ 'length' } -1  ] );

        # look for differences between our segments and content length
        $len_set = $len_set->minus( $set );
        warn "left over set is: $len_set\n" if $DEBUG;
        croak "missing or incomplete segments" if $len_set;
    
    }

    # sort the segment spans
    # these should already be in order as they were created in order of the
    # sorted responses
    @ranges = sort { $a <=> $b } @ranges;

    # look for spans (segments) that overlap each other
    my $last_span;
    foreach my $span ( @ranges ) {
        if ( ! defined( $last_span ) ) {
            $last_span = $span;
            next;
        }

        croak "segments overlap" if $last_span->intersection( $span );
    }

    # create the return HTTP::Response object as a clone of the first object passed in
    my $r =  @{ $args{ 'responses' } }[0]->clone;

    # attempt to look like a single request by removing the Content-Range and
    # resetting the HTTP status code + message
    $r->remove_header( 'Content-Range' );
    $r->code( RC_OK );
    $r->message( HTTP::Status::status_message( $r->code ) );

    # set the content and it's length
    $r->content_ref( $content->string_ref );
    $r->header( content_length => length ${ $r->content_ref } );
    
    return( $r );
}

sub _parse_range
{
    my $res = shift;

    return $res->header( 'Content-Range' ) =~ /bytes (\d+)-(\d+)/;
}

sub _byrange
{
    (_parse_range( $a ))[0] <=> (_parse_range( $b ))[0];
}

1;

__END__