Erlang Library for interacting with SOAP Web Services.
ews is a library for interacting with SOAP web services. It includes functionality to
- generate a model from the WSDLs that describe the web services
- generate a model from plain XSD for file encoding and decoding
- emit an Erlang .hrl file defining records corresponding to the types in the model
- call web service operations with automatic encoding of operands and decoding of the response
- supply hooks that are applied immediately before or after the actual SOAP calls
- New: streaming decode of large XML documents with
ews:stream_decode/7. A repeated child element (e.g. an unbounded sequence ofItemelements inside anItemscontainer) is decoded record by record from a document fed in chunks, without ever holding the whole document or the whole decoded result in memory. Returns{ok, ews_stream:ews_stream_msg()}or{error, term()}, where the message (cont|max_reached|trailers) carries the decoded-record count and, at end of stream, the decoded document around the streamed elements. See "Streaming decode of large documents" under Interface below. - New:
ews_xml:decode/2parses xml incrementally: it consumes as much as possible of each chunk, keeps partial input and parser state between calls, and emits completed target elements as xml terms. - New:
ews_serialize:compile_elem_plan/2builds a compiled decode plan for a single element; streaming decode uses it so each record is decoded without any model (ETS) lookups. - Fix:
ews:decode_compiled/2now decodes present-but-empty elements (e.g.<Note/>) exactly likeews:decode/2does.
- New: precompiled encode/decode plans for fast batch processing.
ews:compile_record_encoder/2walks the model once and resolves every model (ETS) lookup into a reusable plan tree — plain records (seesrc/ews_plan.hrl), not closures, so a plan is human-inspectable.ews:encode_compiled/2andews:decode_compiled/2apply the plan; the same plan drives both directions. Profiling batch encoding showed ~2/3 of the time spent in repeated model lookups, which the plan eliminates. Constructs the compiler cannot specialise statically (type unions, polymorphic subtypes/xsi:type, inline simple types) fall back to the runtime encoder/decoder per node, so results are identical toews:encode/2/ews:decode/2. See "Precompiled encode/decode plans" under Interface below.
soap_timeouthas been replaced byconnect_timeoutandrecv_timeout.- By default the hackney pool is now named after the model.
- Unions now work, but only if they have the same base since it usess the first base for all of the union.
- Add the possibility to add plain XSDs to the model to generate record for elements and types that aren't part of a WSDL.
- Encoding and decoding of any records in the model.
Known issues: Encoding and decoding of overloaded unnamed types like Foo@bar_1 will probably not work.
- Change the order of emited types to
binary() | string(). This will change almost every line in the generated hrls generated from previous versions.
- Support for
includein schemas. - Handle non-ASCII in types
- Fix for a bug that didn't decode emtpy records like
-record(foo, {}). - Handle
group. - Handle
simpleContentwith attributes by creating a record for it.
Breaking changes
ews:call_service_opwill now return{ok, [term()]}instead of{ok, term()}, cause there can actually be more than one message returned.ews:decode_inwill not return{ok, {Svc, Op, list(Headers), list(Body)}}
Other changes
- Non-optional attributes in
__attrswill now have:=in their typespec - A more consistent ordering of records. First in dep order, then namespace and lastly name. This will reorder everything once now, but less in the future.
- No more support for just verifying fingerprints. It has to be certificates.
- Fix of bug allowing expired certs to be verified.
Two breaking changes have been introduced in 3.0.0.
- Prehooks now take 6 arguments and should return 6 arguments.
- XSDs that define attributes now result in a records with an __attrs map.
Versions before 3.0.0 documented that the second argument to prehooks was the Operation, but in fact it was the SOAPAction. From version 3.0.0 both these are now arguments so hooks now need 6 arguments and need to return 6 arguments.
There is also a new type of pre_post hook that lets you modify the actual rendered XML instead of the internal ews representation.
Version 3.0.0 introduces an extra field called __attrs first of record
where the XSD defines attributes. __attrs is a map and keys should be
atoms.
Version 4.1.0 introduces new logic for simpleContent with attributes.
Normally we don't emit records for simpleContent, but if the simpleContent
has attributes we have to. Since the simpleContent isn't an element we
don't have a name for the field in the record so it will get the special
name value. Example:
-record(foo, {'__attrs' :: #{bar => string() | binary()} | undefined
value :: integer() | undefined}).
In order to support encoding and decoding of XML-files that aren't in a message in a WSDL, support for adding random XSDs has been added.
ews:add_xsd_to_model(xsd_test, "test/xmldsig-core-schema.xsd").
Encoded = ews:encode(xsd_test, #rsa_key_value_type{modulus = <<"15">>,
exponent = <<"3">>}).
<<"<?xml version=\"1.0\" encoding=\"UTF-8\"?><p1:RSAKeyValueType x"
"mlns:p1=\"http://www.w3.org/2000/09/xmldsig#\"><p1:Modulus>15</"
"p1:Modulus><p1:Exponent>3</p1:Exponent></p1:RSAKeyValueType>">>
ews:decode(xsd_test, Encoded).
#rsa_key_value_type{modulus = <<"15">>,
exponent = <<"3">>}
Known issues: Encoding and decoding of overloaded unnamed types like Foo@bar_1 will probably not work.
ews is an Erlang application that can be started and stopped in the normal way.
All functions that take a model as a parameter have corresponding ones where you omit it in which case the model used will be default.
ews:add_wsdl_to_model(Model :: atom(), URL :: list())
Downloads the WSDL file specified by the URL and adds the contents to the specified model.
ews:add_wsdl_to_model(Model :: atom(), WSDL :: binary())
The WSDL file contents provided is added to the specified model.
ews:remove_model(Model :: atom())
Removes the specified model.
ews:emit_complete_model_types(Model :: atom(), FileName :: list())
Emits the entire model as an Erlang .hrl file.
ews:list_services(Model :: atom()) -> {ok, Service :: [list()]}
Returns a list of the services defined in the specified model.
ews:list_model_services() -> {ok, [{Model :: atom(), Service :: list()}]}
Returns a list of all services ews knows about along with the model they belong to.
ews:call_service_op(Model :: atom(), Service :: list(), Op :: list(), Header :: list(), Body :: term(), Options :: map()) -> {ok, Response :: term()} | {error, term()}
Calls the specified service operation providing the given header and body. Valid options are
timeout => integer()Call timeout in millisecondshttp_headers => list()Extra HTTP headers added to the lhttpc callinclude_http_response_headers => boolean()When true, includes the HTTP response headers in the returned value
ews:get_service_ops(Model :: atom(), Service :: list()) -> {ok, [OpName :: list()]} | {error, no_service}
Returns a list of the available operations for the specified service in the given model.
ews:get_service_op_info(Model :: atom(), Service :: list()) -> {ok, proplists:proplist()} | {error, no_service} | {error, no_op}
Returns a list with detailed information about the given operation.
It is possible to add pre-call hook functions that are called between the encoding of operation parameters and the HTTP call. You can also add post-call hook functions that are called between the response reception and the decoding of the response. This can be useful for metrics, logging, trouble shooting or to be able to handle strange stuff that WSDL authors come up with :)
A pre-call hook function is a fun of one argument which is a list [Endpoint :: list(), Operation :: list, SoapAction :: list(), Headers :: list(), Body :: term(), Options :: map()] which should return a list of the same type where the elements might be updated. This includes the Options map, which can be used to pass information to other pre-call and post-call hooks.
A pre-post-call hook function is a fun of one argument which is a list [Endpoint :: list(), Operation :: list, XMLBody :: binary(), Options :: prop_list()] which should return a list of the same type where the elements might be updated.
A post-call hook is a fun which takes as arument a list [Headers :: list(), Body :: term(), Options :: map()] and which should return a list of the same type.
If there are multiple pre- or post-call hooks they will be called in the order they were added, each one being passed the output of the previous one.
ews:add_pre_hook(Model :: atom(), Hook :: fun()) -> HookRef :: ref()
Adds a pre-call hook to the specified model.
ews:add_pre_post_hook(Model :: atom(), Hook :: fun()) -> HookRef :: ref()
Adds a pre-post-call hook to the specified model.
ews:add_post_hook(Model :: atom(), Hook :: fun()) -> HookRef :: ref()
Adds a post-call hook to the specified model.
ews:remove_pre_hook(Model :: atom(), HookRef :: ref()) -> ok
Removes a pre-call hook. The HookRef specified should be what the add_pre_hook call returned when the hook was added.
ews:remove_pre_post_hook(Model :: atom(), HookRef :: ref()) -> ok
Removes a pre-post-call hook. The HookRef specified should be what the add_pre_hook call returned when the hook was added.
ews:remove_post_hook(Model :: atom(), HookRef :: ref()) -> ok
Removes a post-call hook. The HookRef specified should be what the add_post_hook call returned when the hook was added.
ews:record_to_map(Model :: atom(), Record :: record()) -> map()
Converts a record representation of an entity in the model into a map representation.
There exist functions to encode and decode service operation parameters without actually doing the call. This was done to allow for a model where batch operations were specified as taking lists of encoded operations and returning lists of the individual results.
ews:encode_service_op(Model :: atom(), Service :: list(), Op :: list(), Header :: list(), Body :: term(), Options :: map()) -> {ok, Xml :: term()} | {error, term()}
Returns and encoding of the specified service operation providing the given header and body.
ews:decode_service_op_result(Model :: atom(), Service :: list(), Op :: list(), Body :: term(), Options :: map()) -> {ok, term()} | {error, term()}
Returns the Erlang representation of the provided result of calling the specified operation.
ews:encode/2 and ews:decode/2 repeat the same model (ETS) lookups for
every record. When processing a large homogeneous batch — encoding or
decoding many records of the same type — those lookups dominate. A plan
resolves them once, up front:
ews:compile_record_encoder(Model :: atom(), Alias :: atom()) -> Plan
Builds a reusable plan for records tagged with Alias (the record name,
as emitted in the generated .hrl). The plan is a tree of plain records
(see src/ews_plan.hrl), so it can be inspected in the shell. Raises
error({not_in_model, Alias}) for an unknown alias.
ews:encode_compiled(Plan, Record :: record()) -> Xml :: binary()
Encodes one record with the plan. Same output as ews:encode/2.
ews:decode_compiled(Plan, Xml :: binary()) -> record()
Decodes an xml binary with the same plan. Same result as ews:decode/2.
Example, with a model containing -record(item_type, {id, name}):
Plan = ews:compile_record_encoder(my_model, item_type),
%% Encode a large batch, one xml document per record:
Xmls = [ews:encode_compiled(Plan, Item) || Item <- Items],
%% The same plan decodes too:
#item_type{} = ews:decode_compiled(Plan, hd(Xmls)).
Constructs that cannot be specialised statically (type unions, polymorphic subtypes/xsi:type, inline simple element types) fall back to the runtime encoder/decoder for that node, so compiled results are always identical to the uncompiled ones.
When a document is too large to decode in one go — typically a batch file where one container element holds an unbounded number of child elements — it can be decoded in a streaming fashion, feeding the raw XML in chunks and receiving the repeated child as records, a bounded batch at a time. Neither the whole document nor the whole decoded result is ever held in memory.
Suppose a schema where the root element Batch contains an Items
container with an unbounded sequence of Item elements, giving these
generated records:
-record(item_type, {id, name, status, note}).
-record(items_type, {item :: [#item_type{}] | undefined}).
ews:stream_decode(Model :: atom(), ContainingRecord :: tuple() | atom(), RecordIdx :: integer(), Chunk :: binary(), Rest, Max :: integer(), Skip :: integer()) -> {ok, ews_stream:ews_stream_msg()} | {error, term()}
Decodes up to Max child records from Chunk plus any data buffered in
Rest. The repeated child is identified by the container record (or its
alias) and the record field index of the child, e.g. #items_type{} and
#items_type.item. Always returns a 2-tuple; the message inside ok
tells the caller what to do next (in the style of hackney's h2_msg()),
and always carries the number of records decoded in the call:
-type ews_stream_msg() ::
{cont, Count, SkipLeft, Records, State}
| {max_reached, Count, SkipLeft, Records, State}
| {trailers, Count, SkipLeft, Records, Trailers, State}.
cont— the buffered input is exhausted; feed more data.max_reached—Maxrecords were decoded; call again with an empty chunk to drain already-buffered data before reading more input. No need to compareCountagainstMaxyourself.trailers— the stream has ended.Trailersis the decoded document around the streamed elements: the whole document record with the streamed field set to[]. Further calls repeat the trailers withCount = 0.Countis the number of elements consumed in the call, INCLUDING skipped ones — summingCountover all calls gives the absolute position in the stream, exactly the value to pass asSkipon a restart. The decoded records areRecords(length(Records)is the decoded count). Skipped elements never appear inRecordsand do not count towardsMax— a call that only skips returns{cont, Count, SkipLeft, [], State}, nevermax_reached.SkipLeftis how many of theSkipelements remain to be skipped after the call.Restis<<>>(orundefined) on the first call; on subsequent calls pass theStatefrom the previous message. It is an opaque state that carries buffered input, parser state and progress.Skipskips the firstSkipchild elements of the stream without decoding them, which allows restarting an interrupted stream from the top of the file. Suppose a run withMax1000 died after 10000 processed elements (ews_stream:seen(State)at the last checkpoint, or the number of records the caller had handled). Restart withRest = <<>>andSkip = 10000and feed chunks as usual: the messages countSkipLeftdown from 10000 to 0 over as many calls and chunks as it takes (withRecords = []on the way,Countshowing how many elements each call skipped), and decoding then resumes at element 10001.- Errors come back as
{error, Reason}, e.g.{error, {not_a_list, Qname}}when the selected field is not a repeated element,{error, {not_in_model, Alias}}for an unknown container,{error, {unmatched_close_tag, Name}}for broken tag nesting, or a decode error when an element does not match the schema. A truncated document is not detectable by the parser (more input might still arrive): the caller seescontat end of input, as in the example below.
Namespace prefixes declared on ancestor elements (typically the document
root) are resolved as usual. The child element must not nest inside
itself, and a self-closing (<Item/>) child element is not emitted.
Decoding uses a compiled plan, so no model lookups are made per record.
Example, decoding a large file read in chunks:
process(File) ->
{ok, Fd} = file:open(File, [read, raw, binary, read_ahead]),
try stream(Fd, <<>>, <<>>)
after file:close(Fd)
end.
stream(Fd, Chunk, Rest) ->
case ews:stream_decode(my_model, #items_type{}, #items_type.item,
Chunk, Rest, 500, 0) of
{ok, {trailers, _Count, _SkipLeft, Items, Batch, _Rest1}} ->
handle_items(Items),
%% Batch is the whole document with item = []
{ok, Batch};
{ok, {max_reached, _Count, _SkipLeft, Items, Rest1}} ->
handle_items(Items),
stream(Fd, <<>>, Rest1);
{ok, {cont, _Count, _SkipLeft, Items, Rest1}} ->
handle_items(Items),
case file:read(Fd, 65536) of
{ok, Chunk1} -> stream(Fd, Chunk1, Rest1);
eof -> {error, truncated_document}
end;
{error, Reason} ->
{error, Reason}
end.
The underlying incremental xml parser can also be used on its own:
ews_xml:stream_new(TargetQname) creates a parser state, and
ews_xml:decode(Chunk, State) -> {XmlTerms, State1} consumes as much as
possible of each chunk and returns the target elements completed so far
as xml terms. ews_xml:stream_done(State) reports whether the document's
root element has been closed.
The ews application uses the following application environment variables:
connect_timeout
Connect timeout for SOAP calls in milliseconds (default: 8000).
recv_timeout
Receive timeout for SOAP calls in milliseconds (default: 5000).
cache_base_dir
Base directory under which ews stores cached xsds (default: code:priv_dir(ews)).
The function ews_test:test_everything(Model :: atom()) will serialize and
deserialize all possible ins, outs and faults of all ops the the selected model.
It will generate defaults for every entry in every record that can be reached.
A possible ct test would look like this:
serialize_deserialize(_Config) ->
{ok, _} = ews:add_wsdl_to_model(moose,
"moose.wsdl")),
ok = ews_test:test_everything(moose),
ok.