diff --git a/Project.toml b/Project.toml index 94ae989..7b29020 100644 --- a/Project.toml +++ b/Project.toml @@ -4,7 +4,7 @@ keywords = ["Swagger", "OpenAPI", "REST"] license = "MIT" desc = "OpenAPI server and client helper for Julia" authors = ["JuliaHub Inc."] -version = "0.2.0" +version = "0.2.1" [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" diff --git a/docs/src/userguide.md b/docs/src/userguide.md index 6da9dfd..245e7a4 100644 --- a/docs/src/userguide.md +++ b/docs/src/userguide.md @@ -127,6 +127,7 @@ Client(root::String; escape_path_params::Union{Nothing,Bool}=nothing, chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing, verbose::Union{Bool,Function}=false, + httplib::Symbol=OpenAPI.HTTPLib.Downloads, ) ``` @@ -140,7 +141,8 @@ Where: - `pre_request_hook`: user provided hook to modify the request before it is sent - `escape_path_params`: Whether the path parameters should be escaped before being used in the URL (true by default). This is useful if the path parameters contain characters that are not allowed in URLs or contain path separators themselves. - `chunk_reader_type`: The type of chunk reader to be used for streaming responses. -- `verbose`: whether to enable verbose logging +- `verbose`: whether to enable verbose logging (behavior depends on chosen HTTP backend) +- `httplib`: The HTTP client library to use for making requests. Can be `OpenAPI.HTTPLib.Downloads` (default) for Downloads.jl or `OpenAPI.HTTPLib.HTTP` for HTTP.jl. The `pre_request_hook` must provide the following two implementations: - `pre_request_hook(ctx::OpenAPI.Clients.Ctx) -> ctx` @@ -150,9 +152,10 @@ The `chunk_reader_type` can be one of `LineChunkReader`, `JSONChunkReader` or `R The `verbose` option can be one of: - `false`: the default, no verbose logging -- `true`: enables curl verbose logging to stderr -- a function that accepts two arguments - type and message (available on Julia version >= 1.7) +- `true`: enables verbose logging to stderr +- a function that accepts two arguments - type and message **(only supported with Downloads.jl backend; available on Julia version >= 1.7)** - a default implementation of this that uses `@info` to log the arguments is provided as `OpenAPI.Clients.default_debug_hook` + - **Note:** This option is not supported when using the HTTP.jl backend. With HTTP.jl, use `verbose=true` for boolean verbose logging only. In case of any errors an instance of `ApiException` is thrown. It has the following fields: diff --git a/src/client.jl b/src/client.jl index 5dad413..b7e90b5 100644 --- a/src/client.jl +++ b/src/client.jl @@ -14,171 +14,9 @@ import Base: convert, show, summary, getproperty, setproperty!, iterate import ..OpenAPI: APIModel, UnionAPIModel, OneOfAPIModel, AnyOfAPIModel, APIClientImpl, OpenAPIException, InvocationException, to_json, from_json, validate_property, property_type import ..OpenAPI: str2zoneddatetime, str2datetime, str2date - -abstract type AbstractChunkReader end - -# collection formats (OpenAPI v2) -# TODO: OpenAPI v3 has style and explode options instead of collection formats, which are yet to be supported -# TODO: Examine whether multi is now supported -const COLL_MULTI = "multi" # (legacy) aliased to CSV, as multi is not supported by Requests.jl (https://github.com/JuliaWeb/Requests.jl/issues/140) -const COLL_PIPES = "pipes" -const COLL_SSV = "ssv" -const COLL_TSV = "tsv" -const COLL_CSV = "csv" -const COLL_DLM = Dict{String,String}([COLL_PIPES=>"|", COLL_SSV=>" ", COLL_TSV=>"\t", COLL_CSV=>",", COLL_MULTI=>","]) - -const DEFAULT_TIMEOUT_SECS = 5*60 -const DEFAULT_LONGPOLL_TIMEOUT_SECS = 15*60 - -struct ApiException <: Exception - status::Int - reason::String - resp::Downloads.Response - error::Union{Nothing,Downloads.RequestError} - - function ApiException(error::Downloads.RequestError; reason::String="") - isempty(reason) && (reason = error.message) - isempty(reason) && (reason = error.response.message) - new(error.response.status, reason, error.response, error) - end - function ApiException(resp::Downloads.Response; reason::String="") - isempty(reason) && (reason = resp.message) - new(resp.status, reason, resp, nothing) - end -end - -""" - ApiResponse - -Represents the HTTP API response from the server. This is returned as the second return value from all API calls. - -Properties available: -- `status`: the HTTP status code -- `message`: the HTTP status message -- `headers`: the HTTP headers -- `raw`: the raw response ( as a Downloads.Response object) -""" -struct ApiResponse - raw::Downloads.Response -end - -function Base.getproperty(resp::ApiResponse, name::Symbol) - raw = getfield(resp, :raw) - if name === :status - return raw.status - elseif name === :message - return raw.message - elseif name === :headers - return raw.headers - else - return getfield(resp, name) - end -end - -function get_api_return_type(return_types::Dict{Regex,Type}, ::Nothing, response_data::String) - # this is the async case, where we do not have the response code yet - # in such cases we look for the 200 response code - return get_api_return_type(return_types, 200, response_data) -end -function get_api_return_type(return_types::Dict{Regex,Type}, response_code::Integer, response_data::String) - default_response_code = 0 - for code in string.([response_code, default_response_code]) - for (re, rt) in return_types - if match(re, code) !== nothing - return rt - end - end - end - # if no specific return type was defined, we assume that: - # - if response code is 2xx, then we make the method call return nothing - # - otherwise we make it throw an ApiException - return (200 <= response_code <=206) ? Nothing : nothing # first(return_types)[2] -end - -function default_debug_hook(type, message) - @info("OpenAPI HTTP transport", type, message) -end - -""" - Client(root::String; - headers::Dict{String,String}=Dict{String,String}(), - get_return_type::Function=get_api_return_type, - long_polling_timeout::Int=DEFAULT_LONGPOLL_TIMEOUT_SECS, - timeout::Int=DEFAULT_TIMEOUT_SECS, - pre_request_hook::Function=noop_pre_request_hook, - escape_path_params::Union{Nothing,Bool}=nothing, - chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing, - verbose::Union{Bool,Function}=false, - ) - -Create a new OpenAPI client context. - -A client context holds common information to be used across APIs. It also holds a connection to the server and uses that across API calls. -The client context needs to be passed as the first parameter of all API calls. - -Parameters: -- `root`: The root URL of the server. This is the base URL that will be used for all API calls. - -Keyword parameters: -- `headers`: A dictionary of HTTP headers to be sent with all API calls. -- `get_return_type`: A function that is called to determine the return type of an API call. This function is called with the following parameters: - - `return_types`: A dictionary of regular expressions and their corresponding return types. The regular expressions are matched against the HTTP status code of the response. - - `response_code`: The HTTP status code of the response. - - `response_data`: The response data as a string. - The function should return the return type to be used for the API call. -- `long_polling_timeout`: The timeout in seconds for long polling requests. This is the time after which the request will be aborted if no data is received from the server. -- `timeout`: The timeout in seconds for all other requests. This is the time after which the request will be aborted if no data is received from the server. -- `pre_request_hook`: A function that is called before every API call. This function must provide two methods: - - `pre_request_hook(ctx::Ctx)`: This method is called before every API call. It is passed the context object that will be used for the API call. The function should return the context object to be used for the API call. - - `pre_request_hook(resource_path::AbstractString, body::Any, headers::Dict{String,String})`: This method is called before every API call. It is passed the resource path, request body and request headers that will be used for the API call. The function should return those after making any modifications to them. -- `escape_path_params`: Whether the path parameters should be escaped before being used in the URL. This is useful if the path parameters contain characters that are not allowed in URLs or contain path separators themselves. -- `chunk_reader_type`: The type of chunk reader to be used for streaming responses. This can be one of `LineChunkReader`, `JSONChunkReader` or `RFC7464ChunkReader`. If not specified, then the type is automatically determined based on the return type of the API call. -- `verbose`: Can be set either to a boolean or a function. - - If set to true, then the client will log all HTTP requests and responses. - - If set to a function, then that function will be called with the following parameters: - - `type`: The type of message. - - `message`: The message to be logged. - -""" -struct Client - root::String - headers::Dict{String,String} - get_return_type::Function # user provided hook to get return type from response data - clntoptions::Dict{Symbol,Any} - downloader::Downloader - timeout::Ref{Int} - pre_request_hook::Function # user provided hook to modify the request before it is sent - escape_path_params::Union{Nothing,Bool} - chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}} - long_polling_timeout::Int - request_interrupt_supported::Bool - - function Client(root::String; - headers::Dict{String,String}=Dict{String,String}(), - get_return_type::Function=get_api_return_type, - long_polling_timeout::Int=DEFAULT_LONGPOLL_TIMEOUT_SECS, - timeout::Int=DEFAULT_TIMEOUT_SECS, - pre_request_hook::Function=noop_pre_request_hook, - escape_path_params::Union{Nothing,Bool}=nothing, - chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing, - verbose::Union{Bool,Function}=false, - ) - clntoptions = Dict{Symbol,Any}(:throw=>false) - if isa(verbose, Bool) - clntoptions[:verbose] = verbose - elseif isa(verbose, Function) - clntoptions[:debug] = verbose - end - downloader = Downloads.Downloader() - downloader.easy_hook = (easy, opts) -> begin - Downloads.Curl.setopt(easy, LibCURL.CURLOPT_LOW_SPEED_TIME, long_polling_timeout) - # disable ALPN to support servers that enable both HTTP/2 and HTTP/1.1 on same port - Downloads.Curl.setopt(easy, LibCURL.CURLOPT_SSL_ENABLE_ALPN, 0) - end - interruptable = request_supports_interrupt() - new(root, headers, get_return_type, clntoptions, downloader, Ref{Int}(timeout), pre_request_hook, escape_path_params, chunk_reader_type, long_polling_timeout, interruptable) - end -end +include("client/clienttypes.jl") +include("client/chunk_readers.jl") +include("client/httplibs/httplibs.jl") """ set_user_agent(client::Client, ua::String) @@ -229,36 +67,6 @@ function with_timeout(fn, api::APIClientImpl, timeout::Integer) end end -struct Ctx - client::Client - method::String - return_types::Dict{Regex,Type} - resource::String - auth::Vector{String} - - path::Dict{String,String} - query::Dict{String,String} - header::Dict{String,String} - form::Dict{String,String} - file::Dict{String,String} - body::Any - timeout::Int - curl_mime_upload::Ref{Any} - pre_request_hook::Function - escape_path_params::Bool - chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}} - - function Ctx(client::Client, method::String, return_types::Dict{Regex,Type}, resource::String, auth, body=nothing; - timeout::Int=client.timeout[], - pre_request_hook::Function=client.pre_request_hook, - escape_path_params::Bool=something(client.escape_path_params, true), - chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=client.chunk_reader_type, - ) - resource = client.root * resource - headers = copy(client.headers) - new(client, method, return_types, resource, auth, Dict{String,String}(), Dict{String,String}(), headers, Dict{String,String}(), Dict{String,String}(), body, timeout, Ref{Any}(nothing), pre_request_hook, escape_path_params, chunk_reader_type) - end -end is_json_mime(mime::T) where {T <: AbstractString} = ("*/*" == mime) || occursin(r"(?i)application/json(;.*)?", mime) || occursin(r"(?i)application/(.*)\+json(;.*)?", mime) @@ -328,107 +136,13 @@ function set_param(params::Dict{String,String}, name::String, value; collection_ end end -function prep_args(ctx::Ctx) - kwargs = copy(ctx.client.clntoptions) - kwargs[:downloader] = ctx.client.downloader # use the default downloader for most cases - - isempty(ctx.file) && (ctx.body === nothing) && isempty(ctx.form) && !("Content-Length" in keys(ctx.header)) && (ctx.header["Content-Length"] = "0") - headers = ctx.header - body = nothing +prep_args(ctx::Ctx) = prep_args(Val(ctx.client.httplib), ctx) - header_pairs = [convert(HTTP.Header, p) for p in headers] - content_type_set = HTTP.header(header_pairs, "Content-Type", nothing) - if !isnothing(content_type_set) - content_type_set = lowercase(content_type_set) - end - - if !isempty(ctx.form) - if !isnothing(content_type_set) && content_type_set !== "multipart/form-data" && content_type_set !== "application/x-www-form-urlencoded" - throw(OpenAPIException("Content type already set to $content_type_set. To send form data, it must be multipart/form-data or application/x-www-form-urlencoded.")) - end - if isnothing(content_type_set) - if !isempty(ctx.file) - headers["Content-Type"] = content_type_set = "multipart/form-data" - else - headers["Content-Type"] = content_type_set = "application/x-www-form-urlencoded" - end - end - if content_type_set == "application/x-www-form-urlencoded" - body = URIs.escapeuri(ctx.form) - else - # we shall process it along with file uploads where we send multipart/form-data - end - end - - if !isempty(ctx.file) || (content_type_set == "multipart/form-data") - if !isnothing(content_type_set) && content_type_set !== "multipart/form-data" - throw(OpenAPIException("Content type already set to $content_type_set. To send file, it must be multipart/form-data.")) - end - - if isnothing(content_type_set) - headers["Content-Type"] = content_type_set = "multipart/form-data" - end - - # use a separate downloader for file uploads - # until we have something like https://github.com/JuliaLang/Downloads.jl/pull/148 - downloader = Downloads.Downloader() - downloader.easy_hook = (easy, opts) -> begin - Downloads.Curl.setopt(easy, LibCURL.CURLOPT_LOW_SPEED_TIME, ctx.client.long_polling_timeout) - mime = ctx.curl_mime_upload[] - if mime === nothing - mime = LibCURL.curl_mime_init(easy.handle) - ctx.curl_mime_upload[] = mime - end - for (_k,_v) in ctx.file - part = LibCURL.curl_mime_addpart(mime) - LibCURL.curl_mime_name(part, _k) - LibCURL.curl_mime_filedata(part, _v) - # TODO: make provision to call curl_mime_type in future? - end - for (_k,_v) in ctx.form - # add multipart sections for form data as well - part = LibCURL.curl_mime_addpart(mime) - LibCURL.curl_mime_name(part, _k) - LibCURL.curl_mime_data(part, _v, length(_v)) - end - Downloads.Curl.setopt(easy, LibCURL.CURLOPT_MIMEPOST, mime) - end - kwargs[:downloader] = downloader - end - - if ctx.body !== nothing - (isempty(ctx.form) && isempty(ctx.file)) || throw(OpenAPIException("Can not send both form-encoded data and a request body")) - if is_json_mime(something(content_type_set, "application/json")) - body = to_json(ctx.body) - elseif ("application/x-www-form-urlencoded" == content_type_set) && isa(ctx.body, Dict) - body = URIs.escapeuri(ctx.body) - elseif isa(ctx.body, APIModel) && isnothing(content_type_set) - headers["Content-Type"] = content_type_set = "application/json" - body = to_json(ctx.body) - else - body = ctx.body - end - end - - kwargs[:timeout] = ctx.timeout - kwargs[:method] = uppercase(ctx.method) - kwargs[:headers] = headers - - return body, kwargs -end - -function header(resp::Downloads.Response, name::AbstractString, defaultval::AbstractString) - for (n,v) in resp.headers - (lowercase(n) == lowercase(name)) && (return v) - end - return defaultval -end - -response(::Type{Nothing}, resp::Downloads.Response, body) = nothing::Nothing -response(::Type{T}, resp::Downloads.Response, body) where {T <: Real} = response(T, body)::T -response(::Type{T}, resp::Downloads.Response, body) where {T <: String} = response(T, body)::T -function response(::Type{T}, resp::Downloads.Response, body) where {T} - ctype = header(resp, "Content-Type", "application/json") +response(::Type{Nothing}, resp::HTTPLibResponse, body) = nothing::Nothing +response(::Type{T}, resp::HTTPLibResponse, body) where {T <: Real} = response(T, body)::T +response(::Type{T}, resp::HTTPLibResponse, body) where {T <: String} = response(T, body)::T +function response(::Type{T}, resp::HTTPLibResponse, body) where {T} + ctype = get_response_header(resp, "Content-Type", "application/json") response(T, is_json_mime(ctype), body)::T end response(::Type{T}, ::Nothing, body) where {T} = response(T, true, body) @@ -449,75 +163,6 @@ response(::Type{T}, data::Dict{String,Any}) where {T} = from_json(T, data)::T response(::Type{T}, data::Dict{String,Any}) where {T<:Dict} = convert(T, data) response(::Type{Vector{T}}, data::Vector{V}) where {T,V} = T[response(T, v) for v in data] -struct LineChunkReader <: AbstractChunkReader - buffered_input::Base.BufferStream -end - -function Base.iterate(iter::LineChunkReader, _state=nothing) - if eof(iter.buffered_input) - return nothing - else - out = IOBuffer() - while !eof(iter.buffered_input) - byte = read(iter.buffered_input, UInt8) - (byte == codepoint('\n')) && break - write(out, byte) - end - return (take!(out), iter) - end -end - -struct JSONChunkReader <: AbstractChunkReader - buffered_input::Base.BufferStream -end - -function Base.iterate(iter::JSONChunkReader, _state=nothing) - if eof(iter.buffered_input) - return nothing - else - # read all whitespaces - while !eof(iter.buffered_input) - byte = peek(iter.buffered_input, UInt8) - if isspace(Char(byte)) - read(iter.buffered_input, UInt8) - else - break - end - end - eof(iter.buffered_input) && return nothing - valid_json = JSON.parse(iter.buffered_input) - bytes = convert(Vector{UInt8}, codeunits(JSON.json(valid_json))) - return (bytes, iter) - end -end - -# Ref: https://www.rfc-editor.org/rfc/rfc7464.html -const RFC7464_RECORD_SEPARATOR = UInt8(0x1E) -struct RFC7464ChunkReader <: AbstractChunkReader - buffered_input::Base.BufferStream -end - -function Base.iterate(iter::RFC7464ChunkReader, _state=nothing) - if eof(iter.buffered_input) - return nothing - else - out = IOBuffer() - while !eof(iter.buffered_input) - byte = read(iter.buffered_input, UInt8) - if byte == RFC7464_RECORD_SEPARATOR - bytes = take!(out) - if isnothing(_state) || !isempty(bytes) - return (bytes, iter) - end - else - write(out, byte) - end - end - bytes = take!(out) - return (bytes, iter) - end -end - noop_pre_request_hook(ctx::Ctx) = ctx noop_pre_request_hook(resource_path::AbstractString, body::Any, headers::Dict{String,String}) = (resource_path, body, headers) @@ -542,112 +187,12 @@ function do_request(ctx::Ctx, stream::Bool=false; stream_to::Union{Channel,Nothi resource_path, body, headers = ctx.pre_request_hook(resource_path, body, kwargs[:headers]) kwargs[:headers] = headers - if body !== nothing - input = PipeBuffer() - write(input, body) - else - input = nothing - end - if stream @assert stream_to !== nothing end - resp = nothing output = Base.BufferStream() - - try - if stream - interrupt = nothing - if ctx.client.request_interrupt_supported - kwargs[:interrupt] = interrupt = Base.Event() - end - @sync begin - download_task = @async begin - try - resp = Downloads.request(resource_path; - input=input, - output=output, - kwargs... - ) - catch ex - # If request method does not support interrupt natively, InterrptException is used to - # signal the download task to stop. Otherwise, InterrptException is not handled and is rethrown. - # Any exception other than InterruptException is rethrown always. - if ctx.client.request_interrupt_supported || !isa(ex, InterruptException) - @error("exception invoking request", exception=(ex,catch_backtrace())) - rethrow() - end - finally - close(output) - end - end - @async begin - try - if isnothing(ctx.chunk_reader_type) - default_return_type = ctx.client.get_return_type(ctx.return_types, nothing, "") - readerT = default_return_type <: APIModel ? JSONChunkReader : LineChunkReader - else - readerT = ctx.chunk_reader_type - end - for chunk in readerT(output) - return_type = ctx.client.get_return_type(ctx.return_types, nothing, String(copy(chunk))) - data = response(return_type, resp, chunk) - put!(stream_to, data) - end - catch ex - if !isa(ex, InvalidStateException) && isopen(stream_to) - @error("exception reading chunk", exception=(ex,catch_backtrace())) - rethrow() - end - finally - close(stream_to) - end - end - @async begin - interrupted = false - while isopen(stream_to) - try - wait(stream_to) - yield() - catch ex - isa(ex, InvalidStateException) || rethrow(ex) - interrupted = true - if !istaskdone(download_task) - # If the download task is still running, interrupt it. - # If it supports interrupt natively, then use event to signal it. - # Otherwise, throw an InterruptException to stop the download task. - if ctx.client.request_interrupt_supported - notify(interrupt) - else - schedule(download_task, InterruptException(), error=true) - end - end - end - end - if !interrupted && !istaskdone(download_task) - if ctx.client.request_interrupt_supported - notify(interrupt) - else - schedule(download_task, InterruptException(), error=true) - end - end - end - end - else - resp = Downloads.request(resource_path; - input=input, - output=output, - kwargs... - ) - close(output) - end - finally - if ctx.curl_mime_upload[] !== nothing - LibCURL.curl_mime_free(ctx.curl_mime_upload[]) - ctx.curl_mime_upload[] = nothing - end - end + resp, output = do_request(Val(ctx.client.httplib), ctx, resource_path, body, output, kwargs, stream; stream_to=stream_to) return resp, output end @@ -661,7 +206,7 @@ function exec(ctx::Ctx, stream_to::Union{Channel,Nothing}=nothing) throw(InvocationException("request was interrupted")) end - if isa(resp, Downloads.RequestError) + if isa(resp, HTTPLibError) throw(ApiException(resp)) end @@ -781,6 +326,7 @@ is_longpoll_timeout(ex) = false is_longpoll_timeout(ex::TaskFailedException) = is_longpoll_timeout(ex.task.exception) is_longpoll_timeout(ex::CompositeException) = any(is_longpoll_timeout, ex.exceptions) function is_longpoll_timeout(ex::ApiException) + # All client library wrappers ensure that the reason string format is the same for longpoll timeouts ex.status == 200 && match(r"Operation timed out after \d+ milliseconds with \d+ bytes received", ex.reason) !== nothing end @@ -851,22 +397,22 @@ end const content_disposition_re = r"filename\*?=['\"]?(?:UTF-\d['\"]*)?([^;\r\n\"']*)['\"]?;?" """ - extract_filename(resp::Downloads.Response)::String + extract_filename(resp)::String Extracts the filename from the `Content-Disposition` header of the HTTP response. If not found, then creates a filename from the `Content-Type` header. """ extract_filename(resp::ApiResponse) = extract_filename(resp.raw) -function extract_filename(resp::Downloads.Response)::String +function extract_filename(resp::HTTPLibResponse)::String # attempt to extract filename from content-disposition header - content_disposition_str = header(resp, "content-disposition", "") + content_disposition_str = get_response_header(resp, "content-disposition", "") m = match(content_disposition_re, content_disposition_str) if !isnothing(m) && !isempty(m.captures) && !isnothing(m.captures[1]) return m.captures[1] end # attempt to create a filename from content-type header - content_type_str = header(resp, "content-type", "") + content_type_str = get_response_header(resp, "content-type", "") return string("response", extension_from_mime(MIME(content_type_str))) end diff --git a/src/client/chunk_readers.jl b/src/client/chunk_readers.jl new file mode 100644 index 0000000..10d9ccf --- /dev/null +++ b/src/client/chunk_readers.jl @@ -0,0 +1,68 @@ +struct LineChunkReader <: AbstractChunkReader + buffered_input::Base.BufferStream +end + +function Base.iterate(iter::LineChunkReader, _state=nothing) + if eof(iter.buffered_input) + return nothing + else + out = IOBuffer() + while !eof(iter.buffered_input) + byte = read(iter.buffered_input, UInt8) + (byte == codepoint('\n')) && break + write(out, byte) + end + return (take!(out), iter) + end +end + +struct JSONChunkReader <: AbstractChunkReader + buffered_input::Base.BufferStream +end + +function Base.iterate(iter::JSONChunkReader, _state=nothing) + if eof(iter.buffered_input) + return nothing + else + # read all whitespaces + while !eof(iter.buffered_input) + byte = peek(iter.buffered_input, UInt8) + if isspace(Char(byte)) + read(iter.buffered_input, UInt8) + else + break + end + end + eof(iter.buffered_input) && return nothing + valid_json = JSON.parse(iter.buffered_input) + bytes = convert(Vector{UInt8}, codeunits(JSON.json(valid_json))) + return (bytes, iter) + end +end + +# Ref: https://www.rfc-editor.org/rfc/rfc7464.html +const RFC7464_RECORD_SEPARATOR = UInt8(0x1E) +struct RFC7464ChunkReader <: AbstractChunkReader + buffered_input::Base.BufferStream +end + +function Base.iterate(iter::RFC7464ChunkReader, _state=nothing) + if eof(iter.buffered_input) + return nothing + else + out = IOBuffer() + while !eof(iter.buffered_input) + byte = read(iter.buffered_input, UInt8) + if byte == RFC7464_RECORD_SEPARATOR + bytes = take!(out) + if isnothing(_state) || !isempty(bytes) + return (bytes, iter) + end + else + write(out, byte) + end + end + bytes = take!(out) + return (bytes, iter) + end +end diff --git a/src/client/clienttypes.jl b/src/client/clienttypes.jl new file mode 100644 index 0000000..34fdb04 --- /dev/null +++ b/src/client/clienttypes.jl @@ -0,0 +1,224 @@ +abstract type AbstractChunkReader end +abstract type AbstractHTTPLibError end +const HTTPLibResponse = Union{HTTP.Response, Downloads.Response} +const HTTPLibError = Union{Downloads.RequestError, AbstractHTTPLibError} + +# methods to get exception messages out of errors which could be surfaced either as request or response errors +get_message(::HTTPLibError) = "" +get_message(::HTTPLibResponse) = "" +get_response(::HTTPLibError) = nothing +get_status(::HTTPLibError) = 0 + +# collection formats (OpenAPI v2) +# TODO: OpenAPI v3 has style and explode options instead of collection formats, which are yet to be supported +# TODO: Examine whether multi is now supported +const COLL_MULTI = "multi" # (legacy) aliased to CSV, as multi is not supported by Requests.jl (https://github.com/JuliaWeb/Requests.jl/issues/140) +const COLL_PIPES = "pipes" +const COLL_SSV = "ssv" +const COLL_TSV = "tsv" +const COLL_CSV = "csv" +const COLL_DLM = Dict{String,String}([COLL_PIPES=>"|", COLL_SSV=>" ", COLL_TSV=>"\t", COLL_CSV=>",", COLL_MULTI=>","]) + +const DEFAULT_TIMEOUT_SECS = 5*60 +const DEFAULT_LONGPOLL_TIMEOUT_SECS = 15*60 + +const HTTPLib = ( + HTTP = :http, + Downloads = :downloads +) + +struct ApiException <: Exception + status::Int + reason::String + resp::Union{Nothing, HTTPLibResponse} + error::Union{Nothing, HTTPLibError} + + function ApiException(error::HTTPLibError; reason::String="") + isempty(reason) && (reason = get_message(error)) + resp = get_response(error) + status = get_status(error) + new(status, reason, resp, error) + end +end + +""" + ApiResponse + +Represents the HTTP API response from the server. This is returned as the second return value from all API calls. + +Properties available: +- `status`: the HTTP status code +- `message`: the HTTP status message +- `headers`: the HTTP headers +- `raw`: the raw response from the HTTP library used +""" +struct ApiResponse + raw::HTTPLibResponse +end + +get_response_property(raw::HTTPLibResponse, name::Symbol) = getproperty(raw, name) +function Base.getproperty(resp::ApiResponse, name::Symbol) + raw = getfield(resp, :raw) + if name in (:status, :message, :headers) + return get_response_property(raw, name) + else + return getfield(resp, name) + end +end + + +function get_api_return_type(return_types::Dict{Regex,Type}, ::Nothing, response_data::String) + # this is the async case, where we do not have the response code yet + # in such cases we look for the 200 response code + return get_api_return_type(return_types, 200, response_data) +end +function get_api_return_type(return_types::Dict{Regex,Type}, response_code::Integer, response_data::String) + default_response_code = 0 + for code in string.([response_code, default_response_code]) + for (re, rt) in return_types + if match(re, code) !== nothing + return rt + end + end + end + # if no specific return type was defined, we assume that: + # - if response code is 2xx, then we make the method call return nothing + # - otherwise we make it throw an ApiException + return (200 <= response_code <=206) ? Nothing : nothing # first(return_types)[2] +end + +function default_debug_hook(type, message) + @info("OpenAPI HTTP transport", type, message) +end + +""" + Client(root::String; + headers::Dict{String,String}=Dict{String,String}(), + get_return_type::Function=get_api_return_type, + long_polling_timeout::Int=DEFAULT_LONGPOLL_TIMEOUT_SECS, + timeout::Int=DEFAULT_TIMEOUT_SECS, + pre_request_hook::Function=noop_pre_request_hook, + escape_path_params::Union{Nothing,Bool}=nothing, + chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing, + verbose::Union{Bool,Function}=false, + httplib::Symbol=HTTPLib.Downloads, + ) + +Create a new OpenAPI client context. + +A client context holds common information to be used across APIs. It also holds a connection to the server and uses that across API calls. +The client context needs to be passed as the first parameter of all API calls. + +Parameters: +- `root`: The root URL of the server. This is the base URL that will be used for all API calls. + +Keyword parameters: +- `headers`: A dictionary of HTTP headers to be sent with all API calls. +- `get_return_type`: A function that is called to determine the return type of an API call. This function is called with the following parameters: + - `return_types`: A dictionary of regular expressions and their corresponding return types. The regular expressions are matched against the HTTP status code of the response. + - `response_code`: The HTTP status code of the response. + - `response_data`: The response data as a string. + The function should return the return type to be used for the API call. +- `long_polling_timeout`: The timeout in seconds for long polling requests. This is the time after which the request will be aborted if no data is received from the server. +- `timeout`: The timeout in seconds for all other requests. This is the time after which the request will be aborted if no data is received from the server. +- `pre_request_hook`: A function that is called before every API call. This function must provide two methods: + - `pre_request_hook(ctx::Ctx)`: This method is called before every API call. It is passed the context object that will be used for the API call. The function should return the context object to be used for the API call. + - `pre_request_hook(resource_path::AbstractString, body::Any, headers::Dict{String,String})`: This method is called before every API call. It is passed the resource path, request body and request headers that will be used for the API call. The function should return those after making any modifications to them. +- `escape_path_params`: Whether the path parameters should be escaped before being used in the URL. This is useful if the path parameters contain characters that are not allowed in URLs or contain path separators themselves. +- `chunk_reader_type`: The type of chunk reader to be used for streaming responses. This can be one of `LineChunkReader`, `JSONChunkReader` or `RFC7464ChunkReader`. If not specified, then the type is automatically determined based on the return type of the API call. +- `verbose`: Can be set either to a boolean or a function (function support depends on the HTTP library). + - If set to true, then the client will log all HTTP requests and responses. + - If set to a function (only supported with Downloads.jl backend), then that function will be called with the following parameters: + - `type`: The type of message. + - `message`: The message to be logged. + - Note: When using HTTP.jl backend (`httplib=OpenAPI.HTTPLib.HTTP`), the `verbose` parameter must be a boolean. +- `httplib`: The HTTP client library to use for making requests. Can be `OpenAPI.HTTPLib.Downloads` (default) for Downloads.jl or `OpenAPI.HTTPLib.HTTP` for HTTP.jl. + +""" +struct Client + root::String + headers::Dict{String,String} + get_return_type::Function # user provided hook to get return type from response data + clntoptions::Dict{Symbol,Any} + downloader::Union{Nothing,Downloader} + timeout::Ref{Int} + pre_request_hook::Function # user provided hook to modify the request before it is sent + escape_path_params::Union{Nothing,Bool} + chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}} + long_polling_timeout::Int + request_interrupt_supported::Bool + httplib::Symbol # which http implementation to use + + function Client(root::String; + headers::Dict{String,String}=Dict{String,String}(), + get_return_type::Function=get_api_return_type, + long_polling_timeout::Int=DEFAULT_LONGPOLL_TIMEOUT_SECS, + timeout::Int=DEFAULT_TIMEOUT_SECS, + pre_request_hook::Function=noop_pre_request_hook, + escape_path_params::Union{Nothing,Bool}=nothing, + chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing, + verbose::Union{Bool,Function}=false, + httplib::Symbol=:http, + ) + # Validate library choice + if httplib ∉ values(HTTPLib) + throw(ArgumentError("Invalid httplib: $httplib")) + end + + clntoptions = Dict{Symbol,Any}(:throw=>false) + if isa(verbose, Bool) + clntoptions[:verbose] = verbose + elseif isa(verbose, Function) + if httplib === HTTPLib.HTTP + throw(ArgumentError("With HTTP.jl, `verbose` can only be a boolean")) + end + clntoptions[:debug] = verbose + end + + if httplib === HTTPLib.HTTP + downloader = nothing + interruptable = false + else + downloader = Downloads.Downloader() + downloader.easy_hook = (easy, opts) -> begin + Downloads.Curl.setopt(easy, LibCURL.CURLOPT_LOW_SPEED_TIME, long_polling_timeout) + # disable ALPN to support servers that enable both HTTP/2 and HTTP/1.1 on same port + Downloads.Curl.setopt(easy, LibCURL.CURLOPT_SSL_ENABLE_ALPN, 0) + end + + interruptable = request_supports_interrupt() + end + new(root, headers, get_return_type, clntoptions, downloader, Ref{Int}(timeout), pre_request_hook, escape_path_params, chunk_reader_type, long_polling_timeout, interruptable, httplib) + end +end + +struct Ctx + client::Client + method::String + return_types::Dict{Regex,Type} + resource::String + auth::Vector{String} + + path::Dict{String,String} + query::Dict{String,String} + header::Dict{String,String} + form::Dict{String,String} + file::Dict{String,String} + body::Any + timeout::Int + curl_mime_upload::Ref{Any} + pre_request_hook::Function + escape_path_params::Bool + chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}} + + function Ctx(client::Client, method::String, return_types::Dict{Regex,Type}, resource::String, auth, body=nothing; + timeout::Int=client.timeout[], + pre_request_hook::Function=client.pre_request_hook, + escape_path_params::Bool=something(client.escape_path_params, true), + chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=client.chunk_reader_type, + ) + resource = client.root * resource + headers = copy(client.headers) + new(client, method, return_types, resource, auth, Dict{String,String}(), Dict{String,String}(), headers, Dict{String,String}(), Dict{String,String}(), body, timeout, Ref{Any}(nothing), pre_request_hook, escape_path_params, chunk_reader_type) + end +end diff --git a/src/client/httplibs/httplibs.jl b/src/client/httplibs/httplibs.jl new file mode 100644 index 0000000..2fda8e2 --- /dev/null +++ b/src/client/httplibs/httplibs.jl @@ -0,0 +1,60 @@ +# ============================================================================= +# HTTP Backend Interface Contract +# ============================================================================= +# +# Each HTTP backend implementation must provide the following functions: +# +# 1. Request Preparation (via Val dispatch) +# prep_args(::Val{:backend_symbol}, ctx::Ctx) -> (body, kwargs) +# +# Prepares request body and HTTP library-specific options from the context. +# - Handles content-type detection and setting +# - Processes form data and file uploads +# - Converts body to appropriate format (JSON, form-encoded, etc.) +# - Returns tuple of (body, kwargs) for the HTTP library +# +# 2. Request Execution (via Val dispatch) +# do_request(::Val{:backend_symbol}, ctx::Ctx, resource_path::String, +# body, output, kwargs, stream::Bool; stream_to::Union{Channel,Nothing}) +# -> (response, output) +# +# Executes the HTTP request using the backend library. +# - Performs synchronous or streaming request based on `stream` flag +# - Handles task management for streaming responses +# - Returns tuple of (response, output) or (error, output) on failure +# +# 3. Response Header Access (via Type dispatch) +# get_response_header(resp::BackendResponse, name::AbstractString, +# defaultval::AbstractString) -> String +# +# Retrieves a header value from the backend-specific response object. +# Case-insensitive header name matching required. +# +# 4. Error Information Extraction (via Type dispatch) +# get_message(error::BackendError) -> String +# get_response(error::BackendError) -> Union{Nothing, BackendResponse} +# get_status(error::BackendError) -> Int +# +# Extracts error information from backend-specific error objects. +# - get_message: Human-readable error description +# - get_response: Associated response object (if available) +# - get_status: HTTP status code (0 if no response available) +# +# 5. Response Property Access (via Type dispatch, optional) +# get_response_property(raw::BackendResponse, name::Symbol) -> Any +# +# Provides access to backend-specific response properties. +# Only needed if backend response type doesn't directly support +# required properties (status, message, headers). +# +# ============================================================================= +# Available Backend Implementations +# ============================================================================= +# +# :downloads (OpenAPI.HTTPLib.Downloads) - Uses Downloads.jl from Julia stdlib +# :http (OpenAPI.HTTPLib.HTTP) - Uses HTTP.jl from JuliaWeb ecosystem +# +# ============================================================================= + +include("juliaweb_http.jl") +include("julialang_downloads.jl") \ No newline at end of file diff --git a/src/client/httplibs/julialang_downloads.jl b/src/client/httplibs/julialang_downloads.jl new file mode 100644 index 0000000..f711b14 --- /dev/null +++ b/src/client/httplibs/julialang_downloads.jl @@ -0,0 +1,242 @@ +# ============================================================================= +# Downloads.jl Backend Implementation +# ============================================================================= +# This file implements the HTTP client backend using the Downloads.jl library. +# +# Dependencies: +# - Downloads (stdlib): Primary HTTP client library +# - LibCURL: For low-level cURL operations (file uploads, MIME handling) +# - URIs: For URI escaping and query parameter handling +# +# Public Interface (via Val dispatch): +# - prep_args(::Val{:downloads}, ctx::Ctx) +# - do_request(::Val{:downloads}, ctx, ...) +# +# Type-Specific Methods: +# - get_response_header(::Downloads.Response, ...) +# - get_message(::Downloads.RequestError) +# - get_response(::Downloads.RequestError) +# - get_status(::Downloads.RequestError) +# ============================================================================= + +function _downloads_get_content_type(headers::Dict{String,String}) + for (name, value) in headers + if lowercase(name) == "content-type" + return value + end + end + return nothing +end + +function prep_args(::Val{:downloads}, ctx::Ctx) + kwargs = copy(ctx.client.clntoptions) + kwargs[:downloader] = ctx.client.downloader # use the default downloader for most cases + + isempty(ctx.file) && (ctx.body === nothing) && isempty(ctx.form) && !("Content-Length" in keys(ctx.header)) && (ctx.header["Content-Length"] = "0") + headers = ctx.header + body = nothing + + content_type_set = _downloads_get_content_type(headers) + if !isnothing(content_type_set) + content_type_set = lowercase(content_type_set) + end + + if !isempty(ctx.form) + if !isnothing(content_type_set) && content_type_set !== "multipart/form-data" && content_type_set !== "application/x-www-form-urlencoded" + throw(OpenAPIException("Content type already set to $content_type_set. To send form data, it must be multipart/form-data or application/x-www-form-urlencoded.")) + end + if isnothing(content_type_set) + if !isempty(ctx.file) + headers["Content-Type"] = content_type_set = "multipart/form-data" + else + headers["Content-Type"] = content_type_set = "application/x-www-form-urlencoded" + end + end + if content_type_set == "application/x-www-form-urlencoded" + body = URIs.escapeuri(ctx.form) + else + # we shall process it along with file uploads where we send multipart/form-data + end + end + + if !isempty(ctx.file) || (content_type_set == "multipart/form-data") + if !isnothing(content_type_set) && content_type_set !== "multipart/form-data" + throw(OpenAPIException("Content type already set to $content_type_set. To send file, it must be multipart/form-data.")) + end + + if isnothing(content_type_set) + headers["Content-Type"] = content_type_set = "multipart/form-data" + end + + # use a separate downloader for file uploads + # until we have something like https://github.com/JuliaLang/Downloads.jl/pull/148 + downloader = Downloads.Downloader() + downloader.easy_hook = (easy, opts) -> begin + Downloads.Curl.setopt(easy, LibCURL.CURLOPT_LOW_SPEED_TIME, ctx.client.long_polling_timeout) + mime = ctx.curl_mime_upload[] + if mime === nothing + mime = LibCURL.curl_mime_init(easy.handle) + ctx.curl_mime_upload[] = mime + end + for (_k,_v) in ctx.file + part = LibCURL.curl_mime_addpart(mime) + LibCURL.curl_mime_name(part, _k) + LibCURL.curl_mime_filedata(part, _v) + # TODO: make provision to call curl_mime_type in future? + end + for (_k,_v) in ctx.form + # add multipart sections for form data as well + part = LibCURL.curl_mime_addpart(mime) + LibCURL.curl_mime_name(part, _k) + LibCURL.curl_mime_data(part, _v, length(_v)) + end + Downloads.Curl.setopt(easy, LibCURL.CURLOPT_MIMEPOST, mime) + end + kwargs[:downloader] = downloader + end + + if ctx.body !== nothing + (isempty(ctx.form) && isempty(ctx.file)) || throw(OpenAPIException("Can not send both form-encoded data and a request body")) + if is_json_mime(something(content_type_set, "application/json")) + body = to_json(ctx.body) + elseif ("application/x-www-form-urlencoded" == content_type_set) && isa(ctx.body, Dict) + body = URIs.escapeuri(ctx.body) + elseif isa(ctx.body, APIModel) && isnothing(content_type_set) + headers["Content-Type"] = content_type_set = "application/json" + body = to_json(ctx.body) + else + body = ctx.body + end + end + + kwargs[:timeout] = ctx.timeout + kwargs[:method] = uppercase(ctx.method) + kwargs[:headers] = headers + + return body, kwargs +end + +function get_response_header(resp::Downloads.Response, name::AbstractString, defaultval::AbstractString) + for (n,v) in resp.headers + (lowercase(n) == lowercase(name)) && (return v) + end + return defaultval +end + +function get_message(error::Downloads.RequestError) + reason = error.message + isempty(reason) && (reason = error.response.message) + return reason +end + +function get_response(error::Downloads.RequestError) + return error.response +end + +function get_status(error::Downloads.RequestError) + return error.response.status +end + +function do_request(::Val{:downloads}, ctx::Ctx, resource_path::String, body, output, kwargs, stream::Bool=false; stream_to::Union{Channel,Nothing}=nothing) + resp = nothing + try + input = nothing + if body !== nothing + input = PipeBuffer() + write(input, body) + end + + if stream + interrupt = nothing + if ctx.client.request_interrupt_supported + kwargs[:interrupt] = interrupt = Base.Event() + end + @sync begin + download_task = @async begin + try + resp = Downloads.request(resource_path; + input=input, + output=output, + kwargs... + ) + catch ex + # If request method does not support interrupt natively, InterrptException is used to + # signal the download task to stop. Otherwise, InterrptException is not handled and is rethrown. + # Any exception other than InterruptException is rethrown always. + if ctx.client.request_interrupt_supported || !isa(ex, InterruptException) + @error("exception invoking request", exception=(ex,catch_backtrace())) + rethrow() + end + finally + close(output) + end + end + @async begin + try + if isnothing(ctx.chunk_reader_type) + default_return_type = ctx.client.get_return_type(ctx.return_types, nothing, "") + readerT = default_return_type <: APIModel ? JSONChunkReader : LineChunkReader + else + readerT = ctx.chunk_reader_type + end + for chunk in readerT(output) + return_type = ctx.client.get_return_type(ctx.return_types, nothing, String(copy(chunk))) + data = response(return_type, resp, chunk) + put!(stream_to, data) + end + catch ex + if !isa(ex, InvalidStateException) && isopen(stream_to) + @error("exception reading chunk", exception=(ex,catch_backtrace())) + rethrow() + end + finally + close(stream_to) + end + end + @async begin + interrupted = false + while isopen(stream_to) + try + wait(stream_to) + yield() + catch ex + isa(ex, InvalidStateException) || rethrow(ex) + interrupted = true + if !istaskdone(download_task) + # If the download task is still running, interrupt it. + # If it supports interrupt natively, then use event to signal it. + # Otherwise, throw an InterruptException to stop the download task. + if ctx.client.request_interrupt_supported + notify(interrupt) + else + schedule(download_task, InterruptException(), error=true) + end + end + end + end + if !interrupted && !istaskdone(download_task) + if ctx.client.request_interrupt_supported + notify(interrupt) + else + schedule(download_task, InterruptException(), error=true) + end + end + end + end + else + resp = Downloads.request(resource_path; + input=input, + output=output, + kwargs... + ) + close(output) + end + finally + if ctx.curl_mime_upload[] !== nothing + LibCURL.curl_mime_free(ctx.curl_mime_upload[]) + ctx.curl_mime_upload[] = nothing + end + end + + return resp, output +end diff --git a/src/client/httplibs/juliaweb_http.jl b/src/client/httplibs/juliaweb_http.jl new file mode 100644 index 0000000..a856b75 --- /dev/null +++ b/src/client/httplibs/juliaweb_http.jl @@ -0,0 +1,284 @@ +# ============================================================================= +# HTTP.jl Backend Implementation +# ============================================================================= +# This file implements the HTTP client backend using the HTTP.jl (JuliaWeb) library. +# +# Dependencies: +# - HTTP: Primary HTTP client library from JuliaWeb +# - URIs: For URI escaping and query parameter handling +# +# Public Interface (via Val dispatch): +# - prep_args(::Val{:http}, ctx::Ctx) +# - do_request(::Val{:http}, ctx, ...) +# +# Type-Specific Methods: +# - get_response_property(::HTTP.Response, ...) +# - get_response_header(::HTTP.Response, ...) +# - get_message(::HTTPRequestError) +# - get_response(::HTTPRequestError) +# - get_status(::HTTPRequestError) +# +# Custom Types: +# - HTTPRequestError <: AbstractHTTPLibError +# ============================================================================= + +function get_response_property(raw::HTTP.Response, name::Symbol) + if name === :message + return HTTP.Messages.statustext(raw.status) + else + return getproperty(raw, name) + end +end + +function get_response_header(resp::HTTP.Response, name::AbstractString, defaultval::AbstractString) + return HTTP.header(resp, name, defaultval) +end + +struct HTTPRequestError <: AbstractHTTPLibError + message::String + error::HTTP.HTTPError + response::Union{Nothing,HTTP.Response} + + function HTTPRequestError(error::HTTP.TimeoutError, bytesread::Int, response::Union{Nothing,HTTP.Response}) + message = "Operation timed out after $(error.readtimeout*1000) milliseconds with $(bytesread) bytes received" + new(message, error, response) + end + + function HTTPRequestError(error::HTTP.TimeoutError, response::Union{Nothing,HTTP.Response}) + message = "Operation timed out after $(error.readtimeout*1000) milliseconds" + new(message, error, response) + end + + function HTTPRequestError(error::HTTP.ConnectError) + message = if isa(error.error, CapturedException) + string(error.error.ex) + else + string(error.error) + end + new(message, error, nothing) + end + + function HTTPRequestError(error::HTTP.HTTPError) + message = string(error) + new(message, error, nothing) + end +end + +_http_as_request_error(args...) = nothing +_http_as_request_error(ex::HTTP.HTTPError, args...) = return HTTPRequestError(ex) +_http_as_request_error(ex::HTTP.ConnectError, args...) = return HTTPRequestError(ex) +_http_as_request_error(ex::HTTP.TimeoutError, args...) = return HTTPRequestError(ex, args...) +_http_as_request_error(ex::TaskFailedException, args...) = _http_as_request_error(ex.task.exception, args...) + +function _http_as_request_error(ex::CompositeException, args...) + for ex in ex.exceptions + request_error = _http_as_request_error(ex, args...) + if !isnothing(request_error) + return request_error + end + end + return nothing +end + +get_response(error::HTTPRequestError) = error.response +function get_message(error::HTTPRequestError) + return error.message +end +function get_status(error::HTTPRequestError) + if isnothing(error.response) + return 0 + else + return error.response.status + end +end + +function prep_args(::Val{:http}, ctx::Ctx) + kwargs = copy(ctx.client.clntoptions) + + isempty(ctx.file) && (ctx.body === nothing) && isempty(ctx.form) && !("Content-Length" in keys(ctx.header)) && (ctx.header["Content-Length"] = "0") + headers = ctx.header + body = nothing + + header_pairs = [convert(HTTP.Header, p) for p in headers] + content_type_set = HTTP.header(header_pairs, "Content-Type", nothing) + if !isnothing(content_type_set) + content_type_set = lowercase(content_type_set) + end + + if !isempty(ctx.form) + if !isnothing(content_type_set) && content_type_set !== "multipart/form-data" && content_type_set !== "application/x-www-form-urlencoded" + throw(OpenAPIException("Content type already set to $content_type_set. To send form data, it must be multipart/form-data or application/x-www-form-urlencoded.")) + end + if isnothing(content_type_set) + if !isempty(ctx.file) + headers["Content-Type"] = content_type_set = "multipart/form-data" + else + headers["Content-Type"] = content_type_set = "application/x-www-form-urlencoded" + end + end + if content_type_set == "application/x-www-form-urlencoded" + body = URIs.escapeuri(ctx.form) + else + # we shall process it along with file uploads where we send multipart/form-data + end + end + + openhandles = Any[] + try + if !isempty(ctx.file) || (content_type_set == "multipart/form-data") + if !isnothing(content_type_set) && content_type_set !== "multipart/form-data" + throw(OpenAPIException("Content type already set to $content_type_set. To send file, it must be multipart/form-data.")) + end + + body_dict = Dict{String,Any}() + + for (_k,_v) in ctx.file + if isfile(_v) + fhandle = open(_v) + push!(openhandles, fhandle) + body_dict[_k] = fhandle + else + body_dict[_k] = HTTP.Multipart(_k, IOBuffer(_v)) + end + end + + for (_k,_v) in ctx.form + body_dict[_k] = _v + end + body = HTTP.Form(body_dict) + headers["Content-Type"] = content_type_set = HTTP.content_type(body)[2] + end + + if ctx.body !== nothing + (isempty(ctx.form) && isempty(ctx.file)) || throw(OpenAPIException("Can not send both form-encoded data and a request body")) + if is_json_mime(something(content_type_set, "application/json")) + body = to_json(ctx.body) + elseif ("application/x-www-form-urlencoded" == content_type_set) && isa(ctx.body, Dict) + body = URIs.escapeuri(ctx.body) + elseif isa(ctx.body, APIModel) && isnothing(content_type_set) + headers["Content-Type"] = content_type_set = "application/json" + body = to_json(ctx.body) + else + body = ctx.body + end + end + + kwargs[:timeout] = ctx.timeout + kwargs[:method] = uppercase(ctx.method) + kwargs[:headers] = headers + kwargs[:openhandles] = openhandles + catch + # if prep_args fails after opening handles, ensure they are closed + for fhandle in openhandles + close(fhandle) + end + rethrow() + end + + return body, kwargs +end + +function do_request(::Val{:http}, ctx::Ctx, resource_path::String, body, output, kwargs, stream::Bool=false; stream_to::Union{Channel,Nothing}=nothing) + method = kwargs[:method] + timeout_secs = kwargs[:timeout] + openhandles = kwargs[:openhandles] + headers_dict = kwargs[:headers] + headers = [k => v for (k, v) in headers_dict] + bytesread = Ref{Int}(0) + captured_response = Ref{Union{Nothing,HTTP.Response}}(nothing) + + if body === nothing + body = UInt8[] + end + + try + if stream + return _http_streaming_request(ctx, method, resource_path, headers, body, timeout_secs, bytesread, captured_response, output, stream_to) + else + return _http_request(ctx, method, resource_path, headers, body, timeout_secs, bytesread, captured_response, output) + end + catch ex + possible_request_error = _http_as_request_error(ex, bytesread[], captured_response[]) + if !isnothing(possible_request_error) + return possible_request_error, output + else + rethrow(ex) + end + finally + for fhandle in openhandles + close(fhandle) + end + end +end + +function _http_request(ctx, method, url, headers, body, timeout, bytesread, captured_response, output) + captured_response[] = http_response = HTTP.request(method, url, headers, body; + readtimeout=timeout, + connect_timeout=timeout ÷ 2, + retry=false, + redirect=true, + status_exception=false, + verbose=get(ctx.client.clntoptions, :verbose, false)) + + bytesread[] += write(output, http_response.body) + close(output) + + return http_response, output +end + +function _http_streaming_request(ctx, method, url, headers, body, timeout, bytesread, captured_response, output, stream_to) + http_response = nothing + + @sync begin + @async begin + try + HTTP.open(method, url, headers; + readtimeout=timeout, + connect_timeout=timeout ÷ 2, + retry=false, + redirect=true, + status_exception=false, + verbose=get(ctx.client.clntoptions, :verbose, false)) do io + write(io, body) + captured_response[] = http_response = startread(io) + try + while !eof(io) + data = read(io, 8192) # Read 8KB chunks + bytesread[] += write(output, data) + end + finally + close(output) + end + end + catch ex + close(output) + rethrow(ex) + end + end + + @async begin + try + if isnothing(ctx.chunk_reader_type) + default_return_type = ctx.client.get_return_type(ctx.return_types, nothing, "") + readerT = default_return_type <: APIModel ? JSONChunkReader : LineChunkReader + else + readerT = ctx.chunk_reader_type + end + for chunk in readerT(output) + return_type = ctx.client.get_return_type(ctx.return_types, nothing, String(copy(chunk))) + data = response(return_type, nothing, chunk) # resp not available yet in streaming + put!(stream_to, data) + end + catch ex + if !isa(ex, InvalidStateException) && isopen(stream_to) + @error("exception reading chunk", exception=(ex,catch_backtrace())) + rethrow() + end + finally + close(stream_to) + end + end + end + + return http_response, output +end diff --git a/test/client/allany/runtests.jl b/test/client/allany/runtests.jl index 7eaa2a7..374d257 100644 --- a/test/client/allany/runtests.jl +++ b/test/client/allany/runtests.jl @@ -30,10 +30,10 @@ end pet_equals(pet1::OpenAPI.UnionAPIModel, pet2::OpenAPI.UnionAPIModel) = pet_equals(pet1.value, pet2.value) basetype_equals(val1::OpenAPI.UnionAPIModel, val2::OpenAPI.UnionAPIModel) = val1.value == val2.value -function runtests() +function runtests(httplib::Symbol) @testset "allany" begin - @info("AllAnyApi") - client = Client(server) + @info("AllAnyApi ($httplib backend)") + client = Client(server; httplib=httplib) api = M.DefaultApi(client) pet = M.AnyOfMappedPets(mapped_cat) @@ -77,12 +77,15 @@ function runtests() end end -function test_debug() +function test_debug(httplib::Symbol) @testset "stderr verbose mode" begin - @info("stderr verbose mode") - client = Client(server; verbose=true) + @info("stderr verbose mode ($httplib backend)") + client = Client(server; + verbose=true, + httplib=httplib, + ) api = M.DefaultApi(client) - + pipe = Pipe() redirect_stderr(pipe) do pet = M.AnyOfMappedPets(mapped_cat) @@ -92,58 +95,67 @@ function test_debug() out_str = String(readavailable(pipe)) @test occursin("HTTP/1.1 200 OK", out_str) end - @testset "debug log verbose mode" begin - @info("debug log verbose mode") - client = Client(server; verbose=OpenAPI.Clients.default_debug_hook) - api = M.DefaultApi(client) - - pipe = Pipe() - redirect_stderr(pipe) do + + if httplib === :downloads + @testset "debug log verbose mode" begin + @info("debug log verbose mode") + client = Client(server; + verbose=OpenAPI.Clients.default_debug_hook, + httplib=httplib, + ) + api = M.DefaultApi(client) + + pipe = Pipe() + redirect_stderr(pipe) do + pet = M.AnyOfMappedPets(mapped_cat) + api_return, http_resp = echo_anyof_mapped_pets_post(api, pet) + @test pet_equals(api_return, pet) + end + out_str = String(readavailable(pipe)) + @test occursin("HTTP/1.1 200 OK", out_str) + end + @testset "custom verbose function" begin + @info("custom verbose function") + messages = Any[] + client = Client(server; + verbose=(type,message)->push!(messages, (type,message)), + httplib=httplib, + ) + api = M.DefaultApi(client) + pet = M.AnyOfMappedPets(mapped_cat) api_return, http_resp = echo_anyof_mapped_pets_post(api, pet) @test pet_equals(api_return, pet) - end - out_str = String(readavailable(pipe)) - @test occursin("HTTP/1.1 200 OK", out_str) - end - @testset "custom verbose function" begin - @info("custom verbose function") - messages = Any[] - client = Client(server; verbose=(type,message)->push!(messages, (type,message))) - api = M.DefaultApi(client) - - pet = M.AnyOfMappedPets(mapped_cat) - api_return, http_resp = echo_anyof_mapped_pets_post(api, pet) - @test pet_equals(api_return, pet) - data_out = filter(messages) do elem - elem[1] == "DATA OUT" - end - @test !isempty(data_out) - iob = IOBuffer() - for (type, message) in data_out - write(iob, message) - end - data_out_str = String(take!(iob)) - data_out_json = JSON.parse(data_out_str) - @test data_out_json["pet_type"] == "cat" - @test data_out_json["hunts"] == true - @test data_out_json["age"] == 5 - - data_in = filter(messages) do elem - elem[1] == "DATA IN" - end - @test !isempty(data_in) - iob = IOBuffer() - for (type, message) in data_in - write(iob, message) + data_out = filter(messages) do elem + elem[1] == "DATA OUT" + end + @test !isempty(data_out) + iob = IOBuffer() + for (type, message) in data_out + write(iob, message) + end + data_out_str = String(take!(iob)) + data_out_json = JSON.parse(data_out_str) + @test data_out_json["pet_type"] == "cat" + @test data_out_json["hunts"] == true + @test data_out_json["age"] == 5 + + data_in = filter(messages) do elem + elem[1] == "DATA IN" + end + @test !isempty(data_in) + iob = IOBuffer() + for (type, message) in data_in + write(iob, message) + end + data_in_str = String(take!(iob)) + data_in_str = strip(split(data_in_str, "\n")[2]) + data_in_json = JSON.parse(data_in_str) + @test data_in_json["pet_type"] == "cat" + @test data_in_json["hunts"] == true + @test data_in_json["age"] == 5 end - data_in_str = String(take!(iob)) - data_in_str = strip(split(data_in_str, "\n")[2]) - data_in_json = JSON.parse(data_in_str) - @test data_in_json["pet_type"] == "cat" - @test data_in_json["hunts"] == true - @test data_in_json["age"] == 5 end end @@ -156,4 +168,4 @@ function test_http_resp() @test pet_equals(OpenAPI.Clients.from_json(M.Dog, json), dog) end -end # module AllAnyTests \ No newline at end of file +end # module AllAnyTests diff --git a/test/client/openapigenerator_petstore_v3/petstore_test_petapi.jl b/test/client/openapigenerator_petstore_v3/petstore_test_petapi.jl index 363ee0f..7db5dd9 100644 --- a/test/client/openapigenerator_petstore_v3/petstore_test_petapi.jl +++ b/test/client/openapigenerator_petstore_v3/petstore_test_petapi.jl @@ -6,9 +6,9 @@ using OpenAPI using OpenAPI.Clients import OpenAPI.Clients: Client -function test(uri; test_file_upload=false) +function test(uri, httplib; test_file_upload=false) @info("PetApi") - client = Client(uri) + client = Client(uri; httplib=httplib) api = PetApi(client) tag1 = Tag(;id=10, name="juliacat") diff --git a/test/client/openapigenerator_petstore_v3/petstore_test_storeapi.jl b/test/client/openapigenerator_petstore_v3/petstore_test_storeapi.jl index 81e8360..59b3a83 100644 --- a/test/client/openapigenerator_petstore_v3/petstore_test_storeapi.jl +++ b/test/client/openapigenerator_petstore_v3/petstore_test_storeapi.jl @@ -8,9 +8,9 @@ using OpenAPI using OpenAPI.Clients import OpenAPI.Clients: Client -function test(uri) +function test(uri, httplib::Symbol) @info("StoreApi") - client = Client(uri) + client = Client(uri; httplib=httplib) api = StoreApi(client) @info("StoreApi - get_inventory") diff --git a/test/client/openapigenerator_petstore_v3/petstore_test_userapi.jl b/test/client/openapigenerator_petstore_v3/petstore_test_userapi.jl index 99d3111..689ead1 100644 --- a/test/client/openapigenerator_petstore_v3/petstore_test_userapi.jl +++ b/test/client/openapigenerator_petstore_v3/petstore_test_userapi.jl @@ -32,7 +32,7 @@ function test_404(uri) @error("ApiException not thrown") catch ex @test isa(ex, ApiException) - @test startswith(ex.reason, "Could not resolve host") + @test startswith(ex.reason, "Could not resolve host") || startswith(ex.reason, "DNSError") end end @@ -120,9 +120,9 @@ function test_parallel(uri) nothing end -function test(uri) +function test(uri, httplib::Symbol) @info("UserApi") - client = Client(uri) + client = Client(uri; httplib=httplib) api = UserApi(client) @info("UserApi - login_user") diff --git a/test/client/openapigenerator_petstore_v3/runtests.jl b/test/client/openapigenerator_petstore_v3/runtests.jl index 6581f7f..8e63f5d 100644 --- a/test/client/openapigenerator_petstore_v3/runtests.jl +++ b/test/client/openapigenerator_petstore_v3/runtests.jl @@ -10,11 +10,11 @@ include("petstore_test_storeapi.jl") const server = "http://127.0.0.1:8081/v3" -function runtests(; test_file_upload=false) +function runtests(httplib::Symbol; test_file_upload=false) @testset "petstore v3" begin - TestUserApi.test(server) - TestStoreApi.test(server) - TestPetApi.test(server; test_file_upload=test_file_upload) + TestUserApi.test(server, httplib) + TestStoreApi.test(server, httplib) + TestPetApi.test(server, httplib; test_file_upload=test_file_upload) end end end # module OpenAPIGenPetStoreV3Tests diff --git a/test/client/petstore_v2/petstore_test_petapi.jl b/test/client/petstore_v2/petstore_test_petapi.jl index ca34260..a1f608c 100644 --- a/test/client/petstore_v2/petstore_test_petapi.jl +++ b/test/client/petstore_v2/petstore_test_petapi.jl @@ -6,9 +6,9 @@ using OpenAPI using OpenAPI.Clients import OpenAPI.Clients: Client -function test(uri) - @info("PetApi") - client = Client(uri) +function test(uri, httplib::Symbol) + @info("PetApi ($httplib backend)") + client = Client(uri; httplib=httplib) api = PetApi(client) tag1 = Tag(;id=10, name="juliacat") diff --git a/test/client/petstore_v2/petstore_test_storeapi.jl b/test/client/petstore_v2/petstore_test_storeapi.jl index 252a5f9..69ed184 100644 --- a/test/client/petstore_v2/petstore_test_storeapi.jl +++ b/test/client/petstore_v2/petstore_test_storeapi.jl @@ -8,9 +8,9 @@ using OpenAPI using OpenAPI.Clients import OpenAPI.Clients: Client -function test(uri) - @info("StoreApi") - client = Client(uri) +function test(uri, httplib::Symbol) + @info("StoreApi ($httplib backend)") + client = Client(uri; httplib=httplib) api = StoreApi(client) @info("StoreApi - get_inventory") diff --git a/test/client/petstore_v2/petstore_test_userapi.jl b/test/client/petstore_v2/petstore_test_userapi.jl index c2a1ebf..4417da8 100644 --- a/test/client/petstore_v2/petstore_test_userapi.jl +++ b/test/client/petstore_v2/petstore_test_userapi.jl @@ -14,16 +14,16 @@ const TEST_USER1 = "jloac1" const TEST_USER2 = "jloac2" const PRESET_TEST_USER = "user1" # this is the username that works for get user requests (as documented in the test docker container API) -function test_404(uri) - @info("Error handling") - client = Client(uri*"_invalid") +function test_404(uri, httplib::Symbol) + @info("Error handling ($httplib backend)") + client = Client(uri*"_invalid"; httplib=httplib) api = UserApi(client) api_return, http_resp = login_user(api, TEST_USER, "testpassword") @test http_resp.status == 404 @test api_return === nothing - client = Client("http://_invalid/") + client = Client("http://_invalid/"; httplib=httplib) api = UserApi(client) try @@ -31,7 +31,7 @@ function test_404(uri) @error("ApiException not thrown") catch ex @test isa(ex, ApiException) - @test startswith(ex.reason, "Could not resolve host") + @test startswith(ex.reason, "Could not resolve host") || startswith(ex.reason, "DNSError") end end @@ -77,9 +77,9 @@ function test_login_user_hook(resource_path::AbstractString, body::Any, headers: (resource_path, body, headers) end -function test_userhook(uri) - @info("User hook") - client = Client(uri; pre_request_hook=test_login_user_hook) +function test_userhook(uri, httplib::Symbol) + @info("User hook ($httplib backend)") + client = Client(uri; pre_request_hook=test_login_user_hook, httplib=httplib) api = UserApi(client) login_result, http_resp = login_user(api, TEST_USER, "wrongpassword") @@ -90,9 +90,9 @@ function test_userhook(uri) @test result["code"] == 200 end -function test_parallel(uri) - @info("Parallel usage") - client = Client(uri) +function test_parallel(uri, httplib::Symbol) + @info("Parallel usage ($httplib backend)") + client = Client(uri; httplib=httplib) api = UserApi(client) for gcidx in 1:100 @@ -121,9 +121,9 @@ function test_parallel(uri) nothing end -function test(uri) - @info("UserApi") - client = Client(uri) +function test(uri, httplib::Symbol) + @info("UserApi ($httplib backend)") + client = Client(uri; httplib=httplib) api = UserApi(client) @info("UserApi - login_user") diff --git a/test/client/petstore_v2/runtests.jl b/test/client/petstore_v2/runtests.jl index e580cc7..6d2cea4 100644 --- a/test/client/petstore_v2/runtests.jl +++ b/test/client/petstore_v2/runtests.jl @@ -10,33 +10,33 @@ include("petstore_test_storeapi.jl") const server = "http://127.0.0.1:8080/v2" -function test_misc() - TestUserApi.test_404(server) - TestUserApi.test_userhook(server) +function test_misc(httplib::Symbol) + TestUserApi.test_404(server, httplib) + TestUserApi.test_userhook(server, httplib) TestUserApi.test_set_methods() end -function test_stress() - TestUserApi.test_parallel(server) +function test_stress(httplib::Symbol) + TestUserApi.test_parallel(server, httplib) end -function petstore_tests() - TestUserApi.test(server) - TestStoreApi.test(server) - TestPetApi.test(server) +function petstore_tests(httplib::Symbol) + TestUserApi.test(server, httplib) + TestStoreApi.test(server, httplib) + TestPetApi.test(server, httplib) end -function runtests() +function runtests(httplib::Symbol) @testset "petstore v2" begin @testset "miscellaneous" begin - test_misc() + test_misc(httplib) end @testset "petstore apis" begin - petstore_tests() + petstore_tests(httplib) end if get(ENV, "STRESS_PETSTORE", "false") == "true" @testset "stress" begin - test_stress() + test_stress(httplib) end end end diff --git a/test/client/petstore_v3/petstore_test_petapi.jl b/test/client/petstore_v3/petstore_test_petapi.jl index fcd0781..d139d7b 100644 --- a/test/client/petstore_v3/petstore_test_petapi.jl +++ b/test/client/petstore_v3/petstore_test_petapi.jl @@ -6,9 +6,9 @@ using OpenAPI using OpenAPI.Clients import OpenAPI.Clients: Client -function test(uri; test_file_upload=false) - @info("PetApi") - client = Client(uri) +function test(uri, httplib::Symbol; test_file_upload=false) + @info("PetApi ($httplib backend)") + client = Client(uri; httplib=httplib) api = PetApi(client) tag1 = Tag(;id=10, name="juliacat") diff --git a/test/client/petstore_v3/petstore_test_storeapi.jl b/test/client/petstore_v3/petstore_test_storeapi.jl index e94cfa9..552b5ad 100644 --- a/test/client/petstore_v3/petstore_test_storeapi.jl +++ b/test/client/petstore_v3/petstore_test_storeapi.jl @@ -8,9 +8,9 @@ using OpenAPI using OpenAPI.Clients import OpenAPI.Clients: Client -function test(uri) - @info("StoreApi") - client = Client(uri) +function test(uri, httplib::Symbol) + @info("StoreApi ($httplib backend)") + client = Client(uri; httplib=httplib) api = StoreApi(client) @info("StoreApi - get_inventory") diff --git a/test/client/petstore_v3/petstore_test_userapi.jl b/test/client/petstore_v3/petstore_test_userapi.jl index d16de27..49d3279 100644 --- a/test/client/petstore_v3/petstore_test_userapi.jl +++ b/test/client/petstore_v3/petstore_test_userapi.jl @@ -15,16 +15,16 @@ const TEST_USER2 = "jloac2" const TEST_USER3 = "jl oac 3" const PRESET_TEST_USER = "user1" # this is the username that works for get user requests (as documented in the test docker container API) -function test_404(uri) - @info("Error handling") - client = Client(uri*"/invalid") +function test_404(uri, httplib::Symbol) + @info("Error handling ($httplib backend)") + client = Client(uri*"/invalid"; httplib=httplib) api = UserApi(client) api_return, http_resp = login_user(api, TEST_USER, "testpassword") @test api_return === nothing @test http_resp.status == 404 - client = Client("http://_invalid/") + client = Client("http://_invalid/"; httplib=httplib) api = UserApi(client) try @@ -32,7 +32,7 @@ function test_404(uri) @error("ApiException not thrown") catch ex @test isa(ex, ApiException) - @test startswith(ex.reason, "Could not resolve host") + @test startswith(ex.reason, "Could not resolve host") || startswith(ex.reason, "DNSError") end end @@ -78,9 +78,9 @@ function test_login_user_hook(resource_path::AbstractString, body::Any, headers: (resource_path, body, headers) end -function test_userhook(uri) - @info("User hook") - client = Client(uri; pre_request_hook=test_login_user_hook) +function test_userhook(uri, httplib::Symbol) + @info("User hook ($httplib backend)") + client = Client(uri; pre_request_hook=test_login_user_hook, httplib=httplib) api = UserApi(client) login_result, http_resp = login_user(api, TEST_USER, "wrongpassword") @@ -89,9 +89,9 @@ function test_userhook(uri) @test startswith(login_result, "logged in user session:") end -function test_parallel(uri) - @info("Parallel usage") - client = Client(uri) +function test_parallel(uri, httplib::Symbol) + @info("Parallel usage ($httplib backend)") + client = Client(uri; httplib=httplib) api = UserApi(client) for gcidx in 1:100 @@ -120,8 +120,8 @@ function test_parallel(uri) nothing end -function test(uri) - @info("UserApi") +function test(uri, httplib::Symbol) + @info("UserApi ($httplib backend)") client = Client(uri) api = UserApi(client) diff --git a/test/client/petstore_v3/runtests.jl b/test/client/petstore_v3/runtests.jl index c8aef75..7f01a5c 100644 --- a/test/client/petstore_v3/runtests.jl +++ b/test/client/petstore_v3/runtests.jl @@ -10,33 +10,33 @@ include("petstore_test_storeapi.jl") const server = "http://127.0.0.1:8081/v3" -function test_misc() - TestUserApi.test_404(server) - TestUserApi.test_userhook(server) +function test_misc(httplib::Symbol) + TestUserApi.test_404(server, httplib) + TestUserApi.test_userhook(server, httplib) TestUserApi.test_set_methods() end -function test_stress() - TestUserApi.test_parallel(server) +function test_stress(httplib::Symbol) + TestUserApi.test_parallel(server, httplib) end -function petstore_tests(; test_file_upload=false) - TestUserApi.test(server) - TestStoreApi.test(server) - TestPetApi.test(server; test_file_upload=test_file_upload) +function petstore_tests(httplib::Symbol; test_file_upload=false) + TestUserApi.test(server, httplib) + TestStoreApi.test(server, httplib) + TestPetApi.test(server, httplib; test_file_upload=test_file_upload) end -function runtests(; test_file_upload=false) +function runtests(httplib::Symbol; test_file_upload=false) @testset "petstore v3" begin @testset "miscellaneous" begin - test_misc() + test_misc(httplib) end @testset "petstore apis" begin - petstore_tests(; test_file_upload=test_file_upload) + petstore_tests(httplib; test_file_upload=test_file_upload) end if get(ENV, "STRESS_PETSTORE", "false") == "true" @testset "stress" begin - test_stress() + test_stress(httplib) end end end diff --git a/test/client/runtests.jl b/test/client/runtests.jl index 640bbb2..16ee164 100644 --- a/test/client/runtests.jl +++ b/test/client/runtests.jl @@ -9,7 +9,7 @@ include("petstore_v3/runtests.jl") include("petstore_v2/runtests.jl") include("openapigenerator_petstore_v3/runtests.jl") -function runtests(; skip_petstore=false, test_file_upload=false) +function runtests(httplib::Symbol; skip_petstore=false, test_file_upload=false) @testset "Client" begin @testset "deepObj query param serialization" begin include("client/param_serialize.jl") @@ -30,11 +30,11 @@ function runtests(; skip_petstore=false, test_file_upload=false) if get(ENV, "RUNNER_OS", "") == "Linux" @testset "V3" begin @info("Running petstore v3 tests") - PetStoreV3Tests.runtests(; test_file_upload=test_file_upload) + PetStoreV3Tests.runtests(httplib; test_file_upload=test_file_upload) end @testset "V2" begin @info("Running petstore v2 tests") - PetStoreV2Tests.runtests() + PetStoreV2Tests.runtests(httplib) end else @info("Skipping petstore tests in non Linux environment (can not run petstore docker on OSX or Windows)") @@ -44,11 +44,11 @@ function runtests(; skip_petstore=false, test_file_upload=false) end end -function run_openapigenerator_tests(; test_file_upload=false) +function run_openapigenerator_tests(httplib::Symbol; test_file_upload=false) @testset "OpenAPIGeneratorPetstoreClient" begin if get(ENV, "RUNNER_OS", "") == "Linux" - @info("Running petstore v3 tests") - OpenAPIGenPetStoreV3Tests.runtests(; test_file_upload=test_file_upload) + @info("Running petstore v3 tests ($httplib backend)") + OpenAPIGenPetStoreV3Tests.runtests(httplib; test_file_upload=test_file_upload) else @info("Skipping petstore tests in non Linux environment (can not run petstore docker on OSX or Windows)") end diff --git a/test/client/timeouttest/TimeoutTestClient/.openapi-generator/VERSION b/test/client/timeouttest/TimeoutTestClient/.openapi-generator/VERSION index 96cfbb1..4c631cf 100644 --- a/test/client/timeouttest/TimeoutTestClient/.openapi-generator/VERSION +++ b/test/client/timeouttest/TimeoutTestClient/.openapi-generator/VERSION @@ -1 +1 @@ -7.13.0-SNAPSHOT +7.14.0-SNAPSHOT diff --git a/test/client/timeouttest/TimeoutTestClient/README.md b/test/client/timeouttest/TimeoutTestClient/README.md index 0fcd6e1..a210913 100644 --- a/test/client/timeouttest/TimeoutTestClient/README.md +++ b/test/client/timeouttest/TimeoutTestClient/README.md @@ -6,7 +6,7 @@ No description provided (generated by Openapi Generator https://github.com/opena This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client. - API version: 1.0.0 -- Generator version: 7.13.0-SNAPSHOT +- Generator version: 7.14.0-SNAPSHOT - Build package: org.openapitools.codegen.languages.JuliaClientCodegen @@ -22,6 +22,7 @@ Documentation is also embedded in Julia which can be used with a Julia specific Class | Method ------------ | ------------- *DefaultApi* | [**delayresponse_get**](docs/DefaultApi.md#delayresponse_get)
**GET** /delayresponse
Delay Response Endpoint +*DefaultApi* | [**longpollstream_get**](docs/DefaultApi.md#longpollstream_get)
**GET** /longpollstream
Long polled streaming endpoint ## Models diff --git a/test/client/timeouttest/TimeoutTestClient/docs/DefaultApi.md b/test/client/timeouttest/TimeoutTestClient/docs/DefaultApi.md index 67d2be8..edc11a8 100644 --- a/test/client/timeouttest/TimeoutTestClient/docs/DefaultApi.md +++ b/test/client/timeouttest/TimeoutTestClient/docs/DefaultApi.md @@ -5,6 +5,7 @@ All URIs are relative to *http://localhost* Method | HTTP request | Description ------------- | ------------- | ------------- [**delayresponse_get**](DefaultApi.md#delayresponse_get) | **GET** /delayresponse | Delay Response Endpoint +[**longpollstream_get**](DefaultApi.md#longpollstream_get) | **GET** /longpollstream | Long polled streaming endpoint # **delayresponse_get** @@ -35,3 +36,31 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) +# **longpollstream_get** +> longpollstream_get(_api::DefaultApi, delay_seconds::Int64; _mediaType=nothing) -> DelayresponseGet200Response, OpenAPI.Clients.ApiResponse
+> longpollstream_get(_api::DefaultApi, response_stream::Channel, delay_seconds::Int64; _mediaType=nothing) -> Channel{ DelayresponseGet200Response }, OpenAPI.Clients.ApiResponse + +Long polled streaming endpoint + +### Required Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **_api** | **DefaultApi** | API context | +**delay_seconds** | **Int64** | Number of seconds to delay the response | + +### Return type + +[**DelayresponseGet200Response**](DelayresponseGet200Response.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + diff --git a/test/client/timeouttest/TimeoutTestClient/src/apis/api_DefaultApi.jl b/test/client/timeouttest/TimeoutTestClient/src/apis/api_DefaultApi.jl index 0b30be7..8e68f2a 100644 --- a/test/client/timeouttest/TimeoutTestClient/src/apis/api_DefaultApi.jl +++ b/test/client/timeouttest/TimeoutTestClient/src/apis/api_DefaultApi.jl @@ -42,4 +42,36 @@ function delayresponse_get(_api::DefaultApi, response_stream::Channel, delay_sec return OpenAPI.Clients.exec(_ctx, response_stream) end +const _returntypes_longpollstream_get_DefaultApi = Dict{Regex,Type}( + Regex("^" * replace("200", "x"=>".") * "\$") => DelayresponseGet200Response, +) + +function _oacinternal_longpollstream_get(_api::DefaultApi, delay_seconds::Int64; _mediaType=nothing) + OpenAPI.validate_param("delay_seconds", "longpollstream_get", :minimum, delay_seconds, 0, false) + + _ctx = OpenAPI.Clients.Ctx(_api.client, "GET", _returntypes_longpollstream_get_DefaultApi, "/longpollstream", []) + OpenAPI.Clients.set_param(_ctx.query, "delay_seconds", delay_seconds; style="form", is_explode=true) # type Int64 + OpenAPI.Clients.set_header_accept(_ctx, ["application/json", ]) + OpenAPI.Clients.set_header_content_type(_ctx, (_mediaType === nothing) ? [] : [_mediaType]) + return _ctx +end + +@doc raw"""Long polled streaming endpoint + +Params: +- delay_seconds::Int64 (required) + +Return: DelayresponseGet200Response, OpenAPI.Clients.ApiResponse +""" +function longpollstream_get(_api::DefaultApi, delay_seconds::Int64; _mediaType=nothing) + _ctx = _oacinternal_longpollstream_get(_api, delay_seconds; _mediaType=_mediaType) + return OpenAPI.Clients.exec(_ctx) +end + +function longpollstream_get(_api::DefaultApi, response_stream::Channel, delay_seconds::Int64; _mediaType=nothing) + _ctx = _oacinternal_longpollstream_get(_api, delay_seconds; _mediaType=_mediaType) + return OpenAPI.Clients.exec(_ctx, response_stream) +end + export delayresponse_get +export longpollstream_get diff --git a/test/client/timeouttest/runtests.jl b/test/client/timeouttest/runtests.jl index 73a229b..4d4a4c2 100644 --- a/test/client/timeouttest/runtests.jl +++ b/test/client/timeouttest/runtests.jl @@ -35,10 +35,25 @@ function test_timeout_operation(client, timeout_secs, delay_secs) end end -function runtests() - @testset "timeout_tests" begin - @info("TimeoutTest") - client = Client(server) +function test_longpoll_timeout_operation(client, timeout_secs, delay_secs) + @info("timeout $timeout_secs secs, delay $delay_secs secs") + with_timeout(client, timeout_secs) do client + try + channel = Channel{Any}(10) + api = M.DefaultApi(client) + api_return, http_resp = longpollstream_get(api, channel, delay_secs) + take!(channel) + error("Timeout not thrown") + catch ex + @test OpenAPI.Clients.is_longpoll_timeout(ex) + end + end +end + +function runtests(httplib::Symbol) + @testset "timeout_tests ($httplib backend)" begin + @info("TimeoutTest ($httplib backend)") + client = Client(server; httplib=httplib) test_normal_operation(client, 10) @@ -53,6 +68,11 @@ function runtests() # also test a long delay in general (default libcurl timeout is 0) test_normal_operation(client, 160) end + @testset "longpoll_timeout_tests ($httplib backend)" begin + @info("TimeoutTest ($httplib backend)") + client = Client(server; httplib=httplib) + test_longpoll_timeout_operation(client, 20, 60) + end end end # module TimeoutTests diff --git a/test/client/utilstests.jl b/test/client/utilstests.jl index f5b5c48..af8f108 100644 --- a/test/client/utilstests.jl +++ b/test/client/utilstests.jl @@ -5,6 +5,7 @@ using Dates using TimeZones using Base64 using Downloads +using HTTP function test_date() dt_now = now() @@ -66,22 +67,38 @@ end function test_longpoll_exception_check() resp = OpenAPI.Clients.Downloads.Response("http", "http://localhost", 200, "no error", []) - reqerr1 = OpenAPI.Clients.Downloads.RequestError("http://localhost", 500, "not timeout error", resp) - reqerr2 = OpenAPI.Clients.Downloads.RequestError("http://localhost", 200, "Operation timed out after 300 milliseconds with 0 bytes received", resp) # timeout error + + not_longpoll_timeouts = [ + OpenAPI.Clients.Downloads.RequestError("http://localhost", 500, "not timeout error", resp), + OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), 20, nothing), + OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), nothing), + OpenAPI.Clients.HTTPRequestError(HTTP.ConnectError("http://localhost", "dns error")), + ] + + longpoll_timeouts = [ + OpenAPI.Clients.Downloads.RequestError("http://localhost", 200, "Operation timed out after 300 milliseconds with 0 bytes received", resp), # timeout error + OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), 20, HTTP.Response(200, "hello")), + ] @test OpenAPI.Clients.is_longpoll_timeout("not an exception") == false - openapiex1 = OpenAPI.Clients.ApiException(reqerr1) - @test OpenAPI.Clients.is_longpoll_timeout(openapiex1) == false - @test OpenAPI.Clients.is_longpoll_timeout(as_taskfailedexception(openapiex1)) == false + for reqerr in not_longpoll_timeouts + openapiex = OpenAPI.Clients.ApiException(reqerr) + @test OpenAPI.Clients.is_longpoll_timeout(openapiex) == false + @test OpenAPI.Clients.is_longpoll_timeout(as_taskfailedexception(openapiex)) == false + end - openapiex2 = OpenAPI.Clients.ApiException(reqerr2) - @test OpenAPI.Clients.is_longpoll_timeout(openapiex2) - @test OpenAPI.Clients.is_longpoll_timeout(as_taskfailedexception(openapiex2)) + for reqerr in longpoll_timeouts + openapiex = OpenAPI.Clients.ApiException(reqerr) + @test OpenAPI.Clients.is_longpoll_timeout(openapiex) + @test OpenAPI.Clients.is_longpoll_timeout(as_taskfailedexception(openapiex)) + end - @test OpenAPI.Clients.is_longpoll_timeout(CompositeException([openapiex1, openapiex2])) - @test OpenAPI.Clients.is_longpoll_timeout(CompositeException([openapiex1, as_taskfailedexception(openapiex2)])) - @test OpenAPI.Clients.is_longpoll_timeout(CompositeException([openapiex1, as_taskfailedexception(openapiex1)])) == false + notlp = OpenAPI.Clients.ApiException(first(not_longpoll_timeouts)) + lp = OpenAPI.Clients.ApiException(first(longpoll_timeouts)) + @test OpenAPI.Clients.is_longpoll_timeout(CompositeException([notlp, lp])) + @test OpenAPI.Clients.is_longpoll_timeout(CompositeException([notlp, as_taskfailedexception(lp)])) + @test OpenAPI.Clients.is_longpoll_timeout(CompositeException([notlp, as_taskfailedexception(notlp)])) == false end function test_request_interrupted_exception_check() @@ -249,49 +266,74 @@ const content_disposition_tests = [ (content_disposition="attachment; filename=content.txt", content_type="", filename="content.txt"), (content_disposition="attachment; filename*=UTF-8''filename.txt", content_type="", filename="filename.txt"), (content_disposition="attachment; filename=\"Image File\"; filename*=utf-8''UTF8ImageFile", content_type="", filename="Image File"), - (content_disposition="attachment; filename=\"चित्त.jpg\"", content_type="", filename="चित्त.jpg"), (content_disposition="", content_type="", filename="response"), (content_disposition="", content_type="image/jpg", filename="response"), ] +const non_ascii_content_disposition_tests = [ + (content_disposition="attachment; filename=\"चित्त.jpg\"", content_type="", filename="चित्त.jpg"), +] + function test_storefile() + # TODO: Checks for HTTP.jl backend for test_data in content_disposition_tests headers = [ "Content-Disposition" => test_data.content_disposition, "Content-Type" => test_data.content_type, ] - resp = Downloads.Response("GET", "http://test/", 200, "", headers) + responses = [ + Downloads.Response("GET", "http://test/", 200, "", headers), + HTTP.Response(200, headers, "") + ] + for resp in responses + @test OpenAPI.Clients.extract_filename(resp) == test_data.filename + end + end + + for test_data in non_ascii_content_disposition_tests + headers = [ + "Content-Disposition" => test_data.content_disposition, + "Content-Type" => test_data.content_type, + ] + resp = Downloads.Response("GET", "http://test/", 200, "", headers) @test OpenAPI.Clients.extract_filename(resp) == test_data.filename end mktempdir() do tmpdir test_data = content_disposition_tests[1] + file_contents = "test file data" + headers = [ "Content-Disposition" => test_data.content_disposition, "Content-Type" => test_data.content_type, ] - resp = OpenAPI.Clients.ApiResponse(Downloads.Response("GET", "http://test/", 200, "", headers)) - file_contents = "test file data" - # test extraction of filename from headers - result, http_response, filepath = OpenAPI.Clients.storefile(; folder=tmpdir) do - return file_contents, resp - end + responses = [ + OpenAPI.Clients.ApiResponse(Downloads.Response("GET", "http://test/", 200, "", headers)), + ] - @test result == file_contents - @test http_response == resp - @test filepath == joinpath(tmpdir, test_data.filename) - @test read(filepath, String) == file_contents + for resp in responses - # test overriding filename - result, http_response, filepath = OpenAPI.Clients.storefile(; folder=tmpdir, filename="overridename.txt") do - return file_contents, resp - end + # test extraction of filename from headers + result, http_response, filepath = OpenAPI.Clients.storefile(; folder=tmpdir) do + return file_contents, resp + end + + @test result == file_contents + @test http_response == resp + @test filepath == joinpath(tmpdir, test_data.filename) + @test read(filepath, String) == file_contents - @test result == file_contents - @test http_response == resp - @test filepath == joinpath(tmpdir, "overridename.txt") - @test read(filepath, String) == file_contents + # test overriding filename + result, http_response, filepath = OpenAPI.Clients.storefile(; folder=tmpdir, filename="overridename.txt") do + return file_contents, resp + end + + @test result == file_contents + @test http_response == resp + @test filepath == joinpath(tmpdir, "overridename.txt") + @test read(filepath, String) == file_contents + end end end \ No newline at end of file diff --git a/test/deep_object/deep_client.jl b/test/deep_object/deep_client.jl index 9edc7b6..59b56b6 100644 --- a/test/deep_object/deep_client.jl +++ b/test/deep_object/deep_client.jl @@ -9,13 +9,13 @@ using Test const server = "http://127.0.0.1:8081" -function runtests() - @info("PetApi") - client = Client(server) +function runtests(httplib::Symbol) + @info("DeepObject tests ($httplib backend)") + client = Client(server; httplib=httplib) api = DeepClient.PetApi(client) unsold = FindPetsByStatusStatusParameter("key", ["available", "pending"]) resp, http_resp = find_pets_by_status(api, unsold) - @info resp http_resp + @debug("deep object response", resp, http_resp) res = resp.result @test res.name == "key" @test res.statuses == ["available", "pending"] diff --git a/test/forms/forms_client.jl b/test/forms/forms_client.jl index 3a513ab..fd96b71 100644 --- a/test/forms/forms_client.jl +++ b/test/forms/forms_client.jl @@ -10,9 +10,9 @@ using Base64 const server = "http://127.0.0.1:8081" -function test(uri) - @info("FormsClient.DefaultApi") - client = Client(uri) +function test(uri, httplib::Symbol) + @info("FormsClient.DefaultApi ($httplib backend)") + client = Client(uri; httplib=httplib) api = FormsClient.DefaultApi(client) mktemp() do test_file_path, test_file_io @@ -39,9 +39,9 @@ function test(uri) return nothing end -function runtests() +function runtests(httplib::Symbol) @testset "Forms and File Uploads" begin - test(server) + test(server, httplib) end end diff --git a/test/runtests.jl b/test/runtests.jl index 4665666..49f661f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,5 +1,7 @@ using Test, HTTP +import OpenAPI + include("chunkreader_tests.jl") include("testutils.jl") include("modelgen/testmodelgen.jl") @@ -24,7 +26,9 @@ include("deep_object/deep_client.jl") run(`bash client/petstore_v3/start_petstore_server.sh`) sleep(20) # let servers start end - OpenAPIClientTests.runtests(; skip_petstore=openapi_generator_env, test_file_upload=false) + for httplib in values(OpenAPI.Clients.HTTPLib) + OpenAPIClientTests.runtests(httplib; skip_petstore=openapi_generator_env, test_file_upload=false) + end finally if run_tests_with_servers && !openapi_generator_env run(`bash client/petstore_v2/stop_petstore_server.sh`) @@ -46,7 +50,9 @@ include("deep_object/deep_client.jl") else servers_running = false end - servers_running && OpenAPIClientTests.runtests(; test_file_upload=true) + for httplib in values(OpenAPI.Clients.HTTPLib) + servers_running && OpenAPIClientTests.runtests(httplib; test_file_upload=true) + end finally if run_tests_with_servers && !servers_running # we probably had an error starting the servers @@ -72,7 +78,9 @@ include("deep_object/deep_client.jl") else servers_running = false end - servers_running && OpenAPIClientTests.run_openapigenerator_tests(; test_file_upload=true) + for httplib in values(OpenAPI.Clients.HTTPLib) + servers_running && OpenAPIClientTests.run_openapigenerator_tests(httplib; test_file_upload=true) + end finally if run_tests_with_servers && !servers_running # we probably had an error starting the servers @@ -93,7 +101,9 @@ include("deep_object/deep_client.jl") if run_tests_with_servers ret, out = run_server(joinpath(@__DIR__, "forms", "forms_server.jl")) servers_running &= wait_server(8081) - FormsV3Client.runtests() + for httplib in values(OpenAPI.Clients.HTTPLib) + FormsV3Client.runtests(httplib) + end else servers_running = false end @@ -114,7 +124,9 @@ include("deep_object/deep_client.jl") if run_tests_with_servers ret, out = run_server(joinpath(@__DIR__, "deep_object", "deep_server.jl")) servers_running &= wait_server(8081) - DeepClientTest.runtests() + for httplib in values(OpenAPI.Clients.HTTPLib) + DeepClientTest.runtests(httplib) + end else servers_running = false end @@ -136,7 +148,9 @@ include("deep_object/deep_client.jl") if run_tests_with_servers ret, out = run_server(joinpath(@__DIR__, "server", "allany", "allany_server.jl")) servers_running &= wait_server(8081) - AllAnyTests.runtests() + for httplib in values(OpenAPI.Clients.HTTPLib) + AllAnyTests.runtests(httplib) + end else servers_running = false end @@ -159,7 +173,9 @@ include("deep_object/deep_client.jl") ret, out = run_server(joinpath(@__DIR__, "server", "allany", "allany_server.jl")) servers_running &= wait_server(8081) if VERSION >= v"1.7" - AllAnyTests.test_debug() + for httplib in values(OpenAPI.Clients.HTTPLib) + AllAnyTests.test_debug(httplib) + end end else servers_running = false @@ -186,7 +202,9 @@ include("deep_object/deep_client.jl") if run_tests_with_servers ret, out = run_server(joinpath(@__DIR__, "server", "timeouttest", "timeouttest_server.jl")) servers_running &= wait_server(8081) - TimeoutTests.runtests() + for httplib in values(OpenAPI.Clients.HTTPLib) + TimeoutTests.runtests(httplib) + end else servers_running = false end diff --git a/test/server/timeouttest/TimeoutTestServer/.openapi-generator/VERSION b/test/server/timeouttest/TimeoutTestServer/.openapi-generator/VERSION index 96cfbb1..4c631cf 100644 --- a/test/server/timeouttest/TimeoutTestServer/.openapi-generator/VERSION +++ b/test/server/timeouttest/TimeoutTestServer/.openapi-generator/VERSION @@ -1 +1 @@ -7.13.0-SNAPSHOT +7.14.0-SNAPSHOT diff --git a/test/server/timeouttest/TimeoutTestServer/README.md b/test/server/timeouttest/TimeoutTestServer/README.md index eee4b42..8acab4c 100644 --- a/test/server/timeouttest/TimeoutTestServer/README.md +++ b/test/server/timeouttest/TimeoutTestServer/README.md @@ -6,7 +6,7 @@ No description provided (generated by Openapi Generator https://github.com/opena This API server was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client. - API version: 1.0.0 -- Generator version: 7.13.0-SNAPSHOT +- Generator version: 7.14.0-SNAPSHOT - Build package: org.openapitools.codegen.languages.JuliaServerCodegen @@ -43,6 +43,7 @@ The following server methods must be implemented: Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- *DefaultApi* | [**delayresponse_get**](docs/DefaultApi.md#delayresponse_get) | **GET** /delayresponse | Delay Response Endpoint +*DefaultApi* | [**longpollstream_get**](docs/DefaultApi.md#longpollstream_get) | **GET** /longpollstream | Long polled streaming endpoint diff --git a/test/server/timeouttest/TimeoutTestServer/docs/DefaultApi.md b/test/server/timeouttest/TimeoutTestServer/docs/DefaultApi.md index 745e4aa..b115a02 100644 --- a/test/server/timeouttest/TimeoutTestServer/docs/DefaultApi.md +++ b/test/server/timeouttest/TimeoutTestServer/docs/DefaultApi.md @@ -5,6 +5,7 @@ All URIs are relative to *http://localhost* Method | HTTP request | Description ------------- | ------------- | ------------- [**delayresponse_get**](DefaultApi.md#delayresponse_get) | **GET** /delayresponse | Delay Response Endpoint +[**longpollstream_get**](DefaultApi.md#longpollstream_get) | **GET** /longpollstream | Long polled streaming endpoint # **delayresponse_get** @@ -34,3 +35,30 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **longpollstream_get** +> longpollstream_get(req::HTTP.Request, delay_seconds::Int64;) -> DelayresponseGet200Response + +Long polled streaming endpoint + +### Required Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **req** | **HTTP.Request** | The HTTP Request object | +**delay_seconds** | **Int64**| Number of seconds to delay the response | + +### Return type + +[**DelayresponseGet200Response**](DelayresponseGet200Response.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/test/server/timeouttest/TimeoutTestServer/src/TimeoutTestServer.jl b/test/server/timeouttest/TimeoutTestServer/src/TimeoutTestServer.jl index 5e41ac3..01e600b 100644 --- a/test/server/timeouttest/TimeoutTestServer/src/TimeoutTestServer.jl +++ b/test/server/timeouttest/TimeoutTestServer/src/TimeoutTestServer.jl @@ -10,6 +10,9 @@ The following server methods must be implemented: - **delayresponse_get** - *invocation:* GET /delayresponse - *signature:* delayresponse_get(req::HTTP.Request, delay_seconds::Int64;) -> DelayresponseGet200Response +- **longpollstream_get** + - *invocation:* GET /longpollstream + - *signature:* longpollstream_get(req::HTTP.Request, delay_seconds::Int64;) -> DelayresponseGet200Response """ module TimeoutTestServer diff --git a/test/server/timeouttest/TimeoutTestServer/src/apis/api_DefaultApi.jl b/test/server/timeouttest/TimeoutTestServer/src/apis/api_DefaultApi.jl index aafbaf7..6a7ae51 100644 --- a/test/server/timeouttest/TimeoutTestServer/src/apis/api_DefaultApi.jl +++ b/test/server/timeouttest/TimeoutTestServer/src/apis/api_DefaultApi.jl @@ -38,8 +38,45 @@ function delayresponse_get_invoke(impl; post_invoke=nothing) end end +function longpollstream_get_read(handler) + function longpollstream_get_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + query_params = HTTP.queryparams(URIs.URI(req.target)) + openapi_params["delay_seconds"] = OpenAPI.Servers.to_param(Int64, query_params, "delay_seconds", required=true, style="form", is_explode=true) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function longpollstream_get_validate(handler) + function longpollstream_get_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + op = "longpollstream_get" + + n = "delay_seconds" + v = get(openapi_params, n, nothing) + isnothing(v) && throw(OpenAPI.ValidationException(;reason="missing parameter $n", operation_or_model=op)) + if !isnothing(v) + OpenAPI.validate_param(n, op, :minimum, v, 0, false) + end + + return handler(req) + end +end + +function longpollstream_get_invoke(impl; post_invoke=nothing) + function longpollstream_get_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.longpollstream_get(req::HTTP.Request, openapi_params["delay_seconds"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + function registerDefaultApi(router::HTTP.Router, impl; path_prefix::String="", optional_middlewares...) HTTP.register!(router, "GET", path_prefix * "/delayresponse", OpenAPI.Servers.middleware(impl, delayresponse_get_read, delayresponse_get_validate, delayresponse_get_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/longpollstream", OpenAPI.Servers.middleware(impl, longpollstream_get_read, longpollstream_get_validate, longpollstream_get_invoke; optional_middlewares...)) return router end diff --git a/test/server/timeouttest/timeouttest_server.jl b/test/server/timeouttest/timeouttest_server.jl index da3021e..df9702b 100644 --- a/test/server/timeouttest/timeouttest_server.jl +++ b/test/server/timeouttest/timeouttest_server.jl @@ -1,6 +1,7 @@ module TimeoutTestServerImpl using HTTP +using OpenAPI include("TimeoutTestServer/src/TimeoutTestServer.jl") @@ -13,9 +14,10 @@ delayresponse_get *invocation:* GET /delayresponse """ -function delayresponse_get(req::HTTP.Request, delay_seconds::Int64;) :: TimeoutTestServer.DelayresponseGet200Response +function delayresponse_get(request::HTTP.Request) + delay_seconds = parse(Int, HTTP.URIs.queryparams(HTTP.URIs.parse_uri_reference(request.target))["delay_seconds"]) sleep(delay_seconds) - return TimeoutTestServer.DelayresponseGet200Response(string(delay_seconds)) + return HTTP.Response(200, OpenAPI.Clients.to_json(TimeoutTestServer.DelayresponseGet200Response(string(delay_seconds)))) end function stop(::HTTP.Request) @@ -27,13 +29,29 @@ function ping(::HTTP.Request) return HTTP.Response(200, "") end +function longpollstream(stream::HTTP.Stream) + request::HTTP.Request = stream.message + + if startswith(request.target, "/longpollstream") + HTTP.setheader(stream, "Content-Type" => "application/json") + delay_seconds = parse(Int, HTTP.URIs.queryparams(HTTP.URIs.parse_uri_reference(request.target))["delay_seconds"]) + while true + write(stream, OpenAPI.Clients.to_json(TimeoutTestServer.DelayresponseGet200Response(string(delay_seconds)))) + write(stream, "\n") + sleep(delay_seconds) + end + end + return nothing +end + function run_server(port=8081) try router = HTTP.Router() - router = TimeoutTestServer.register(router, @__MODULE__) - HTTP.register!(router, "GET", "/stop", stop) - HTTP.register!(router, "GET", "/ping", ping) - server[] = HTTP.serve!(router, port) + HTTP.register!(router, "/delayresponse", HTTP.streamhandler(delayresponse_get)) + HTTP.register!(router, "/longpollstream", longpollstream) + HTTP.register!(router, "/stop", HTTP.streamhandler(stop)) + HTTP.register!(router, "/ping", HTTP.streamhandler(ping)) + server[] = HTTP.serve!(router, port; stream=true) wait(server[]) catch ex @error("Server error", exception=(ex, catch_backtrace())) diff --git a/test/specs/stresstest.yaml b/test/specs/stresstest.yaml new file mode 100644 index 0000000..5d634d2 --- /dev/null +++ b/test/specs/stresstest.yaml @@ -0,0 +1,56 @@ +openapi: 3.0.3 +info: + title: Stress Test Echo Service + description: Simple echo service for stress testing the OpenAPI client + version: 1.0.0 +servers: + - url: http://127.0.0.1:8082 + description: Local test server +paths: + /echo: + get: + summary: Echo GET endpoint + description: Returns a simple JSON response with server timestamp + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + timestamp: + type: string + format: date-time + description: Server timestamp when request was received + message: + type: string + description: Echo message + post: + summary: Echo POST endpoint + description: Echoes back the request body with metadata + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + data: + type: string + description: Data to echo back + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + timestamp: + type: string + format: date-time + description: Server timestamp when request was received + data: + type: string + description: Echoed data from request diff --git a/test/specs/timeouttest.yaml b/test/specs/timeouttest.yaml index 046cd78..7888c12 100644 --- a/test/specs/timeouttest.yaml +++ b/test/specs/timeouttest.yaml @@ -27,4 +27,29 @@ paths: x-code-samples: - lang: curl source: | - curl -X GET "http://example.com/delayresponse?delay_seconds=5" \ No newline at end of file + curl -X GET "http://example.com/delayresponse?delay_seconds=5" + /longpollstream: + get: + summary: Long polled streaming endpoint + parameters: + - name: delay_seconds + in: query + description: Number of seconds to delay the response + required: true + schema: + type: integer + minimum: 0 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + delay_seconds: + type: string + x-code-samples: + - lang: curl + source: | + curl -X GET "http://example.com/longpollstream?delay_seconds=5" \ No newline at end of file diff --git a/test/stresstest/README.md b/test/stresstest/README.md new file mode 100644 index 0000000..0f021d5 --- /dev/null +++ b/test/stresstest/README.md @@ -0,0 +1,34 @@ +# Stress Test for OpenAPI.jl Client + +Stress testing suite for the OpenAPI.jl HTTP client. + +## Quick Start + +Run the stress test with default settings: + +```bash +julia runtests.jl +``` + +## Configuration + +Configure via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `STRESS_DURATION` | 30 | Test duration in seconds | +| `STRESS_CONCURRENCY` | 10 | Number of concurrent tasks | +| `STRESS_PAYLOAD_SIZE` | 1024 | POST payload size in bytes | +| `STRESS_HTTPLIB` | http | HTTP backend (`http` or `downloads`) | + +## Examples + +**Light test:** +```bash +STRESS_DURATION=5 STRESS_CONCURRENCY=5 julia runtests.jl +``` + +**Test with Downloads.jl backend:** +```bash +STRESS_HTTPLIB=downloads STRESS_DURATION=30 STRESS_CONCURRENCY=100 julia runtests.jl +``` diff --git a/test/stresstest/StressTest/StressTest.jl b/test/stresstest/StressTest/StressTest.jl new file mode 100644 index 0000000..e191961 --- /dev/null +++ b/test/stresstest/StressTest/StressTest.jl @@ -0,0 +1,24 @@ +module StressTest + +using JSON +using Statistics +using Printf + +# Include submodules in correct dependency order +include("metrics.jl") # Independent, must come first +include("execution.jl") # Depends on metrics.jl + +# Re-export all public functions and types +export StressMetrics, + record_success, record_error, + report_metrics, + calculate_percentile, + get_total_requests, get_success_count, + get_success_rate, get_error_rate, get_throughput, + get_min_latency, get_max_latency, + get_mean_latency, get_median_latency, + StressTestConfig, + run_get_stress_test, run_post_stress_test, + generate_payload + +end # module diff --git a/test/stresstest/StressTest/execution.jl b/test/stresstest/StressTest/execution.jl new file mode 100644 index 0000000..7b7ebc5 --- /dev/null +++ b/test/stresstest/StressTest/execution.jl @@ -0,0 +1,134 @@ +""" +Stress test execution functions for testing the OpenAPI client. +""" + +""" + StressTestConfig + +Configuration for stress tests. + +# Fields +- `duration::Int` - Test duration in seconds +- `concurrency::Int` - Number of concurrent tasks +- `payload_size::Int` - POST payload size in bytes +- `httplib::Symbol` - HTTP backend to use (:http or :downloads) +- `target_url::String` - Base URL of the echo server +""" +mutable struct StressTestConfig + duration::Int + concurrency::Int + payload_size::Int + httplib::Symbol + target_url::String + + function StressTestConfig(; + duration=30, + concurrency=100, + payload_size=1024, + httplib=:http, + target_url="http://127.0.0.1:8082", + ) + return new(duration, concurrency, payload_size, httplib, target_url) + end +end + +function generate_payload(size::Int) + data = "x" ^ max(1, size) + return Main.StressTestClient.EchoPostRequest(; data = data) +end + +""" + run_get_stress_test(api::DefaultApi, config::StressTestConfig, metrics::StressMetrics) + +Run a GET stress test for the specified duration and concurrency. + +# Arguments +- `api::DefaultApi` - API instance to use +- `config::StressTestConfig` - Test configuration +- `metrics::StressMetrics` - Metrics container to record results +""" +function run_get_stress_test(api::Main.StressTestClient.DefaultApi, config::StressTestConfig, metrics::StressMetrics) + @info("Starting GET stress test") + + metrics.start_time = time() + + @sync begin + for task_idx in 1:config.concurrency + @async begin + task_start = time() + local requests_made = 0 + + while time() - task_start < config.duration + try + t0 = time() + result, resp = Main.StressTestClient.echo_get(api) + duration = time() - t0 + + if resp.status == 200 + record_success(metrics, duration) + else + record_error(metrics, "HTTP-$(resp.status)") + end + + requests_made += 1 + catch ex + error_type = string(typeof(ex)) + # Remove module prefix for cleaner output + error_type = split(error_type, ".")[end] + record_error(metrics, error_type) + end + end + end + end + end + + metrics.end_time = time() +end + +""" + run_post_stress_test(api::DefaultApi, config::StressTestConfig, metrics::StressMetrics) + +Run a POST stress test for the specified duration and concurrency. + +# Arguments +- `api::DefaultApi` - API instance to use +- `config::StressTestConfig` - Test configuration +- `metrics::StressMetrics` - Metrics container to record results +""" +function run_post_stress_test(api::Main.StressTestClient.DefaultApi, config::StressTestConfig, metrics::StressMetrics) + @info("Starting POST stress test") + payload = generate_payload(config.payload_size) + metrics.start_time = time() + + @sync begin + for task_idx in 1:config.concurrency + @async begin + task_start = time() + local requests_made = 0 + + while time() - task_start < config.duration + try + t0 = time() + result, resp = Main.StressTestClient.echo_post(api, payload) + duration = time() - t0 + + if resp.status == 200 + record_success(metrics, duration) + else + record_error(metrics, "HTTP-$(resp.status)") + end + + requests_made += 1 + catch ex + error_type = string(typeof(ex)) + # Remove module prefix for cleaner output + error_type = split(error_type, ".")[end] + record_error(metrics, error_type) + end + end + end + end + end + + metrics.end_time = time() +end diff --git a/test/stresstest/StressTest/metrics.jl b/test/stresstest/StressTest/metrics.jl new file mode 100644 index 0000000..11d8b45 --- /dev/null +++ b/test/stresstest/StressTest/metrics.jl @@ -0,0 +1,306 @@ +""" +Metrics collection and reporting for stress tests. +""" + +using Statistics +using Printf + +""" + StressMetrics + +Container for collecting metrics during stress tests. + +# Fields +- `request_times::Vector{Float64}` - Response time for each request (in seconds) +- `error_count::Int` - Total number of failed requests +- `error_types::Dict{String,Int}` - Count of each error type +- `start_time::Float64` - Test start time (from time()) +- `end_time::Float64` - Test end time (from time()) +""" +mutable struct StressMetrics + request_times::Vector{Float64} + error_count::Int + error_types::Dict{String,Int} + start_time::Float64 + end_time::Float64 + + function StressMetrics() + return new(Float64[], 0, Dict{String,Int}(), 0.0, 0.0) + end +end + +""" + record_success(metrics::StressMetrics, duration::Float64) + +Record a successful request with the given duration. + +# Arguments +- `metrics::StressMetrics` - Metrics container +- `duration::Float64` - Request duration in seconds +""" +function record_success(metrics::StressMetrics, duration::Float64) + push!(metrics.request_times, duration) +end + +""" + record_error(metrics::StressMetrics, error_type::String) + +Record a failed request with the given error type. + +# Arguments +- `metrics::StressMetrics` - Metrics container +- `error_type::String` - Type or description of the error +""" +function record_error(metrics::StressMetrics, error_type::String) + metrics.error_count += 1 + metrics.error_types[error_type] = get(metrics.error_types, error_type, 0) + 1 +end + +""" + calculate_percentile(times::Vector{Float64}, p::Float64)::Float64 + +Calculate the p-th percentile of the given times. + +# Arguments +- `times::Vector{Float64}` - Vector of times (in seconds) +- `p::Float64` - Percentile (0-100) + +# Returns +The p-th percentile value in seconds, or 0.0 if no data +""" +function calculate_percentile(times::Vector{Float64}, p::Float64)::Float64 + if isempty(times) + return 0.0 + end + + sorted_times = sort(times) + index = ceil(Int, length(sorted_times) * p / 100) + index = max(1, min(index, length(sorted_times))) + return sorted_times[index] +end + +""" + get_total_requests(metrics::StressMetrics)::Int + +Get the total number of requests (successful + failed). +""" +function get_total_requests(metrics::StressMetrics)::Int + return length(metrics.request_times) + metrics.error_count +end + +""" + get_success_count(metrics::StressMetrics)::Int + +Get the number of successful requests. +""" +function get_success_count(metrics::StressMetrics)::Int + return length(metrics.request_times) +end + +""" + get_success_rate(metrics::StressMetrics)::Float64 + +Get the success rate as a percentage (0-100). +""" +function get_success_rate(metrics::StressMetrics)::Float64 + total = get_total_requests(metrics) + if total == 0 + return 0.0 + end + return 100.0 * get_success_count(metrics) / total +end + +""" + get_error_rate(metrics::StressMetrics)::Float64 + +Get the error rate as a percentage (0-100). +""" +function get_error_rate(metrics::StressMetrics)::Float64 + return 100.0 - get_success_rate(metrics) +end + +""" + get_throughput(metrics::StressMetrics)::Float64 + +Get the throughput in requests per second. +""" +function get_throughput(metrics::StressMetrics)::Float64 + if metrics.start_time == 0.0 || metrics.end_time == 0.0 + return 0.0 + end + + duration = metrics.end_time - metrics.start_time + if duration <= 0.0 + return 0.0 + end + + return get_total_requests(metrics) / duration +end + +""" + get_min_latency(metrics::StressMetrics)::Float64 + +Get the minimum request latency in seconds. +""" +function get_min_latency(metrics::StressMetrics)::Float64 + if isempty(metrics.request_times) + return 0.0 + end + return minimum(metrics.request_times) +end + +""" + get_max_latency(metrics::StressMetrics)::Float64 + +Get the maximum request latency in seconds. +""" +function get_max_latency(metrics::StressMetrics)::Float64 + if isempty(metrics.request_times) + return 0.0 + end + return maximum(metrics.request_times) +end + +""" + get_mean_latency(metrics::StressMetrics)::Float64 + +Get the mean request latency in seconds. +""" +function get_mean_latency(metrics::StressMetrics)::Float64 + if isempty(metrics.request_times) + return 0.0 + end + return mean(metrics.request_times) +end + +""" + get_median_latency(metrics::StressMetrics)::Float64 + +Get the median request latency in seconds. +""" +function get_median_latency(metrics::StressMetrics)::Float64 + if isempty(metrics.request_times) + return 0.0 + end + return median(metrics.request_times) +end + +""" + report_metrics(metrics::StressMetrics, endpoint::String, payload_size::Union{Int,Nothing}=nothing) + +Print a formatted report of the collected metrics. + +# Arguments +- `metrics::StressMetrics` - Metrics container +- `endpoint::String` - The endpoint that was tested (e.g., "GET /echo") +- `payload_size::Union{Int,Nothing}` - Payload size in bytes (optional, for POST requests) +""" +function report_metrics(metrics::StressMetrics, endpoint::String, payload_size::Union{Int,Nothing}=nothing) + println("\n" * "="^60) + println("$endpoint Results") + if payload_size !== nothing + println("Payload Size: $(format_bytes(payload_size))") + end + println("="^60) + + total = get_total_requests(metrics) + success = get_success_count(metrics) + success_rate = get_success_rate(metrics) + error_rate = get_error_rate(metrics) + + println(" Total Requests: $(format_number(total))") + println(" Successful: $(format_number(success)) ($(format_percent(success_rate))%)") + println(" Failed: $(metrics.error_count) ($(format_percent(error_rate))%)") + + if metrics.error_count > 0 + println("\n Error Types:") + for (error_type, count) in sort(collect(metrics.error_types), by=x -> -x[2]) + percent = 100.0 * count / metrics.error_count + println(" $error_type: $count ($(format_percent(percent))%)") + end + end + + throughput = get_throughput(metrics) + println("\n Throughput: $(format_number(throughput)) req/s") + + if !isempty(metrics.request_times) + println("\n Latency:") + println(" Min: $(format_latency(get_min_latency(metrics)))") + println(" Mean: $(format_latency(get_mean_latency(metrics)))") + println(" Median: $(format_latency(get_median_latency(metrics)))") + println(" Max: $(format_latency(get_max_latency(metrics)))") + println(" P95: $(format_latency(calculate_percentile(metrics.request_times, 95.0)))") + println(" P99: $(format_latency(calculate_percentile(metrics.request_times, 99.0)))") + end + + if metrics.start_time > 0.0 && metrics.end_time > 0.0 + duration = metrics.end_time - metrics.start_time + println("\n Duration: $(format_number(duration))s") + end + println() +end + +# Formatting helper functions + +""" + format_number(n::Union{Int,Float64})::String + +Format a number with thousand separators. +""" +function format_number(n::Union{Int,Float64})::String + if n isa Int + # Format integer with commas as thousand separators + s = string(n) + parts = [] + for (i, c) in enumerate(reverse(s)) + if i > 1 && (i - 1) % 3 == 0 + pushfirst!(parts, ",") + end + pushfirst!(parts, c) + end + return join(parts) + else + return @sprintf("%.1f", n) + end +end + +""" + format_percent(p::Float64)::String + +Format a percentage with one decimal place. +""" +function format_percent(p::Float64)::String + return @sprintf("%.1f", p) +end + +""" + format_latency(seconds::Float64)::String + +Format latency in appropriate units (ms or seconds). +""" +function format_latency(seconds::Float64)::String + if seconds < 0.001 + return @sprintf("%.2fμs", seconds * 1e6) + elseif seconds < 1.0 + return @sprintf("%.2fms", seconds * 1000) + else + return @sprintf("%.2fs", seconds) + end +end + +""" + format_bytes(bytes::Int)::String + +Format bytes in appropriate units (B, KB, MB, GB). +""" +function format_bytes(bytes::Int)::String + if bytes < 1024 + return "$(bytes)B" + elseif bytes < 1024 * 1024 + return @sprintf("%.1fKB", bytes / 1024) + elseif bytes < 1024 * 1024 * 1024 + return @sprintf("%.1fMB", bytes / (1024 * 1024)) + else + return @sprintf("%.1fGB", bytes / (1024 * 1024 * 1024)) + end +end diff --git a/test/stresstest/StressTestClient/src/StressTestClient.jl b/test/stresstest/StressTestClient/src/StressTestClient.jl new file mode 100644 index 0000000..6dbbd38 --- /dev/null +++ b/test/stresstest/StressTestClient/src/StressTestClient.jl @@ -0,0 +1,16 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + +module StressTestClient + +using Dates, TimeZones +using OpenAPI +using OpenAPI.Clients + +const API_VERSION = "1.0.0" + +include("modelincludes.jl") + +include("apis/api_DefaultApi.jl") + +end # module StressTestClient diff --git a/test/stresstest/StressTestClient/src/apis/api_DefaultApi.jl b/test/stresstest/StressTestClient/src/apis/api_DefaultApi.jl new file mode 100644 index 0000000..1bac822 --- /dev/null +++ b/test/stresstest/StressTestClient/src/apis/api_DefaultApi.jl @@ -0,0 +1,74 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + +struct DefaultApi <: OpenAPI.APIClientImpl + client::OpenAPI.Clients.Client +end + +""" +The default API base path for APIs in `DefaultApi`. +This can be used to construct the `OpenAPI.Clients.Client` instance. +""" +basepath(::Type{ DefaultApi }) = "http://127.0.0.1:8082" + +const _returntypes_echo_get_DefaultApi = Dict{Regex,Type}( + Regex("^" * replace("200", "x"=>".") * "\$") => EchoGet200Response, +) + +function _oacinternal_echo_get(_api::DefaultApi; _mediaType=nothing) + _ctx = OpenAPI.Clients.Ctx(_api.client, "GET", _returntypes_echo_get_DefaultApi, "/echo", []) + OpenAPI.Clients.set_header_accept(_ctx, ["application/json", ]) + OpenAPI.Clients.set_header_content_type(_ctx, (_mediaType === nothing) ? [] : [_mediaType]) + return _ctx +end + +@doc raw"""Echo GET endpoint + +Returns a simple JSON response with server timestamp + +Params: + +Return: EchoGet200Response, OpenAPI.Clients.ApiResponse +""" +function echo_get(_api::DefaultApi; _mediaType=nothing) + _ctx = _oacinternal_echo_get(_api; _mediaType=_mediaType) + return OpenAPI.Clients.exec(_ctx) +end + +function echo_get(_api::DefaultApi, response_stream::Channel; _mediaType=nothing) + _ctx = _oacinternal_echo_get(_api; _mediaType=_mediaType) + return OpenAPI.Clients.exec(_ctx, response_stream) +end + +const _returntypes_echo_post_DefaultApi = Dict{Regex,Type}( + Regex("^" * replace("200", "x"=>".") * "\$") => EchoPost200Response, +) + +function _oacinternal_echo_post(_api::DefaultApi, echo_post_request::EchoPostRequest; _mediaType=nothing) + _ctx = OpenAPI.Clients.Ctx(_api.client, "POST", _returntypes_echo_post_DefaultApi, "/echo", [], echo_post_request) + OpenAPI.Clients.set_header_accept(_ctx, ["application/json", ]) + OpenAPI.Clients.set_header_content_type(_ctx, (_mediaType === nothing) ? ["application/json", ] : [_mediaType]) + return _ctx +end + +@doc raw"""Echo POST endpoint + +Echoes back the request body with metadata + +Params: +- echo_post_request::EchoPostRequest (required) + +Return: EchoPost200Response, OpenAPI.Clients.ApiResponse +""" +function echo_post(_api::DefaultApi, echo_post_request::EchoPostRequest; _mediaType=nothing) + _ctx = _oacinternal_echo_post(_api, echo_post_request; _mediaType=_mediaType) + return OpenAPI.Clients.exec(_ctx) +end + +function echo_post(_api::DefaultApi, response_stream::Channel, echo_post_request::EchoPostRequest; _mediaType=nothing) + _ctx = _oacinternal_echo_post(_api, echo_post_request; _mediaType=_mediaType) + return OpenAPI.Clients.exec(_ctx, response_stream) +end + +export echo_get +export echo_post diff --git a/test/stresstest/StressTestClient/src/modelincludes.jl b/test/stresstest/StressTestClient/src/modelincludes.jl new file mode 100644 index 0000000..12bd391 --- /dev/null +++ b/test/stresstest/StressTestClient/src/modelincludes.jl @@ -0,0 +1,6 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + +include("models/model_EchoGet200Response.jl") +include("models/model_EchoPost200Response.jl") +include("models/model_EchoPostRequest.jl") diff --git a/test/stresstest/StressTestClient/src/models/model_EchoGet200Response.jl b/test/stresstest/StressTestClient/src/models/model_EchoGet200Response.jl new file mode 100644 index 0000000..da622a6 --- /dev/null +++ b/test/stresstest/StressTestClient/src/models/model_EchoGet200Response.jl @@ -0,0 +1,44 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + + +@doc raw"""_echo_get_200_response + + EchoGet200Response(; + timestamp=nothing, + message=nothing, + ) + + - timestamp::ZonedDateTime : Server timestamp when request was received + - message::String : Echo message +""" +Base.@kwdef mutable struct EchoGet200Response <: OpenAPI.APIModel + timestamp::Union{Nothing, ZonedDateTime} = nothing + message::Union{Nothing, String} = nothing + + function EchoGet200Response(timestamp, message, ) + o = new(timestamp, message, ) + OpenAPI.validate_properties(o) + return o + end +end # type EchoGet200Response + +const _property_types_EchoGet200Response = Dict{Symbol,String}(Symbol("timestamp")=>"ZonedDateTime", Symbol("message")=>"String", ) +OpenAPI.property_type(::Type{ EchoGet200Response }, name::Symbol) = Union{Nothing,eval(Base.Meta.parse(_property_types_EchoGet200Response[name]))} + +function OpenAPI.check_required(o::EchoGet200Response) + true +end + +function OpenAPI.validate_properties(o::EchoGet200Response) + OpenAPI.validate_property(EchoGet200Response, Symbol("timestamp"), o.timestamp) + OpenAPI.validate_property(EchoGet200Response, Symbol("message"), o.message) +end + +function OpenAPI.validate_property(::Type{ EchoGet200Response }, name::Symbol, val) + + if name === Symbol("timestamp") + OpenAPI.validate_param(name, "EchoGet200Response", :format, val, "date-time") + end + +end diff --git a/test/stresstest/StressTestClient/src/models/model_EchoPost200Response.jl b/test/stresstest/StressTestClient/src/models/model_EchoPost200Response.jl new file mode 100644 index 0000000..7eee139 --- /dev/null +++ b/test/stresstest/StressTestClient/src/models/model_EchoPost200Response.jl @@ -0,0 +1,44 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + + +@doc raw"""_echo_post_200_response + + EchoPost200Response(; + timestamp=nothing, + data=nothing, + ) + + - timestamp::ZonedDateTime : Server timestamp when request was received + - data::String : Echoed data from request +""" +Base.@kwdef mutable struct EchoPost200Response <: OpenAPI.APIModel + timestamp::Union{Nothing, ZonedDateTime} = nothing + data::Union{Nothing, String} = nothing + + function EchoPost200Response(timestamp, data, ) + o = new(timestamp, data, ) + OpenAPI.validate_properties(o) + return o + end +end # type EchoPost200Response + +const _property_types_EchoPost200Response = Dict{Symbol,String}(Symbol("timestamp")=>"ZonedDateTime", Symbol("data")=>"String", ) +OpenAPI.property_type(::Type{ EchoPost200Response }, name::Symbol) = Union{Nothing,eval(Base.Meta.parse(_property_types_EchoPost200Response[name]))} + +function OpenAPI.check_required(o::EchoPost200Response) + true +end + +function OpenAPI.validate_properties(o::EchoPost200Response) + OpenAPI.validate_property(EchoPost200Response, Symbol("timestamp"), o.timestamp) + OpenAPI.validate_property(EchoPost200Response, Symbol("data"), o.data) +end + +function OpenAPI.validate_property(::Type{ EchoPost200Response }, name::Symbol, val) + + if name === Symbol("timestamp") + OpenAPI.validate_param(name, "EchoPost200Response", :format, val, "date-time") + end + +end diff --git a/test/stresstest/StressTestClient/src/models/model_EchoPostRequest.jl b/test/stresstest/StressTestClient/src/models/model_EchoPostRequest.jl new file mode 100644 index 0000000..b68f6ad --- /dev/null +++ b/test/stresstest/StressTestClient/src/models/model_EchoPostRequest.jl @@ -0,0 +1,36 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + + +@doc raw"""_echo_post_request + + EchoPostRequest(; + data=nothing, + ) + + - data::String : Data to echo back +""" +Base.@kwdef mutable struct EchoPostRequest <: OpenAPI.APIModel + data::Union{Nothing, String} = nothing + + function EchoPostRequest(data, ) + o = new(data, ) + OpenAPI.validate_properties(o) + return o + end +end # type EchoPostRequest + +const _property_types_EchoPostRequest = Dict{Symbol,String}(Symbol("data")=>"String", ) +OpenAPI.property_type(::Type{ EchoPostRequest }, name::Symbol) = Union{Nothing,eval(Base.Meta.parse(_property_types_EchoPostRequest[name]))} + +function OpenAPI.check_required(o::EchoPostRequest) + true +end + +function OpenAPI.validate_properties(o::EchoPostRequest) + OpenAPI.validate_property(EchoPostRequest, Symbol("data"), o.data) +end + +function OpenAPI.validate_property(::Type{ EchoPostRequest }, name::Symbol, val) + +end diff --git a/test/stresstest/generate.sh b/test/stresstest/generate.sh new file mode 100755 index 0000000..ce22532 --- /dev/null +++ b/test/stresstest/generate.sh @@ -0,0 +1,5 @@ +java -jar openapi-generator-cli.jar generate \ + -i ../specs/stresstest.yaml \ + -g julia-client \ + -o StressTestClient \ + --additional-properties=packageName=StressTestClient diff --git a/test/stresstest/runtests.jl b/test/stresstest/runtests.jl new file mode 100644 index 0000000..dd97c14 --- /dev/null +++ b/test/stresstest/runtests.jl @@ -0,0 +1,90 @@ +""" +Stress tests for OpenAPI.jl client testing. + +Environment Variables: + STRESS_DURATION - Test duration in seconds (default: 10) + STRESS_CONCURRENCY - Number of concurrent tasks (default: 10) + STRESS_PAYLOAD_SIZE - POST payload size in bytes (default: 1024) + STRESS_HTTPLIB - HTTP backend to use, :http or :downloads (default: :http) + +Example: + STRESS_DURATION=30 STRESS_CONCURRENCY=10 julia --project=.. runtests.jl +""" + +using Test +using OpenAPI +using OpenAPI.Clients +using HTTP + +include("../testutils.jl") +include("StressTestClient/src/StressTestClient.jl") +using .StressTestClient +include("StressTest/StressTest.jl") +using .StressTest + +const TEST_PORT = 8082 +const TEST_SERVER_SCRIPT = abspath(joinpath(@__DIR__, "stresstest_server.jl")) + +""" +Parse configuration from environment variables with defaults. +""" +function get_config() + duration = parse(Int, get(ENV, "STRESS_DURATION", "30")) + concurrency = parse(Int, get(ENV, "STRESS_CONCURRENCY", "10")) + payload_size = parse(Int, get(ENV, "STRESS_PAYLOAD_SIZE", "1024")) + + httplib_str = get(ENV, "STRESS_HTTPLIB", "http") + httplib = Symbol(httplib_str) + if !in(httplib, (:http, :downloads)) + @warn("Invalid STRESS_HTTPLIB '$httplib_str', using :http") + httplib = :http + end + + return StressTestConfig( + duration=duration, + concurrency=concurrency, + payload_size=payload_size, + httplib=httplib, + ) +end + +function main() + config = get_config() + + @info("Starting stress test", + server_port=TEST_PORT, + duration = config.duration, + concurrency = config.concurrency, + http_backend = config.httplib, + + ) + proc, iob = run_server(TEST_SERVER_SCRIPT) + + try + if !wait_server(TEST_PORT) + @error("Server failed to start") + return false + end + + client = OpenAPI.Clients.Client(config.target_url; httplib=config.httplib) + api = StressTestClient.DefaultApi(client) + + get_metrics = StressMetrics() + run_get_stress_test(api, config, get_metrics) + report_metrics(get_metrics, "GET /echo") + + post_metrics = StressMetrics() + run_post_stress_test(api, config, post_metrics) + report_metrics(post_metrics, "POST /echo", config.payload_size) + + return true + finally + @info("Stopping server") + stop_server(TEST_PORT, proc, iob) + end +end + +@testset "Stress Tests" begin + success = main() + @test success +end diff --git a/test/stresstest/stresstest_server.jl b/test/stresstest/stresstest_server.jl new file mode 100644 index 0000000..1ca6d29 --- /dev/null +++ b/test/stresstest/stresstest_server.jl @@ -0,0 +1,100 @@ +module StressTestServerImpl + +using HTTP +using OpenAPI +using Dates +using Random +using JSON + +const server = Ref{Any}(nothing) +const headers = ["Content-Type" => "application/json"] + + +""" + echo_get(request::HTTP.Request) + +Handler for GET /echo endpoint. +Returns a simple JSON response with timestamp and request info. +""" +function echo_get(request::HTTP.Request) + timestamp = Dates.now(UTC) + + response_data = Dict( + "timestamp" => string(timestamp), + "message" => "Echo GET response", + ) + + return HTTP.Response(200, headers, JSON.json(response_data)) +end + +""" + echo_post(request::HTTP.Request) + +Handler for POST /echo endpoint. +Echoes back the request body with metadata. +""" +function echo_post(request::HTTP.Request) + timestamp = Dates.now(UTC) + request_body = String(request.body) + request_data = JSON.parse(request_body) + + response_data = Dict( + "timestamp" => string(timestamp), + "data" => get(request_data, "data", ""), + ) + + response_json = JSON.json(response_data) + + return HTTP.Response(200, headers, response_json) +end + +""" + stop(::HTTP.Request) + +Handler for GET /stop endpoint. +Gracefully shuts down the server. +""" +function stop(::HTTP.Request) + try + HTTP.close(server[]) + catch + # Ignore errors during shutdown + end + return HTTP.Response(200, "") +end + +""" + ping(::HTTP.Request) + +Handler for GET /ping endpoint. +Health check endpoint. +""" +function ping(::HTTP.Request) + return HTTP.Response(200, "") +end + +""" + run_server(port=8082) + +Start the echo server on the given port. +""" +function run_server(port=8082) + try + router = HTTP.Router() + HTTP.register!(router, "GET", "/echo", echo_get) + HTTP.register!(router, "POST", "/echo", echo_post) + HTTP.register!(router, "GET", "/stop", stop) + HTTP.register!(router, "GET", "/ping", ping) + + @info("Starting StressTest server on port $port") + server[] = HTTP.serve!(router, "127.0.0.1", port; stream=false) + wait(server[]) + catch ex + @error("Server error", exception=(ex, catch_backtrace())) + end +end + +end # module StressTestServerImpl + +# Start the server when this script is run directly +StressTestServerImpl.run_server()