crystal-docker-api | d2fd2cb51ab5a88e2abf0624b1d1197aec93b705 | src/docker/api/api_client.cr ∙ GitFOSS
.cr
Crystal
(text/x-crystal)
require "uri"
require "socket"
require "http/client"
require "json"
require "../../core_ext/http/client"
require "../../core_ext/openssl/**"
require "../../core_ext/named_tuple/camelcase_keys"
require "../errors"
require "./containers"
require "./daemon"
require "./images"
require "./volumes"

# Low-level wrapper for the Docker Engine API.
#
# Each method on maps one-to-one with a REST API endpoint, and either returns the response that the
# API responds with or raises an `Docker::ApiError`. It's possible to use `ApiClient` directly if
# you require the extra flexibility, however in most cases the abstractions provided by the
# higher-level `Docker::Client` may be the better choice.
class Docker::Api::ApiClient
  DEFAULT_URL = "unix:///var/run/docker.sock"

  getter socket_path : String
  getter socket : UNIXSocket

  # API version in use, defaults to latest if `nil`
  property api_version : String = "latest"

  def initialize(base_url = nil, @api_version = "latest")
    base_url ||= DEFAULT_URL
    uri = URI.parse base_url
    @socket_path = uri.to_s

    case uri.scheme
    when "unix"
      # puts "unix socket connection to: #{@socket_path}\n"
      @socket_path = @socket_path.gsub(/unix:\/\//, "")
      @socket = UNIXSocket.new(@socket_path)
    else
      raise NotImplementedError.new("local unix socket only supported at the time")
    end
  end

  {% for method in %w(get post put head delete patch options) %}
    # Builds and executes a GET/POST/... request on a unix socket.
    def unix_{{method.id}}(
      path : String,
      headers : HTTP::Headers? = nil,
      body : HTTP::Client::BodyType? = nil
    )
      headers ||= HTTP::Headers.new
      headers = headers.dup

      headers["Host"] ||= "docker"
      headers["Connection"] = "close"
      headers["Content-Type"] = "application/json"

      if body && !body.empty?
        headers["Content-Length"] = body.bytesize.to_s
      else
        body = nil
      end

      req = HTTP::Request.new(
        "{{method.id.upcase}}",
        URI.parse("http:/#{path}").to_s,
        headers,
        body
      )

      req_serialized = String.build do |s|
        s << "#{req.method} #{path} HTTP/1.1\r\n"
        req.headers.each do |k, v|
          s << "#{k}: #{v.join(", ")}\r\n"
        end
        s << "\r\n"
        s << (req.body ? req.body.to_s : "")
      end

      # open unix socket, write request, read response
      @socket = UNIXSocket.new(@socket_path)

      begin
        @socket.write req_serialized.to_slice
        res_io = IO::Memory.new
        buf = Bytes.new(8192)
        while true
          r = @socket.read(buf) rescue 0
          break if r == 0
          res_io.write buf[0, r]
        end
        res_io.rewind
        res_str = res_io.gets_to_end
        res_io.rewind
      ensure
        @socket.close
      end

      res = HTTP::Client::Response.from_io(res_io) do |response|
        response
      end

      # cleanup response body send over docker socket
      raw = res_str
        .gsub("10f1\r\n", "") # ?
        .gsub("10f", "")      # ?
        .gsub("11\r\n", "")   # ?
        .gsub("11c4\r\n", "") # ?
        .gsub("0\r\n", "")    # ?
        .gsub("1321\r\n", "") # ?
        .gsub("1322\r\n", "") # ?
        .gsub("13\r\n", "")   # ?
        .gsub("14\r\n", "")   # ?
        .gsub("1d\r\n", "")   # ?
        .gsub("4f\r\n", "")   # ?

      # pp path
      # pp raw
      # puts "\n"

      # Parse status line, headers and body
      head, body = raw.split("\r\n\r\n", 2)
      body = body.chomp("\r\n").chomp("\r\n")
      status_line, *header_lines = head.split("\r\n")
      http_version, status_code_str, *status_parts = status_line.split(" ")
      status_code = status_code_str.to_i
      status_message = status_parts.join(" ")

      headers = HTTP::Headers.new
      header_lines.each do |line|
        next if line.empty?
        name, value = line.split(":", 2)
        headers.add name.strip, value.strip
      end

      if headers["Content-Length"]?.nil?
        headers["Content-Length"] = "0"
      end

      HTTP::Client::Response.new(
        status_code,
        status_code != 204 ? body : nil,
        headers,
        status_message,
        http_version,
        status_code != 204 ? IO::Memory.new(body) : IO::Memory.new,
      )
    end

    # Executes a {{method.id.upcase}} request on the docker client connection.
    #
    # The response status will be automatically checked and a Docker::ApiError raised if
    # unsuccessful.
    # ```
    def {{method.id}}(path, headers : HTTP::Headers? = nil, body : HTTP::Client::BodyType? = nil)
      path = "/#{api_version}#{path}" unless api_version.nil?
      response = unix_{{method.id}} path, headers, body
      raise Docker::ApiError.from_response(response) unless response.success?
      response
    end

    # Executes a {{method.id.upcase}} request on the docker client connection with a JSON body
    # formed from the passed `NamedTuple`.
    def {{method.id}}(path, body : NamedTuple)
      headers = HTTP::Headers.new
      headers["Content-Type"] = "application/json"
      {{method.id}} path, headers, body.camelcase_keys.to_json
    end

    # :ditto:
    def {{method.id}}(path, headers : HTTP::Headers, body : NamedTuple)
      {{method.id}} path, headers, body.camelcase_keys.to_json
    end

    # Executes a {{method.id.upcase}} request and yields a `HTTP::Client::Response`.
    #
    # When working with endpoint that provide stream responses these may be accessed as available
    # by calling `#body_io` on the yielded response object.
    #
    # The response status will be automatically checked and a Docker::ApiError raised if
    # unsuccessful.
    def {{method.id}}(path, headers : HTTP::Headers? = nil, body : HTTP::Client::BodyType = nil)
    {{method.id}} path, headers, body do |response|
        raise Docker::ApiError.from_response(response) unless response.success?
        yield response
      end
    end

    # Executes a {{method.id.upcase}} request on the docker client connection with a JSON body
    # formed from the passed `NamedTuple` and yields streamed response entries to the block.
    def {{method.id}}(path, body : NamedTuple)
      headers = HTTP::Headers.new
      headers["Content-Type"] = "application/json"
      {{method.id}} path, headers, body.camelcase_keys.to_json do |response|
        yield response
      end
    end

    # :ditto:
    def {{method.id}}(path, headers : HTTP::Headers, body : NamedTuple)
      headers["Content-Type"] = "application/json"
      {{method.id}} path, headers, body.camelcase_keys.to_json do |response|
        yield response
      end
    end
  {% end %}

  include Containers
  include Daemon
  include Images
  include ::Volumes
end