645 lines
18 KiB
Prolog
645 lines
18 KiB
Prolog
/* $Id$
|
|
|
|
Part of SWI-Prolog
|
|
|
|
Author: Jan Wielemaker
|
|
E-mail: J.Wielemaker@uva.nl
|
|
WWW: http://www.swi-prolog.org
|
|
Copyright (C): 2008, University of Amsterdam
|
|
|
|
This program is free software; you can redistribute it and/or
|
|
modify it under the terms of the GNU General Public License
|
|
as published by the Free Software Foundation; either version 2
|
|
of the License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Lesser General Public
|
|
License along with this library; if not, write to the Free Software
|
|
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|
|
|
As a special exception, if you link this library with other files,
|
|
compiled with a Free Software compiler, to produce an executable, this
|
|
library does not by itself cause the resulting executable to be covered
|
|
by the GNU General Public License. This exception does not however
|
|
invalidate any other reasons why the executable file might be covered by
|
|
the GNU General Public License.
|
|
*/
|
|
|
|
|
|
:- module(http_open,
|
|
[ http_open/3, % +URL, -Stream, +Options
|
|
http_set_authorization/2 % +URL, +Authorization
|
|
]).
|
|
|
|
:- use_module(library(url)).
|
|
:- use_module(library(readutil)).
|
|
:- use_module(library(socket)).
|
|
:- use_module(library(lists)).
|
|
:- use_module(library(option)).
|
|
:- use_module(library(error)).
|
|
:- use_module(library(base64)).
|
|
:- use_module(library(debug)).
|
|
|
|
:- expects_dialect(swi).
|
|
:- assert(system:swi_io).
|
|
|
|
user_agent('SWI-Prolog <http://www.swi-prolog.org>').
|
|
|
|
/** <module> Simple HTTP client
|
|
|
|
This library provides a light-weight HTTP client library to get the data
|
|
from a URL. The functionality of the library can be extended by loading
|
|
two additional modules that acts as plugins:
|
|
|
|
* library(http/http_chunked)
|
|
Loading this library causes http_open/3 to support chunked
|
|
transfer encoding.
|
|
|
|
* library(http/http_header)
|
|
Loading this library causes http_open/3 to support the =POST= method
|
|
in addition to =GET= and =HEAD=.
|
|
|
|
Here is a simple example to fetch a web-page:
|
|
|
|
==
|
|
?- http_open('http://www.google.com/search?q=prolog', In, []),
|
|
copy_stream_data(In, user_output),
|
|
close(In).
|
|
<!doctype html><head><title>prolog - Google Search</title><script>
|
|
...
|
|
==
|
|
|
|
The example below fetches the modification time of a web-page. Note that
|
|
Modified is '' if the web-server does not provide a time-stamp for the
|
|
resource. See also parse_time/2.
|
|
|
|
==
|
|
modified(URL, Stamp) :-
|
|
http_open(URL, In,
|
|
[ method(head),
|
|
header(last_modified, Modified)
|
|
]),
|
|
close(In),
|
|
Modified \== '',
|
|
parse_time(Modified, Stamp).
|
|
close(In).
|
|
==
|
|
|
|
@see xpath/3
|
|
@see http_get/3
|
|
@see http_post/4
|
|
*/
|
|
|
|
:- multifile
|
|
http:encoding_filter/3, % +Encoding, +In0, -In
|
|
http:current_transfer_encoding/1, % ?Encoding
|
|
http:http_protocol_hook/7. % +Protocol, +Parts, +In, +Out,
|
|
% -NewIn, -NewOut, +Options
|
|
|
|
|
|
%% http_open(+URL, -Stream, +Options) is det.
|
|
%
|
|
% Open the data at the HTTP server as a Prolog stream. URL is
|
|
% either an atom specifying a URL or a list representing a
|
|
% broken-down URL compatible to parse_url/2. After this predicate
|
|
% succeeds the data can be read from Stream. After completion this
|
|
% stream must be closed using the built-in Prolog predicate
|
|
% close/1. Options provides additional options:
|
|
%
|
|
% * authorization(+Term)
|
|
% Send authorization. Currently only supports basic(User,Password).
|
|
% See also http_set_authorization/2.
|
|
%
|
|
% * final_url(-FinalURL)
|
|
% Unify FinalURL} with the final destination. This differs from
|
|
% the original URL if the returned head of the original
|
|
% indicates an HTTP redirect (codes 301, 302 or 303). Without a
|
|
% redirect, FinalURL is unified with the canonical version of
|
|
% URL using:
|
|
%
|
|
% ==
|
|
% parse_url(URL, Parts),
|
|
% parse_url(FinalURL, Parts)
|
|
% ==
|
|
%
|
|
% * header(Name, -AtomValue)
|
|
% If provided, AtomValue is unified with the value of the
|
|
% indicated field in the reply header. Name is matched
|
|
% case-insensitive and the underscore (_) matches the hyphen
|
|
% (-). Multiple of these options may be provided to extract
|
|
% multiple header fields. If the header is not available
|
|
% AtomValue is unified to the empty atom ('').
|
|
%
|
|
% * method(+Method)
|
|
% One of =get= (default) or =head=. The =head= message can be
|
|
% used in combination with the header(Name, Value) option to
|
|
% access information on the resource without actually fetching
|
|
% the resource itself. The returned stream must be closed
|
|
% immediately. If library(http/http_header) is loaded,
|
|
% http_open/3 also supports =post=. See the post(Data) option.
|
|
%
|
|
% * size(-Size)
|
|
% Size is unified with the integer value of =|Content-Length|=
|
|
% in the reply header.
|
|
%
|
|
% * timeout(+Timeout)
|
|
% If provided, set a timeout on the stream using set_stream/2.
|
|
% With this option if no new data arrives within Timeout seconds
|
|
% the stream raises an exception. Default is to wait forever
|
|
% (=infinite=).
|
|
%
|
|
% * post(+Data)
|
|
% Provided if library(http/http_header) is also loaded. Data is
|
|
% handed to http_post_data/3.
|
|
%
|
|
% * proxy(+Host, +Port)
|
|
% Use an HTTP proxy to connect to the outside world.
|
|
%
|
|
% * proxy_authorization(+Authorization)
|
|
% Send authorization to the proxy. Otherwise the same as the
|
|
% =authorization= option.
|
|
%
|
|
% * request_header(Name = Value)
|
|
% Additional name-value parts are added in the order of
|
|
% appearance to the HTTP request header. No interpretation is
|
|
% done.
|
|
%
|
|
% * user_agent(+Agent)
|
|
% Defines the value of the =|User-Agent|= field of the HTTP
|
|
% header. Default is =|SWI-Prolog (http://www.swi-prolog.org)|=.
|
|
%
|
|
% @error existence_error(url, Id)
|
|
|
|
http_open(URL, Stream, Options) :-
|
|
atom(URL), !,
|
|
parse_url_ex(URL, Parts),
|
|
add_authorization(URL, Options, Options1),
|
|
http_open(Parts, Stream, Options1).
|
|
http_open(Parts, Stream, Options0) :-
|
|
memberchk(proxy(Host, ProxyPort), Options0), !,
|
|
parse_url_ex(Location, Parts),
|
|
Options = [visited(Parts)|Options0],
|
|
open_socket(Host:ProxyPort, In, Out, Options),
|
|
option(protocol(Protocol), Parts, http),
|
|
default_port(Protocol, DefPort),
|
|
option(port(Port), Parts, DefPort),
|
|
host_and_port(Host, DefPort, Port, HostPort),
|
|
add_authorization(Parts, Options, Options1),
|
|
send_rec_header(Out, In, Stream, HostPort, Location, Parts, Options1),
|
|
return_final_url(Options).
|
|
http_open(Parts, Stream, Options0) :-
|
|
memberchk(host(Host), Parts),
|
|
option(protocol(Protocol), Parts, http),
|
|
default_port(Protocol, DefPort),
|
|
option(port(Port), Parts, DefPort),
|
|
http_location(Parts, Location),
|
|
Options = [visited(Parts)|Options0],
|
|
open_socket(Host:Port, SocketIn, SocketOut, Options),
|
|
( http:http_protocol_hook(Protocol, Parts,
|
|
SocketIn, SocketOut,
|
|
In, Out, Options)
|
|
-> true
|
|
; In = SocketIn,
|
|
Out = SocketOut
|
|
),
|
|
host_and_port(Host, DefPort, Port, HostPort),
|
|
add_authorization(Parts, Options, Options1),
|
|
send_rec_header(Out, In, Stream, HostPort, Location, Parts, Options1),
|
|
return_final_url(Options).
|
|
|
|
http:http_protocol_hook(http, _, In, Out, In, Out, _).
|
|
|
|
default_port(https, 443) :- !.
|
|
default_port(_, 80).
|
|
|
|
host_and_port(Host, DefPort, DefPort, Host) :- !.
|
|
host_and_port(Host, _, Port, Host:Port).
|
|
|
|
%% send_rec_header(+Out, +In, -InStream,
|
|
%% +Host, +Location, +Parts, +Options) is det.
|
|
%
|
|
% Send header to Out and process reply. If there is an error or
|
|
% failure, close In and Out and return the error or failure.
|
|
|
|
send_rec_header(Out, In, Stream, Host, Location, Parts, Options) :-
|
|
( catch(guarded_send_rec_header(Out, In, Stream,
|
|
Host, Location, Parts, Options),
|
|
E, true)
|
|
-> ( var(E)
|
|
-> close(Out)
|
|
; force_close(In, Out),
|
|
throw(E)
|
|
)
|
|
; force_close(In, Out),
|
|
fail
|
|
).
|
|
|
|
guarded_send_rec_header(Out, In, Stream, Host, Location, Parts, Options) :-
|
|
user_agent(Agent, Options),
|
|
method(Options, MNAME),
|
|
http_version(Version),
|
|
format(Out,
|
|
'~w ~w HTTP/~w\r\n\
|
|
Host: ~w\r\n\
|
|
User-Agent: ~w\r\n\
|
|
Connection: close\r\n',
|
|
[MNAME, Location, Version, Host, Agent]),
|
|
x_headers(Options, Out),
|
|
( option(post(PostData), Options)
|
|
-> http_header:http_post_data(PostData, Out, [])
|
|
; format(Out, '\r\n', [])
|
|
),
|
|
flush_output(Out),
|
|
% read the reply header
|
|
read_header(In, Code, Comment, Lines),
|
|
do_open(Code, Comment, Lines, Options, Parts, In, Stream).
|
|
|
|
|
|
%% http_version(-Version:atom) is det.
|
|
%
|
|
% HTTP version we publish. We can only use 1.1 if we support
|
|
% chunked encoding, which means http_chunked.pl must be loaded.
|
|
|
|
http_version('1.1') :-
|
|
http:current_transfer_encoding(chunked), !.
|
|
http_version('1.0').
|
|
|
|
force_close(S1, S2) :-
|
|
close(S1, [force(true)]),
|
|
close(S2, [force(true)]).
|
|
|
|
method(Options, MNAME) :-
|
|
option(post(_), Options), !,
|
|
option(method(M), Options, post),
|
|
( map_method(M, MNAME0)
|
|
-> MNAME = MNAME0
|
|
; domain_error(method, M)
|
|
).
|
|
method(Options, MNAME) :-
|
|
option(method(M), Options, get),
|
|
( map_method(M, MNAME0)
|
|
-> MNAME = MNAME0
|
|
; domain_error(method, M)
|
|
).
|
|
|
|
map_method(get, 'GET').
|
|
map_method(head, 'HEAD').
|
|
map_method(post, 'POST') :-
|
|
current_predicate(http_header:http_post_data/3).
|
|
|
|
|
|
%% x_headers(+Options, +Out) is det.
|
|
%
|
|
% Emit extra headers from request_header(Name=Value) options in
|
|
% Options.
|
|
|
|
x_headers([], _).
|
|
x_headers([H|T], Out) :- !,
|
|
x_header(H, Out),
|
|
x_headers(T, Out).
|
|
|
|
x_header(request_header(Name=Value), Out) :- !,
|
|
format(Out, '~w: ~w\r\n', [Name, Value]).
|
|
x_header(proxy_authorization(ProxyAuthorization), Out) :- !,
|
|
auth_header(ProxyAuthorization, 'Proxy-Authorization', Out).
|
|
x_header(authorization(Authorization), Out) :- !,
|
|
auth_header(Authorization, 'Authorization', Out).
|
|
x_header(_, _).
|
|
|
|
auth_header(basic(User, Password), Header, Out) :- !,
|
|
format(codes(Codes), '~w:~w', [User, Password]),
|
|
phrase(base64(Codes), Base64Codes),
|
|
format(Out, '~w: basic ~s\r\n', [Header, Base64Codes]).
|
|
auth_header(Auth, _, _) :-
|
|
domain_error(authorization, Auth).
|
|
|
|
user_agent(Agent, Options) :-
|
|
( option(user_agent(Agent), Options)
|
|
-> true
|
|
; user_agent(Agent)
|
|
).
|
|
|
|
%% do_open(+HTTPStatusCode, +HTTPStatusComment, +Header,
|
|
%% +Options, +Parts, +In, -FinalIn) is det.
|
|
%
|
|
% Handle the HTTP status. If 200, we are ok. If a redirect, redo
|
|
% the open, returning a new stream. Else issue an error.
|
|
%
|
|
% @error existence_error(url, URL)
|
|
|
|
do_open(200, _, Lines, Options, Parts, In0, In) :- !,
|
|
return_size(Options, Lines),
|
|
return_fields(Options, Lines),
|
|
transfer_encoding_filter(Lines, In0, In),
|
|
% properly re-initialise the stream
|
|
parse_url_ex(Id, Parts),
|
|
set_stream(In, file_name(Id)),
|
|
set_stream(In, record_position(true)).
|
|
% Handle redirections
|
|
do_open(Code, _, Lines, Options, Parts, In, Stream) :-
|
|
redirect_code(Code),
|
|
location(Lines, Location), !,
|
|
debug(http(redirect), 'http_open: redirecting to ~w', [Location]),
|
|
parse_url_ex(Location, Parts, Redirected),
|
|
close(In),
|
|
http_open(Redirected, Stream, [visited(Redirected)|Options]).
|
|
% report anything else as error
|
|
do_open(Code, Comment, _, _, Parts, _, _) :-
|
|
parse_url_ex(Id, Parts),
|
|
( map_error_code(Code, Error)
|
|
-> Formal =.. [Error, url, Id]
|
|
; Formal = existence_error(url, Id)
|
|
),
|
|
throw(error(Formal, context(_, status(Code, Comment)))).
|
|
|
|
%% map_error_code(+HTTPCode, -PrologError) is semidet.
|
|
%
|
|
% Map HTTP error codes to Prolog errors.
|
|
%
|
|
% @tbd Many more maps. Unfortunately many have no sensible Prolog
|
|
% counterpart.
|
|
|
|
map_error_code(401, permission_error).
|
|
map_error_code(403, permission_error).
|
|
map_error_code(404, existence_error).
|
|
map_error_code(405, permission_error).
|
|
map_error_code(407, permission_error).
|
|
map_error_code(410, existence_error).
|
|
|
|
redirect_code(301). % moved permanently
|
|
redirect_code(302). % moved temporary
|
|
redirect_code(303). % see also
|
|
|
|
%% open_socket(+Address, -In, -Out, +Options) is det.
|
|
%
|
|
% Create and connect a client socket to Address. Options
|
|
%
|
|
% * timeout(+Timeout)
|
|
% Sets timeout on the stream, *after* connecting the
|
|
% socket.
|
|
%
|
|
% @tbd Make timeout also work on tcp_connect/4.
|
|
% @tbd This is the same as do_connect/4 in http_client.pl
|
|
|
|
open_socket(Address, In, Out, Options) :-
|
|
debug(http(open), 'http_open: Connecting to ~p ...', [Address]),
|
|
tcp_socket(Socket),
|
|
catch(tcp_connect(Socket, Address, In, Out),
|
|
E,
|
|
( tcp_close_socket(Socket),
|
|
throw(E)
|
|
)),
|
|
debug(http(open), '\tok ~p --> ~p', [In, Out]),
|
|
set_stream(In, record_position(false)),
|
|
( memberchk(Options, timeout(Timeout))
|
|
-> set_stream(In, timeout(Timeout))
|
|
; true
|
|
).
|
|
|
|
|
|
return_size(Options, Lines) :-
|
|
memberchk(size(Size), Options), !,
|
|
content_length(Lines, Size).
|
|
return_size(_, _).
|
|
|
|
return_fields([], _).
|
|
return_fields([header(Name, Value)|T], Lines) :- !,
|
|
atom_codes(Name, Codes),
|
|
( member(Line, Lines),
|
|
phrase(atom_field(Codes, Value), Line)
|
|
-> true
|
|
; Value = ''
|
|
),
|
|
return_fields(T, Lines).
|
|
return_fields([_|T], Lines) :-
|
|
return_fields(T, Lines).
|
|
|
|
|
|
%% return_final_url(+Options) is semidet.
|
|
%
|
|
% If Options contains final_url(URL), unify URL with the final
|
|
% URL after redirections.
|
|
|
|
return_final_url(Options) :-
|
|
memberchk(final_url(URL), Options),
|
|
var(URL), !,
|
|
memberchk(visited(Parts), Options),
|
|
parse_url_ex(URL, Parts).
|
|
return_final_url(_).
|
|
|
|
|
|
%% transfer_encoding_filter(+Lines, +In0, -In) is det.
|
|
%
|
|
% Install filters depending on the encoding.
|
|
|
|
transfer_encoding_filter(Lines, In0, In) :-
|
|
transfer_encoding(Lines, Encoding), !,
|
|
( http:encoding_filter(Encoding, In0, In)
|
|
-> true
|
|
; domain_error(http_encoding, Encoding)
|
|
).
|
|
transfer_encoding_filter(_, In, In).
|
|
|
|
|
|
%% transfer_encoding(+Lines, -Encoding) is semidet.
|
|
%
|
|
% True if Encoding is the value of the =|Transfer-encoding|=
|
|
% header.
|
|
|
|
transfer_encoding(Lines, Encoding) :-
|
|
member(Line, Lines),
|
|
phrase(transfer_encoding(Encoding0), Line), !,
|
|
debug(http(transfer_encoding), 'Transfer-encoding: ~w', [Encoding0]),
|
|
Encoding = Encoding0.
|
|
|
|
transfer_encoding(Encoding) -->
|
|
field("transfer-encoding"),
|
|
rest(Encoding).
|
|
|
|
%% read_header(+In:stream, -Code:int, -Comment:atom, -Lines:list)
|
|
%
|
|
% Read the HTTP reply-header.
|
|
%
|
|
% @param Code Numeric HTTP reply-code
|
|
% @param Comment Comment of reply-code as atom
|
|
% @param Lines Remaining header lines as code-lists.
|
|
|
|
read_header(In, Code, Comment, Lines) :-
|
|
read_line_to_codes(In, Line),
|
|
phrase(first_line(Code, Comment), Line),
|
|
read_line_to_codes(In, Line2),
|
|
rest_header(Line2, In, Lines).
|
|
|
|
rest_header("", _, []) :- !. % blank line: end of header
|
|
rest_header(L0, In, [L0|L]) :-
|
|
read_line_to_codes(In, L1),
|
|
rest_header(L1, In, L).
|
|
|
|
%% content_length(+Header, -Length:int) is semidet.
|
|
%
|
|
% Find the Content-Length in an HTTP reply-header.
|
|
|
|
content_length(Lines, Length) :-
|
|
member(Line, Lines),
|
|
phrase(content_length(Length0), Line), !,
|
|
Length = Length0.
|
|
|
|
location(Lines, Location) :-
|
|
member(Line, Lines),
|
|
phrase(atom_field("location", Location), Line), !.
|
|
|
|
first_line(Code, Comment) -->
|
|
"HTTP/", [_], ".", [_],
|
|
skip_blanks,
|
|
integer(Code),
|
|
skip_blanks,
|
|
rest(Comment).
|
|
|
|
atom_field(Name, Value) -->
|
|
field(Name),
|
|
rest(Value).
|
|
|
|
content_length(Len) -->
|
|
field("content-length"),
|
|
integer(Len).
|
|
|
|
field([]) -->
|
|
":",
|
|
skip_blanks.
|
|
field([H|T]) -->
|
|
[C],
|
|
{ match_header_char(H, C)
|
|
},
|
|
field(T).
|
|
|
|
match_header_char(C, C) :- !.
|
|
match_header_char(C, U) :-
|
|
code_type(C, to_lower(U)), !.
|
|
match_header_char(0'_, 0'-).
|
|
|
|
|
|
skip_blanks -->
|
|
[C],
|
|
{ code_type(C, white)
|
|
}, !,
|
|
skip_blanks.
|
|
skip_blanks -->
|
|
[].
|
|
|
|
%% integer(-Int)//
|
|
%
|
|
% Read 1 or more digits and return as integer.
|
|
|
|
integer(Code) -->
|
|
digit(D0),
|
|
digits(D),
|
|
{ number_codes(Code, [D0|D])
|
|
}.
|
|
|
|
digit(C) -->
|
|
[C],
|
|
{ code_type(C, digit)
|
|
}.
|
|
|
|
digits([D0|D]) -->
|
|
digit(D0), !,
|
|
digits(D).
|
|
digits([]) -->
|
|
[].
|
|
|
|
%% rest(-Atom:atom)//
|
|
%
|
|
% Get rest of input as an atom.
|
|
|
|
rest(A,L,[]) :-
|
|
atom_codes(A, L).
|
|
|
|
|
|
/*******************************
|
|
* AUTHORIZATION MANAGEMENT *
|
|
*******************************/
|
|
|
|
%% http_set_authorization(+URL, +Authorization) is det.
|
|
%
|
|
% Set user/password to supply with URLs that have URL as prefix.
|
|
% If Authorization is the atom =|-|=, possibly defined
|
|
% authorization is cleared. For example:
|
|
%
|
|
% ==
|
|
% ?- http_set_authorization('http://www.example.com/private/',
|
|
% basic('John', 'Secret'))
|
|
% ==
|
|
%
|
|
% @tbd Move to a separate module, so http_get/3, etc. can use this
|
|
% too.
|
|
|
|
:- dynamic
|
|
stored_authorization/2,
|
|
cached_authorization/2.
|
|
|
|
http_set_authorization(URL, Authorization) :-
|
|
must_be(atom, URL),
|
|
retractall(stored_authorization(URL, _)),
|
|
( Authorization = (-)
|
|
-> true
|
|
; check_authorization(Authorization),
|
|
assert(stored_authorization(URL, Authorization))
|
|
),
|
|
retractall(cached_authorization(_,_)).
|
|
|
|
check_authorization(Var) :-
|
|
var(Var), !,
|
|
instantiation_error(Var).
|
|
check_authorization(basic(User, Password)) :-
|
|
must_be(atom, User),
|
|
must_be(atom, Password).
|
|
|
|
%% authorization(+URL, -Authorization) is semdet.
|
|
%
|
|
% True if Authorization must be supplied for URL.
|
|
%
|
|
% @tbd Cleanup cache if it gets too big.
|
|
|
|
authorization(_, _) :-
|
|
\+ stored_authorization(_, _), !,
|
|
fail.
|
|
authorization(URL, Authorization) :-
|
|
cached_authorization(URL, Authorization), !,
|
|
Authorization \== (-).
|
|
authorization(URL, Authorization) :-
|
|
( stored_authorization(Prefix, Authorization),
|
|
sub_atom(URL, 0, _, _, Prefix)
|
|
-> assert(cached_authorization(URL, Authorization))
|
|
; assert(cached_authorization(URL, -)),
|
|
fail
|
|
).
|
|
|
|
add_authorization(_, Options, Options) :-
|
|
option(authorization(_), Options), !.
|
|
add_authorization(For, Options0, Options) :-
|
|
stored_authorization(_, _) -> % quick test to avoid work
|
|
( atom(For)
|
|
-> URL = For
|
|
; parse_url_ex(URL, For)
|
|
),
|
|
authorization(URL, Auth), !,
|
|
Options = [authorization(Auth)|Options0].
|
|
add_authorization(_, Options, Options).
|
|
|
|
|
|
parse_url_ex(URL, Parts) :-
|
|
parse_url(URL, Parts), !.
|
|
parse_url_ex(URL, _) :-
|
|
domain_error(url, URL). % Syntax error?
|
|
|
|
parse_url_ex(URL, RelativeTo, Parts) :-
|
|
parse_url(URL, RelativeTo, Parts), !.
|
|
parse_url_ex(URL, _, _) :-
|
|
domain_error(url, URL). % Syntax error?
|
|
|
|
:- retract(system:swi_io).
|