- Publicity: Public Only All
http-client-procs.tcl
XOTcl implementation for synchronous and asynchronous HTTP and HTTPS requests
- Location:
- packages/xotcl-core/tcl/http-client-procs.tcl
- Created:
- 2007-10-05
- Author:
- Gustaf Neumann, Stefan Sobernig
- CVS Identification:
$Id: http-client-procs.tcl,v 1.42 2024/09/11 06:15:56 gustafn Exp $
Procedures in this file
Detailed information
[ hide source ] | [ make this the default ]Content File Source
xo::library doc { XOTcl implementation for synchronous and asynchronous HTTP and HTTPS requests @author Gustaf Neumann, Stefan Sobernig @creation-date 2007-10-05 @cvs-id $Id: http-client-procs.tcl,v 1.42 2024/09/11 06:15:56 gustafn Exp $ } # # The XOTcl HTTP client procs are deprecated. # Use util::http::get/post/... etc. instead # if {![ad_with_deprecated_code_p]} { ns_log notice "http-client-procs: skip deprecated code" return } ns_log notice "http-client-procs include deprecated code" namespace eval ::xo { # # Defined classes # 1) HttpCore (common base class) # 2) HttpRequest (for blocking requests + timeout support) # 3) AsyncHttpRequest (for nonblocking requests + timeout support) # 4) HttpRequestTrace (mixin class) # 5) Tls (mixin class, applicable to various protocols) # ###################### # # 1 HttpRequest # # HttpRequest is a class to implement the client side # for the HTTP methods GET and POST. # # Example of a GET request: # # set r [::xo::HttpRequest new -url http://www.openacs.org/] # # The resulting object $r contains all information # about the requests, such as e.g. status_code or # data (the response body from the server). For details # look into the output of [$r serialize]. The result # object $r is automatically deleted at cleanup of # a connection thread. # # Example of a POST request with a form with var1 and var2 # (providing post_data causes the POST request). # # set r [::xo::HttpRequest new \ # -url http://yourhost.yourdomain/yourpath \ # -post_data [export_vars {var1 var2}] \ # -content_type "application/x-www-form-urlencoded; charset=UTF-8"] # # More recently, we added timeout support for blocking http # requests. By passing a timeout parameter, you gain control # on the total roundtrip time (in milliseconds, ms): # # set r [::xo::HttpRequest new \ # -url http://www.openacs.org/ \ # -timeout 1500] # # Please, make sure that you use a recent distribution of tclthread # ( > 2.6.5 ) to have the blocking-timeout feature working # safely. This newly introduced feature makes use of advanced thread # synchronization offered by tclthread that needed to be fixed in # tclthread <= 2.6.5. At the time of this writing, there was no # post-2.6.5 release of tclthread, hence, you are required to obtain a # CVS snapshot, dating at least 2008-05-23. E.g.: # # cvs -z3 -d:pserver:anonymous@tcl.cvs.sourceforge.net:/cvsroot/tcl co \ # -D 20080523 -d thread2.6.5~20080523 thread # # Provided that the Tcl module tls (see e.g. http://tls.sourceforge.net/) # is available and can be loaded via "package require tls" into # the AOLserver, you can use both TLS/SSL secured or insecured requests # in the synchronous/ asynchronous mode by using an # https url. # # set r [::xo::HttpRequest new -url https://learn.wu-wien.ac.at/] # ###################### # # 2 AsyncHttpRequest # # AsyncHttpRequest is a subclass for HttpCore implementing # asynchronous HTTP requests without vwait (vwait causes # stalls on AOLserver). AsyncHttpRequest requires to provide a listener # or callback object that will be notified upon success or failure of # the request. # # Asynchronous requests are much more complex to handle, since # an application (a connection thread) can submit multiple # asynchronous requests in parallel, which are likely to # finish after the current request is done. The advantages # are that the spooling of data can be delegated to a spooling # thead and the connection thread is available for handling more # incoming connections. The disadvantage is the higher # complexity, one needs means to collect the received data. # # # The following example uses the background delivery thread for # spooling and defines in this thread a listener. This generic # listener can be subclasses in applications. # # When using asynchronous requests, make sure to specify a listener # for the callbacks and delete finally the request object in the # bgdelivery thread. # # ::bgdelivery do ::xo::AsyncHttpRequest new \ # -url "https://oacs-dotlrn-conf2007.wu-wien.ac.at/conf2007/" \ # -mixin ::xo::AsyncHttpRequest::SimpleListener # -proc finalize {obj status value} { :destroy } # ###################### # # 3 HttpRequestTrace # # HttpRequestTrace can be used to trace one or all requests. # If activated, the class writes protocol data into # [ad_tmpdir]/req-<somenumber>. # # Use # # ::xo::HttpCore instmixin add ::xo::HttpRequestTrace # # to activate trace for all requests, # or mixin the class into a single request to trace it. # Class create HttpCore \ -slots { Attribute create host Attribute create protocol -default "http" Attribute create port Attribute create path -default "/" Attribute create url Attribute create method Attribute create post_data -default "" Attribute create content_type \ -default "text/plain; charset=[ns_config ns/parameters OutputCharset iso-8859-1]" Attribute create request_header_fields -default {} Attribute create user_agent -default "xohttp/0.2" } HttpCore instproc set_default_port {protocol} { switch -- $protocol { http {set :port 80} https {set :port 443} } } HttpCore instproc parse_url {} { :instvar protocol url host port path if {[regexp {^(http|https)://([^/]+)(/.*)?$} $url _ protocol host path]} { # Be friendly and allow strictly speaking invalid URLs # like "http://www.openacs.org" (no trailing slash) if {$path eq ""} {set path /} :set_default_port $protocol regexp {^([^:]+):(.*)$} $host _ host port } else { error "unsupported or invalid url '$url'" } } HttpCore instproc open_connection {} { :instvar host port S set S [socket -async $host $port] } HttpCore instproc get_channel_settings { {-text_translation {auto binary}} content_type } { # # 1. NOTE: We have to treat translation and encoding settings # separately. "Defaulting" to "binary" translation would imply a # "binary" encoding: [fconfigure -translation binary] "[...] sets # the encoding to binary (which disables encoding filtering)", # i.e. it is idempotent to [fconfigure -translation binary # -encoding binary]. # # see also http://docs.activestate.com/activetcl/8.5/tcl/TclCmd/fconfigure.htm # # 2. Note: I would claim here that we could stick with binary # translations, effectively deactivating any eol/eof # interpretations. As we use the byte-oriented [read] rathen than # the line-oriented [gets] in the processing of HTTP bodies of replies # ([gets] is only applied for header processing), this should be # fine. Anyways, I leave it as is for the moment ... # set content_type [string tolower $content_type] set trl [expr {[string match "text/*" $content_type] ? $text_translation : "binary"}] # # 3. In the following, an IANA/MIME charset resolution scheme is # implemented which is compliant with RFC 3023 which deals with # treating XML media types properly. # # see http://tools.ietf.org/html/rfc3023 # # This makes the use of [ns_encodingfortype] obsolete as this # helper proc does not consider RFC 3023 at all. In the future, # RFC 3023 support should enter a revised [ns_encodingfortype], # for now, we fork. # # The mappings between Tcl encoding names (as shown by [encoding # names]) and IANA/MIME charset names (i.e., names and aliases in # the sense of http://www.iana.org/assignments/character-sets) is # provided by ... # # i. A static, built-in correspondence map: see nsd/encoding.c # ii. An extensible correspondence map (i.e., the ns/charsets # section in config.tcl). # # For mapping charset to encoding names, I use # [ns_encodingforcharset]. # # Note, there are also alternatives for resolving IANA/MIME # charset names to Tcl encoding names, however, they all have # issues (non-extensibility from standard configuration sites, # incompleteness, redundant thread-local storing, scripted # implementation): # 1. Tcllib/mime package: ::mime::reversemapencoding() # 2. Tdom: tDOM::IANAEncoding2TclEncoding(); see lib/tdom.tcl # # RFC 3023 support (at least in my reading) demands the following # resolution order (see also Section 3.6 in RFC 3023), when # applied along with RFC 2616 (see especially Section 3.7.1 in RFC 2616) # # (A) Check for the "charset" parameter on certain (!) media types: # an explicitly stated, yet optional "charset" parameter is # permitted for all text/* media subtypes (RFC 2616) and selected # the XML media type classes listed by RFC 3023 (beyond the text/* # media type; e.g. "application/xml*", "*/*+xml", etc.). # # (B) If the "charset" is omitted, certain default values apply (!): # # (B.1) RFC 3023 text/* registrations default to us-ascii (!), # and not iso-8859-1 (overruling RFC 2616). # # (B.2) RFC 3023 application/* and non-text "+xml" registrations # are to be left untreated (in our context, no encoding # filtering is to be applied -> "binary") # # (B.3) RFC 2616 text/* registration (if not covered by B.1) # default to iso-8859-1 # # (C) If neither A or B apply (e.g., because an invalid charset # name was given to the charset parameter), we default to # "binary". This corresponds to the behavior of # [ns_encodingfortype]. Also note that the RFCs 3023 and 2616 do # not state any procedure when "invalid" charsets etc. are # identified. I assume, RFC-compliant clients have to ignore them # which means keep the channel in- and output unfiltered (encoding # = "binary"). This requires the client of the *HttpRequest* to # treat the data accordingly. # set enc "" if {[regexp {^text/.*$|^.*/xml.*$|^.*\+xml.*$} $content_type]} { # Case (A): Check for an explicitly provided charset parameter if {[regexp {;\s*charset\s*=([^;]*)} $content_type _ charset]} { set enc [ns_encodingforcharset [string trim $charset]] } # Case (B.1) if {$enc eq "" && [regexp {^text/xml.*$|text/.*\+xml.*$} $content_type]} { set enc [ns_encodingforcharset us-ascii] } # Case (B.3) if {$enc eq "" && [string match "text/*" $content_type]} { set enc [ns_encodingforcharset iso-8859-1] } } # Cases (C) and (B.2) are covered by the [expr] below. return [list encoding [expr {$enc eq ""?"binary":$enc}] translation $trl] } HttpCore instproc init {} { :instvar S post_data host port protocol :destroy_on_cleanup set :meta [list] set :data "" if {![info exists :method]} { set :method [expr {$post_data eq "" ? "GET" : "POST"}] } if {[info exists :url]} { :parse_url } else { if {![info exists port]} {:set_default_port $protocol} if {![info exists host]} { error "either host or url must be specified" } } if {$protocol eq "https"} { package require tls if {[info commands ::tls::import] eq ""} { error "https request require the Tcl module TLS to be installed\n\ See e.g. http://tls.sourceforge.net/" } # # Add HTTPs handling # :mixin add ::xo::Tls } if {[catch {:open_connection} err]} { :cancel "error during open connection via $protocol to $host $port: $err" } } HttpCore instproc send_request {} { :instvar S post_data host method if {[catch { puts $S "$method [:path] HTTP/1.0" puts $S "Host: $host" puts $S "User-Agent: [:user_agent]" foreach {tag value} [:request_header_fields] { #regsub -all \[\n\r\] $value {} value #set tag [string trim $tag] puts $S "$tag: $value" } :$method } err]} { :cancel "error send $host [:port]: $err" return } } HttpCore instproc GET {} { :instvar S puts $S "" :request_done } HttpCore instproc POST {} { :instvar S post_data array set "" [:get_channel_settings [:content_type]] if {$(encoding) ne "binary"} { set post_data [encoding convertto $(encoding) $post_data] } puts $S "Content-Length: [string length $post_data]" puts $S "Content-Type: [:content_type]" puts $S "" fconfigure $S -translation $(translation) -encoding binary :send_POST_data } HttpCore instproc send_POST_data {} { :instvar S post_data puts -nonewline $S $post_data :request_done } HttpCore instproc request_done {} { :instvar S flush $S :reply_first_line } HttpCore instproc close {} { catch {close ${:S}} errMsg :debug "--- closing socket socket?[info exists :S] => $errMsg" } HttpCore instproc cancel {reason} { set :status canceled set :cancel_message $reason :debug "--- canceled for $reason" :close } HttpCore instproc finish {} { set :status finished :close :debug "--- [:host] [:port] [:path] has finished" } HttpCore instproc getLine {var} { :upvar $var response :instvar S set n [gets $S response] if {[eof $S]} { :debug "--premature eof" return -2 } if {$n == -1} {:debug "--input pending, no full line"; return -1} return $n } HttpCore instproc reply_first_line {} { :instvar S status_code fconfigure $S -translation crlf set n [:getLine response] switch -exact -- $n { -2 {:cancel premature-eof; return} -1 {:finish; return} } if {[regexp {^HTTP/([0-9.]+) +([0-9]+) *} $response _ \ responseHttpVersion status_code]} { :reply_first_line_done } else { :cancel "unexpected-response '$response'" } } HttpCore instproc reply_first_line_done {} { :header } HttpCore instproc header {} { while {1} { set n [:getLine response] switch -exact -- $n { -2 {:cancel premature-eof; return} -1 {continue} 0 {break} default { #:debug "--header $response" if {[regexp -nocase {^content-length:(.+)$} $response _ length]} { set :content_length [string trim $length] } elseif {[regexp -nocase {^content-type:(.+)$} $response _ type]} { set :content_type [string trim $type] } if {[regexp -nocase {^([^:]+): *(.+)$} $response _ key value]} { lappend :meta [string tolower $key] $value } } } } :reply_header_done } HttpCore instproc reply_header_done {} { :instvar S # we have received the header, including potentially the # content_type of the returned data array set "" [:get_channel_settings [:content_type]] fconfigure $S -translation $(translation) -encoding $(encoding) if {[info exists :content_length]} { set :data [read ${:S} ${:content_length}] } else { set :data [read ${:S}] } :finish } HttpCore instproc set_status {key newStatus {value ""}} { nsv_set bgdelivery $key [list $newStatus $value] } HttpCore instproc unset_status {key} { nsv_unset bgdelivery $key } HttpCore instproc exists_status {key} { return [nsv_exists bgdelivery $key] } HttpCore instproc get_status {key} { return [lindex [nsv_get bgdelivery $key] 0] } HttpCore instproc get_value_for_status {key} { return [lindex [nsv_get bgdelivery $key] 1] } # # Synchronous (blocking) requests # Class create HttpRequest -superclass HttpCore -slots { Attribute create timeout -type integer } HttpRequest instproc init {} { # :log "[info exists :timeout]" if {[info exists :timeout] && [:timeout] > 0} { # create a cond and mutex set cond [thread::cond create] set mutex [thread::mutex create] thread::mutex lock $mutex # start the asynchronous request :debug "--a create new ::xo::AsyncHttpRequest" set req [bgdelivery do -async ::xo::AsyncHttpRequest new \ -mixin ::xo::AsyncHttpRequest::RequestManager \ -url [:url] \ -timeout [:timeout] \ -post_data [:post_data] \ -request_header_fields [:request_header_fields] \ -content_type [:content_type] \ -user_agent [:user_agent] \ -condition $cond] while {1} { :set_status $cond COND_WAIT_TIMEOUT thread::cond wait $cond $mutex [:timeout] set status [:get_status $cond] :debug "status after cond-wait $status" if {$status ne "COND_WAIT_REFRESH"} break } if {$status eq "COND_WAIT_TIMEOUT"} { :set_status $cond "COND_WAIT_CANCELED" } set status_value [:get_value_for_status $cond] if {$status eq "JOB_COMPLETED"} { set :data $status_value } else { set msg "Timeout-constraint, blocking HTTP request failed. Reason: '$status'" if {$status_value ne ""} { append msg " ($status_value)" } error $msg } thread::cond destroy $cond thread::mutex unlock $mutex thread::mutex destroy $mutex :unset_status $cond } else { next ;# HttpCore->init # # test whether open_connection yielded # a socket ... # # :log "after core init, S?[info exists :S]" if {[info exists :S]} { :send_request } } } # # Asynchronous (nonblocking) requests # Class create AsyncHttpRequest -superclass HttpCore -slots { Attribute create timeout -type integer -default 10000 ;# 10 seconds Attribute create request_manager } AsyncHttpRequest instproc set_timeout {} { :cancel_timeout :debug "--- setting socket timeout: ${:timeout}" set :timeout_handle [after ${:timeout} [self] cancel timeout] } AsyncHttpRequest instproc cancel_timeout {} { if {[info exists :timeout_handle]} { after cancel ${:timeout_handle} } } AsyncHttpRequest instproc send_request {} { # remove fileevent handler explicitly fileevent ${:S} writable {} next } AsyncHttpRequest instproc init {} { :notify start_request :set_timeout next # # test whether open_connection yielded # a socket ... # if {[info exists :S]} { fileevent ${:S} writable [list [self] send_request] } } AsyncHttpRequest instproc notify {method {arg ""}} { if {[info exists :request_manager]} { [:request_manager] $method $arg [self] } } AsyncHttpRequest instproc POST {} { if {[info exists :S]} {fconfigure ${:S} -blocking false} fileevent ${:S} writable [list [self] send_POST_data] set :bytes_sent 0 next } AsyncHttpRequest instproc send_POST_data {} { :instvar S post_data bytes_sent :set_timeout set total_bytes [string length $post_data] if {$bytes_sent < $total_bytes} { set to_send [expr {$total_bytes - $bytes_sent}] set block_size [expr {$to_send < 4096 ? $to_send : 4096}] set next_block_size [expr {$bytes_sent + $block_size}] set block [string range $post_data $bytes_sent $next_block_size-1] :notify request_data $block puts -nonewline $S $block set bytes_sent $next_block_size } else { fileevent $S writable "" :request_done } } AsyncHttpRequest instproc cancel {reason} { if {$reason ne "timeout"} { :cancel_timeout } next :notify failure $reason } AsyncHttpRequest instproc finish {} { :cancel_timeout next :debug "--- finished data ${:data}" :notify success ${:data} } AsyncHttpRequest instproc request_done {} { :notify start_reply :set_timeout :instvar S flush $S fconfigure $S -blocking false fileevent $S readable [list [self] reply_first_line] } AsyncHttpRequest instproc reply_first_line_done {} { :set_timeout :instvar S fileevent $S readable [list [self] header] } AsyncHttpRequest instproc reply_header_done {} { :instvar S :set_timeout # we have received the header, including potentially the # content_type of the returned data array set "" [:get_channel_settings [:content_type]] fconfigure $S -translation $(translation) -encoding $(encoding) fileevent ${:S} readable [list [self] receive_reply_data] } AsyncHttpRequest instproc receive_reply_data {} { :instvar S :debug "JOB receive_reply_data eof=[eof $S]" if {[eof $S]} { :finish } else { :set_timeout set block [read $S] :notify reply_data $block append :data $block #:debug "received [string length $block] bytes" } } # # SimpleListener defines a mixin class for providing a stub # implementation for callbacks of the asynchrous HTTP requests. # This class is typically run in the scope of bgdelivery # Class create AsyncHttpRequest::SimpleListener \ -instproc init {} { :debug "INIT- NEXT=[self next]" # register request object as its own request_manager :request_manager [self] next } -instproc start_request {payload obj} { :debug "request $obj started" } -instproc request_data {payload obj} { :debug "partial or complete post" } -instproc start_reply {payload obj} { :debug "reply $obj started" } -instproc reply_data {payload obj} { :debug "partial or complete delivery" } -instproc finalize {obj status value} { :debug "finalize $obj $status" # this is called as a single method after success or failure next } -instproc success {payload obj} { :debug "[string length $payload] bytes payload" #if {[string length $payload]<600} {:log payload=$payload} # this is called as after a successful request :finalize $obj "JOB_COMPLETED" $payload } -instproc failure {reason obj} { :log "[self proc] [self args]" :log "failed for '$reason'" # this is called as after an unsuccessful request :finalize $obj "JOB_FAILED" $reason } -instproc unknown {method args} { :log "[self proc] [self args]" :log "UNKNOWN $method" } # Mixin class, used to turn instances of # AsyncHttpRequest into result callbacks # in the scope of bgdelivery, realising # the blocking-timeout feature ... # Class create AsyncHttpRequest::RequestManager \ -superclass AsyncHttpRequest::SimpleListener \ -slots { Attribute create condition } -instproc finalize {obj status value} { # set the result and do the notify :instvar condition # If a job was canceled, the status variable might not exist # anymore, the condition might be already gone as well. In # this case, we do not have to perform the cond-notify. if {[:exists_status $condition] && [:get_status $condition] eq "COND_WAIT_REFRESH"} { } if {[:exists_status $condition] && ( [:get_status $condition] eq "COND_WAIT_REFRESH" || [:get_status $condition] eq "COND_WAIT_TIMEOUT") } { # Before, we had here one COND_WAIT_TIMEOUT, and once # COND_WAIT_REFRESH :set_status $condition $status $value catch {thread::cond notify $condition} $obj debug "--- destroying after finish" $obj destroy } } -instproc set_cond_timeout {} { :instvar condition if {[:exists_status $condition] && [:get_status $condition] eq "COND_WAIT_TIMEOUT"} { :set_status $condition COND_WAIT_REFRESH catch {thread::cond notify $condition} } } -instproc start_request {payload obj} { :debug "JOB start request $obj" :set_cond_timeout } -instproc request_data {payload obj} { :debug "JOB request data $obj [string length $payload]" :set_cond_timeout } -instproc start_reply {payload obj} { :debug "JOB start reply $obj" :set_cond_timeout } -instproc reply_data {payload obj} { :debug "JOB reply data $obj [string length $payload]" :set_cond_timeout } # # TLS/SSL support # # Perform HTTPS requests via TLS (does not require nsopenssl) # - requires tls 1.5.0 to be compiled into <AOLserver>/lib/ ... # - - - - - - - - - - - - - - - - - - # - see http://www.ietf.org/rfc/rfc2246.txt # - http://wp.netscape.com/eng/ssl3/3-SPEC.HTM # - - - - - - - - - - - - - - - - - - Class create Tls Tls instproc open_connection {} { :instvar S # # first perform regular initialization of the socket # next # # then import tls (could configure it here in more detail) # ::tls::import $S } # # Trace Requests # Class create HttpRequestTrace nsv_set HttpRequestTrace count 0 HttpRequestTrace instproc init {} { :instvar F post_data set :meta [list] set :requestCount [nsv_incr HttpRequestTrace count] ;# make it an instvar to find it in the log file set F [open [ad_tmpdir]/req-[format %.4d ${:requestCount}] w] set method [expr {$post_data eq "" ? "GET" : "POST"}] puts $F "$method [:path] HTTP/1.0" puts $F "Host: [:host]" puts $F "User-Agent: [:user_agent]" foreach {tag value} [:request_header_fields] { puts $F "$tag: $value" } next } HttpRequestTrace instproc POST {} { :instvar F post_data puts $F "Content-Length: [string length $post_data]" puts $F "Content-Type: [:content_type]" puts $F "" fconfigure $F -translation {auto binary} puts -nonewline $F $post_data next } HttpRequestTrace instproc cancel {reason} { catch {close ${:F}} next } HttpRequestTrace instproc finish {} { catch {close ${:F}} next } # # To activate trace for all requests, uncomment the following line. # To trace a single request, mixin ::xo::HttpRequestTrace into the request. # # HttpCore instmixin add ::xo::HttpRequestTrace } ::xo::library source_dependent # # Local variables: # mode: tcl # tcl-indent-level: 2 # indent-tabs-mode: nil # End: