diff --git a/Project.toml b/Project.toml index 4b16882..7018cca 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.1.22" +version = "0.1.23" [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" @@ -17,17 +17,19 @@ MIMEs = "6c6e2e6c-3030-632d-7369-2d6c69616d65" MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d" TimeZones = "f269a46b-ccf7-5d73-abea-4c690281aa53" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +p7zip_jll = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" [compat] Downloads = "1" HTTP = "1" JSON = "0.20, 0.21" LibCURL = "0.6" +MIMEs = "0.1" MbedTLS = "0.6.8, 0.7, 1" TimeZones = "1" URIs = "1.3" julia = "1.6" -MIMEs = "0.1" +p7zip_jll = "17" [extras] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" diff --git a/docs/src/reference.md b/docs/src/reference.md index 531854d..f2c6449 100644 --- a/docs/src/reference.md +++ b/docs/src/reference.md @@ -57,6 +57,9 @@ Refer to the User Guide section for mode details of the API that is generated. ## Tools ```@docs +openapi_generator +stop_openapi_generator +generate swagger_ui stop_swagger_ui swagger_editor diff --git a/docs/src/tools.md b/docs/src/tools.md index 52c6073..f44ed81 100644 --- a/docs/src/tools.md +++ b/docs/src/tools.md @@ -1,5 +1,51 @@ # Tools +## Code Generator + +The [OpenAPI Generator Docker image](https://hub.docker.com/r/openapitools/openapi-generator-cli) is a code generator that can generate client libraries, server stubs, and API documentation from an OpenAPI Specification. OpenAPI.jl includes convenience methods to use the OpenAPI Generator from Julia. + +Use `OpenAPI.generate` to generate code from an OpenAPI specification. It can be pointed at a server hosted on the local machine or a remote server. The OpenAPI Generator must be running at the specified `generator_host`. Returns the folder containing generated code. + +```julia +OpenAPI.generate( + spec::Dict{String,Any}; + type::Symbol=:client, + package_name::AbstractString="APIClient", + export_models::Bool=false, + export_operations::Bool=false, + output_dir::AbstractString="", + generator_host::AbstractString=GeneratorHost.Local +) +``` + +Arguments: +- `spec`: The OpenAPI specification as a Dict. It can be obtained by parsing a JSON or YAML file using `JSON.parse` or `YAML.load`. + +Optional arguments: +- `type`: The type of code to generate. Must be `:client` or `:server`. Defaults to `:client`. +- `package_name`: The name of the package to generate. Defaults to "APIClient". +- `export_models`: Whether to export models. Defaults to false. +- `export_operations`: Whether to export operations. Defaults to false. +- `output_dir`: The directory to save the generated code. Defaults to a temporary directory. Directory will be created if it does not exist. +- `generator_host`: The host of the OpenAPI Generator. Defaults to `GeneratorHost.Local` (which points to `http://localhost:8080`). + +The `generator_host` can be pointed to any other URL where the OpenAPI Generator is running, e.g. `https://openapigen.myorg.com`. Other possible pre-defined values of `generator_host`, which point to the public service hosted by OpenAPI org are: +- `OpenAPI.GeneratorHost.OpenAPIGeneratorTech.Stable`: Runs a stable version of the OpenAPI Generator at . +- `OpenAPI.GeneratorHost.OpenAPIGeneratorTech.Master`: Runs the latest version of the OpenAPI Generator at . + +A locally hosted generator service is preferred by default for privacy reasons. One can be started on the local machine using `OpenAPI.openapi_generator`. It uses the `openapitools/openapi-generator-online` docker image and requires docker engine to be installed. Use `OpenAPI.stop_openapi_generator` to stop the local generator service after use. + +```julia +OpenAPI.openapi_generator(; + port::Int=8080, # port to use + use_sudo::Bool=false # whether to use sudo while invoking docker +) + +OpenAPI.stop_openapi_generator(; + use_sudo::Bool=false # whether to use sudo while invoking docker +) +``` + ## Swagger UI [Swagger UI](https://swagger.io/tools/swagger-ui/) allows visualization and interaction with the API’s resources without having any of the implementation logic in place. OpenAPI.jl includes convenience methods to launch Swagger UI from Julia. @@ -7,14 +53,14 @@ Use `OpenAPI.swagger_ui` to open Swagger UI. It uses the standard `swaggerapi/swagger-ui` docker image and requires docker engine to be installed. ```julia -# specify a specification file to start with +# provide a specification file to start with OpenAPI.swagger_ui( spec::AbstractString; # the OpenAPI specification to use port::Int=8080, # port to use use_sudo::Bool=false # whether to use sudo while invoking docker ) -# specify a folder and specification file name to start with +# provide a folder and specification file name to start with OpenAPI.swagger_ui( spec_dir::AbstractString; # folder containing the specification file spec_file::AbstractString; # the specification file diff --git a/src/OpenAPI.jl b/src/OpenAPI.jl index 08b3500..50d9e56 100644 --- a/src/OpenAPI.jl +++ b/src/OpenAPI.jl @@ -1,6 +1,8 @@ module OpenAPI using HTTP, JSON, URIs, Dates, TimeZones, Base64 +using Downloads +using p7zip_jll import Base: getindex, keys, length, iterate, hasproperty import JSON: lower diff --git a/src/tools.jl b/src/tools.jl index dfbefc9..03a32ef 100644 --- a/src/tools.jl +++ b/src/tools.jl @@ -1,7 +1,81 @@ -const SwaggerImage = (UI="swaggerapi/swagger-ui", Editor="swaggerapi/swagger-editor") +const SwaggerImage = ( + UI="swaggerapi/swagger-ui", + Editor="swaggerapi/swagger-editor", +) +const OpenAPIImage = ( + GeneratorOnline="openapitools/openapi-generator-online", + GeneratorCLI="openapitools/openapi-generator-cli", +) + +const GeneratorHost = ( + OpenAPIGeneratorTech = ( + Stable = "https://api.openapi-generator.tech", + Master = "https://api-latest-master.openapi-generator.tech", + ), + Local="http://localhost:8080", +) + +const GeneratorHeaders = [ + "Content-Type" => "application/json", + "Accept" => "application/json", +] docker_cmd(; use_sudo::Bool=false) = use_sudo ? `sudo docker` : `docker` +function _start_docker(cmd, port) + run(cmd) + return "http://localhost:$port" +end + +function _stop_docker(image_name::AbstractString, image_type::AbstractString; use_sudo::Bool=false) + docker = docker_cmd(; use_sudo=use_sudo) + find_cmd = `$docker ps -a -q -f ancestor=$image_name` + container_id = strip(String(read(find_cmd))) + + if !isempty(container_id) + stop_cmd = `$docker stop $container_id` + stop_res = strip(String(read(stop_cmd))) + + if stop_res == container_id + @debug("Stopped $(image_type) container") + elseif isempty(stop_res) + @debug("$(image_type) container not running") + else + @error("Failed to stop $(image_type) container: $stop_res") + return false + end + + container_id = strip(String(read(find_cmd))) + if !isempty(container_id) + rm_cmd = `$docker rm $container_id` + rm_res = strip(String(read(rm_cmd))) + + if rm_res == container_id + @debug("Removed $(image_type) container") + elseif isempty(rm_res) + @debug("$(image_type) container not found") + else + @error("Failed to remove $(image_type) container: $rm_res") + return false + end + end + + return true + else + @debug("$(image_type) container not found") + end + + return false +end + +""" + stop_openapi_generator(; use_sudo=false) + +Stop and remove the OpenAPI Generator container, if it is running. +Returns true if the container was stopped and removed, false otherwise. +""" +stop_openapi_generator(; use_sudo::Bool=false) = _stop_docker(OpenAPIImage.GeneratorOnline, "OpenAPI Generator"; use_sudo=use_sudo) + """ stop_swagger_ui(; use_sudo=false) @@ -30,50 +104,120 @@ function stop_swagger(; use_sudo::Bool=false) return stopped end -function _stop_swagger(image_name::AbstractString; use_sudo::Bool=false) +_stop_swagger(image_name::AbstractString; use_sudo::Bool=false) = _stop_docker(image_name, "Swagger", use_sudo=use_sudo) +_start_swagger(cmd, port) = _start_docker(cmd, port) + +""" + openapi_generator(; port=8080, use_sudo=false) + +Start an OpenAPI Generator Online container. Returns the URL of the OpenAPI Generator. + +Optional arguments: +- `port`: The port to use for the OpenAPI Generator. Defaults to 8080. +- `use_sudo`: Whether to use `sudo` to run Docker commands. Defaults to false. +""" +function openapi_generator(; port::Int=8080, use_sudo::Bool=false) docker = docker_cmd(; use_sudo=use_sudo) - find_cmd = `$docker ps -a -q -f ancestor=$image_name` - container_id = strip(String(read(find_cmd))) - - if !isempty(container_id) - stop_cmd = `$docker stop $container_id` - stop_res = strip(String(read(stop_cmd))) + cmd = `$docker run -d --rm -p $port:8080 $(OpenAPIImage.GeneratorOnline)` + return _start_docker(cmd, port) +end - if stop_res == container_id - @debug("Stopped Swagger container") - elseif isempty(stop_res) - @debug("Swagger container not running") - else - @error("Failed to stop Swagger container: $stop_res") - return false - end +function _strip_trailing_pathsep(path::AbstractString) + if endswith(path, '/') + return path[1:end-1] + end + return path +end - container_id = strip(String(read(find_cmd))) - if !isempty(container_id) - rm_cmd = `$docker rm $container_id` - rm_res = strip(String(read(rm_cmd))) +""" + generate( + spec::Dict{String,Any}; + type::Symbol=:client, + package_name::AbstractString="APIClient", + export_models::Bool=false, + export_operations::Bool=false, + output_dir::AbstractString="", + generator_host::AbstractString=GeneratorHost.Local + ) - if rm_res == container_id - @debug("Removed Swagger container") - elseif isempty(rm_res) - @debug("Swagger container not found") - else - @error("Failed to remove Swagger container: $rm_res") - return false - end - end +Generate client or server code from an OpenAPI spec using the OpenAPI Generator. +The OpenAPI Generator must be running at the specified `generator_host`. - return true +Returns the path to the generated code. + +Optional arguments: +- `type`: The type of code to generate. Must be `:client` or `:server`. Defaults to `:client`. +- `package_name`: The name of the package to generate. Defaults to "APIClient". +- `export_models`: Whether to export models. Defaults to false. +- `export_operations`: Whether to export operations. Defaults to false. +- `output_dir`: The directory to save the generated code. Defaults to a temporary directory. Directory will be created if it does not exist. +- `generator_host`: The host of the OpenAPI Generator. Defaults to `GeneratorHost.Local`. + Other possible values are `GeneratorHost.OpenAPIGeneratorTech.Stable` or `GeneratorHost.OpenAPIGeneratorTech.Master`, which point to + the service hosted by OpenAPI org. It can also be any other URL where the OpenAPI Generator is running. + +A locally hosted generator service is preferred by default for privacy reasons. +Use `openapi_generator` to start a local container. +Use `stop_openapi_generator` to stop the local generator service after use. +""" +function generate( + spec::Dict{String,Any}; + type::Symbol=:client, + package_name::AbstractString="APIClient", + export_models::Bool=false, + export_operations::Bool=false, + output_dir::AbstractString="", + generator_host::AbstractString=GeneratorHost.Local, +) + if type === :client + generator_path = "clients/julia-client" + elseif type === :server + generator_path = "servers/julia-server" else - @debug("Swagger container not found") + throw(ArgumentError("Invalid generator type: $type. Must be :client or :server")) end - return false -end + if isempty(output_dir) + output_dir = mktempdir() + end -function _start_swagger(cmd, port) - run(cmd) - return "http://localhost:$port" + url = _strip_trailing_pathsep(generator_host) * "/api/gen/" * generator_path + post_json = Dict{String,Any}( + "spec" => spec, + "options" => Dict{String,Any}( + "packageName" => package_name, + "exportModels" => string(export_models), + "exportOperations" => string(export_operations), + ) + ) + + out = PipeBuffer() + inp = PipeBuffer() + JSON.print(inp, post_json, 4) + closewrite(inp) + Downloads.request(url; method="POST", headers=GeneratorHeaders, input=inp, output=out, throw=true) + res = JSON.parse(out) + + url = res["link"] + mktempdir() do extracted_dir + mktempdir() do download_dir + output_file = joinpath(download_dir, "generated.zip") + open(output_file, "w") do out + Downloads.request(url; method="GET", output=out) + end + + p7zip = p7zip_jll.p7zip() + run(`$p7zip x -o$extracted_dir $output_file`) + + # we expect a single containing root directory in the extrated zip, the contents of which we move to the output directory + root_dir = only(readdir(extracted_dir)) + mkpath(output_dir) + for entry in readdir(joinpath(extracted_dir, root_dir)) + mv(joinpath(extracted_dir, root_dir, entry), joinpath(output_dir, entry); force=true) + end + end + end + + return output_dir end """