Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 6 additions & 3 deletions docs/src/userguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
```

Expand All @@ -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`
Expand All @@ -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:

Expand Down
486 changes: 16 additions & 470 deletions src/client.jl

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions src/client/chunk_readers.jl
Original file line number Diff line number Diff line change
@@ -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
224 changes: 224 additions & 0 deletions src/client/clienttypes.jl
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions src/client/httplibs/httplibs.jl
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading