├── .editorconfig ├── .github └── workflows │ ├── documentation-coverage.yaml │ ├── documentation.yaml │ ├── rubocop.yaml │ ├── test-coverage.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── .mailmap ├── .rubocop.yml ├── async-rest.gemspec ├── config └── sus.rb ├── examples ├── github │ └── feed.rb ├── ollama │ └── ollama.rb ├── slack │ └── clean.rb └── xkcd │ └── comic.rb ├── fixtures └── async │ └── rest │ └── a_wrapper.rb ├── gems.rb ├── guides └── getting-started │ └── readme.md ├── lib └── async │ ├── rest.rb │ └── rest │ ├── error.rb │ ├── representation.rb │ ├── resource.rb │ ├── version.rb │ ├── wrapper.rb │ └── wrapper │ ├── form.rb │ ├── generic.rb │ ├── json.rb │ └── url_encoded.rb ├── license.md ├── readme.md ├── release.cert └── test └── async └── rest ├── dns.rb ├── representation.rb ├── resource.rb └── wrapper ├── generic.rb ├── json.rb └── url_encoded.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.3" 21 | bundler-cache: true 22 | 23 | - name: Validate coverage 24 | timeout-minutes: 5 25 | run: bundle exec bake decode:index:coverage lib 26 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.3" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Run RuboCop 23 | timeout-minutes: 10 24 | run: bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.3" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | include-hidden-files: true 40 | if-no-files-found: error 41 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 42 | path: .covered.db 43 | 44 | validate: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: "3.3" 53 | bundler-cache: true 54 | 55 | - uses: actions/download-artifact@v4 56 | 57 | - name: Validate coverage 58 | timeout-minutes: 5 59 | run: bundle exec bake covered:validate --paths */.covered.db \; 60 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - "3.1" 24 | - "3.2" 25 | - "3.3" 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{matrix.ruby}} 32 | bundler-cache: true 33 | 34 | - name: Run tests 35 | timeout-minutes: 10 36 | run: bundle exec bake test:external 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | 28 | experimental: [false] 29 | 30 | include: 31 | - os: ubuntu 32 | ruby: truffleruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: jruby 36 | experimental: true 37 | - os: ubuntu 38 | ruby: head 39 | experimental: true 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{matrix.ruby}} 46 | bundler-cache: true 47 | 48 | - name: Run tests 49 | timeout-minutes: 10 50 | run: bundle exec bake test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Terry Kerr -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | 4 | Layout/IndentationStyle: 5 | Enabled: true 6 | EnforcedStyle: tabs 7 | 8 | Layout/InitialIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationWidth: 12 | Enabled: true 13 | Width: 1 14 | 15 | Layout/IndentationConsistency: 16 | Enabled: true 17 | EnforcedStyle: normal 18 | 19 | Layout/BlockAlignment: 20 | Enabled: true 21 | 22 | Layout/EndAlignment: 23 | Enabled: true 24 | EnforcedStyleAlignWith: start_of_line 25 | 26 | Layout/BeginEndAlignment: 27 | Enabled: true 28 | EnforcedStyleAlignWith: start_of_line 29 | 30 | Layout/ElseAlignment: 31 | Enabled: true 32 | 33 | Layout/DefEndAlignment: 34 | Enabled: true 35 | 36 | Layout/CaseIndentation: 37 | Enabled: true 38 | 39 | Layout/CommentIndentation: 40 | Enabled: true 41 | 42 | Layout/EmptyLinesAroundClassBody: 43 | Enabled: true 44 | 45 | Layout/EmptyLinesAroundModuleBody: 46 | Enabled: true 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/StringLiterals: 52 | Enabled: true 53 | EnforcedStyle: double_quotes 54 | -------------------------------------------------------------------------------- /async-rest.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/async/rest/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "async-rest" 7 | spec.version = Async::REST::VERSION 8 | 9 | spec.summary = "A library for RESTful clients (and hopefully servers)." 10 | spec.authors = ["Samuel Williams", "Olle Jonsson", "Cyril Roelandt", "Terry Kerr"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/async-rest" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/async-rest/", 20 | "source_code_uri" => "https://github.com/socketry/async-rest.git", 21 | } 22 | 23 | spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 24 | 25 | spec.required_ruby_version = ">= 3.1" 26 | 27 | spec.add_dependency "async-http", "~> 0.42" 28 | spec.add_dependency "protocol-http", "~> 0.45" 29 | end 30 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "covered/sus" 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /examples/github/feed.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | 7 | require "async" 8 | require "async/rest" 9 | require "async/rest/wrapper/form" 10 | 11 | require "date" 12 | 13 | URL = "https://api.github.com" 14 | ENDPOINT = Async::HTTP::Endpoint.parse(URL) 15 | 16 | module GitHub 17 | class Wrapper < Async::REST::Wrapper::Form 18 | DEFAULT_CONTENT_TYPES = { 19 | "application/vnd.github.v3+json" => Async::REST::Wrapper::JSON::Parser 20 | } 21 | 22 | def initialize 23 | super(DEFAULT_CONTENT_TYPES) 24 | end 25 | 26 | def parser_for(response) 27 | if content_type = response.headers["content-type"] 28 | if content_type.start_with? "application/json" 29 | return Async::REST::Wrapper::JSON::Parser 30 | end 31 | end 32 | 33 | return super 34 | end 35 | end 36 | 37 | class Representation < Async::REST::Representation[Wrapper] 38 | end 39 | 40 | class User < Representation 41 | end 42 | 43 | class Client < Representation 44 | def user(name) 45 | self.with(User, path: "users/#{name}") 46 | end 47 | end 48 | 49 | module Paginate 50 | include Enumerable 51 | 52 | def represent(metadata, attributes) 53 | resource = @resource.with(path: attributes[:id]) 54 | 55 | representation.new(resource, metadata: metadata, value: attributes) 56 | end 57 | 58 | def each(page: 1, per_page: 50, **parameters) 59 | return to_enum(:each, page: page, per_page: per_page, **parameters) unless block_given? 60 | 61 | while true 62 | items = @resource.get(self.class, page: page, per_page: per_page, **parameters) 63 | 64 | break if items.empty? 65 | 66 | Array(items.value).each do |item| 67 | yield represent(items.metadata, item) 68 | end 69 | 70 | page += 1 71 | 72 | # Was this the last page? 73 | break if items.value.size < per_page 74 | end 75 | end 76 | 77 | def empty? 78 | self.value.empty? 79 | end 80 | end 81 | 82 | class Event < Representation 83 | def created_at 84 | DateTime.parse(value[:created_at]) 85 | end 86 | end 87 | 88 | class Events < Representation 89 | include Paginate 90 | 91 | def representation 92 | Event 93 | end 94 | end 95 | 96 | class User < Representation 97 | def public_events 98 | self.with(Events, path: "events/public") 99 | end 100 | end 101 | end 102 | 103 | puts "Connecting..." 104 | headers = Protocol::HTTP::Headers.new 105 | headers["user-agent"] = "async-rest/GitHub v#{Async::REST::VERSION}" 106 | 107 | GitHub::Client.for(ENDPOINT, headers) do |client| 108 | user = client.user("ioquatix") 109 | 110 | events = user.public_events.to_a 111 | pp events.first.created_at 112 | pp events.last.created_at 113 | end 114 | 115 | puts "done" 116 | -------------------------------------------------------------------------------- /examples/ollama/ollama.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2024, by Samuel Williams. 6 | 7 | require "async/rest" 8 | require "console" 9 | 10 | terminal = Console::Terminal.for($stdout) 11 | terminal[:reply] = terminal.style(:blue) 12 | terminal[:reset] = terminal.reset 13 | 14 | module Ollama 15 | class Wrapper < Async::REST::Wrapper::Generic 16 | APPLICATION_JSON = "application/json" 17 | APPLICATION_JSON_STREAM = "application/x-ndjson" 18 | 19 | def prepare_request(request, payload) 20 | request.headers.add("accept", APPLICATION_JSON) 21 | request.headers.add("accept", APPLICATION_JSON_STREAM) 22 | 23 | if payload 24 | request.headers["content-type"] = APPLICATION_JSON 25 | 26 | request.body = ::Protocol::HTTP::Body::Buffered.new([ 27 | ::JSON.dump(payload) 28 | ]) 29 | end 30 | end 31 | 32 | class StreamingResponseParser < ::Protocol::HTTP::Body::Wrapper 33 | def initialize(...) 34 | super 35 | 36 | @buffer = String.new.b 37 | @offset = 0 38 | 39 | @response = String.new 40 | @value = {response: @response} 41 | end 42 | 43 | def read 44 | return if @buffer.nil? 45 | 46 | while true 47 | if index = @buffer.index("\n", @offset) 48 | line = @buffer.byteslice(@offset, index - @offset) 49 | @buffer = @buffer.byteslice(index + 1, @buffer.bytesize - index - 1) 50 | @offset = 0 51 | 52 | return ::JSON.parse(line, symbolize_names: true) 53 | end 54 | 55 | if chunk = super 56 | @buffer << chunk 57 | else 58 | return nil if @buffer.empty? 59 | 60 | line = @buffer 61 | @buffer = nil 62 | @offset = 0 63 | 64 | return ::JSON.parse(line, symbolize_names: true) 65 | end 66 | end 67 | end 68 | 69 | def each 70 | super do |line| 71 | token = line.delete(:response) 72 | @response << token 73 | @value.merge!(line) 74 | 75 | yield token 76 | end 77 | end 78 | 79 | def join 80 | self.each{} 81 | 82 | return @value 83 | end 84 | end 85 | 86 | def parser_for(response) 87 | case response.headers["content-type"] 88 | when APPLICATION_JSON 89 | return Async::REST::Wrapper::JSON::Parser 90 | when APPLICATION_JSON_STREAM 91 | return StreamingResponseParser 92 | end 93 | end 94 | end 95 | 96 | class Generate < Async::REST::Representation[Wrapper] 97 | def response 98 | self.value[:response] 99 | end 100 | 101 | def context 102 | self.value[:context] 103 | end 104 | 105 | def model 106 | self.value[:model] 107 | end 108 | 109 | def generate(prompt, &block) 110 | self.class.post(self.resource, prompt: prompt, context: self.context, model: self.model, &block) 111 | end 112 | end 113 | 114 | class Client < Async::REST::Resource 115 | ENDPOINT = Async::HTTP::Endpoint.parse("http://localhost:11434") 116 | 117 | def generate(prompt, **options, &block) 118 | options[:prompt] = prompt 119 | options[:model] ||= "llama2" 120 | 121 | Generate.post(self.with(path: "/api/generate"), options, &block) 122 | end 123 | end 124 | end 125 | 126 | Ollama::Client.open do |client| 127 | generator = client 128 | 129 | while input = $stdin.gets 130 | generator = generator.generate(input) do |response| 131 | terminal.write terminal[:reply] 132 | 133 | response.body.each do |token| 134 | terminal.write token 135 | end 136 | 137 | terminal.puts terminal[:reset] 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /examples/slack/clean.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | 7 | require "pry" 8 | require "set" 9 | 10 | def rate_limited?(response) 11 | pp response 12 | 13 | response[:error] == "ratelimited" 14 | end 15 | 16 | require "async/rest" 17 | 18 | URL = "https://slack.com/api" 19 | TOKEN = "xoxp-your-api-token" 20 | 21 | Async::REST::Resource.for(URL) do |resource| 22 | authenticated = resource.with(parameters: {token: TOKEN}) 23 | delete = authenticated.with(path: "chat.delete") 24 | 25 | page = 1 26 | while true 27 | search = authenticated.with(path: "search.messages", parameters: {page: page, count: 100, query: "from:@username before:2019-02-15"}) 28 | representation = search.get 29 | 30 | messages = representation.value[:messages] 31 | matches = messages[:matches] 32 | 33 | puts "Found #{matches.size} messages on page #{page} out of #{messages[:total]}..." 34 | 35 | break if matches.empty? 36 | 37 | matches.each do |message| 38 | text = message[:text] 39 | channel_id = message[:channel][:id] 40 | channel_name = message[:channel][:name] 41 | timestamp = message[:ts] 42 | 43 | pp [timestamp, channel_name, text] 44 | 45 | message_delete = Async::REST::Representation.new( 46 | delete.with(parameters: {channel: channel_id, ts: timestamp}) 47 | ) 48 | 49 | response = message_delete.post 50 | if rate_limited?(response.read) 51 | puts "Rate limiting..." 52 | Async::Task.current.sleep 10 53 | end 54 | end 55 | 56 | page += 1 57 | end 58 | end 59 | 60 | puts "Done" 61 | -------------------------------------------------------------------------------- /examples/xkcd/comic.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2019-2024, by Samuel Williams. 6 | 7 | require_relative "../../lib/async/rest" 8 | require_relative "../../lib/async/rest/wrapper/url_encoded" 9 | 10 | require "nokogiri" 11 | 12 | Async.logger.debug! 13 | 14 | module XKCD 15 | module Wrapper 16 | # This defines how we interact with the XKCD service. 17 | class HTML < Async::REST::Wrapper::URLEncoded 18 | TEXT_HTML = "text/html" 19 | 20 | # How to process the response body. 21 | class Parser < ::Protocol::HTTP::Body::Wrapper 22 | def join 23 | Nokogiri::HTML(super) 24 | end 25 | end 26 | 27 | # We wrap the response body with the parser (it could incrementally parse the body). 28 | def wrap_response(response) 29 | if body = response.body 30 | response.body = Parser.new(body) 31 | end 32 | end 33 | 34 | def process_response(request, response) 35 | if content_type = response.headers["content-type"] 36 | if content_type.start_with? TEXT_HTML 37 | wrap_response(response) 38 | else 39 | raise Error, "Unknown content type: #{content_type}!" 40 | end 41 | end 42 | 43 | return response 44 | end 45 | end 46 | end 47 | 48 | # A comic representation. 49 | class Comic < Async::REST::Representation[Wrapper::HTML] 50 | def image_url 51 | self.value.css("#comic img").attribute("src").text 52 | end 53 | end 54 | end 55 | 56 | Async do 57 | URL = "https://xkcd.com/" 58 | 59 | Async::REST::Resource.for(URL) do |resource| 60 | (2000..2010).each do |id| 61 | Async do 62 | representation = resource.with(path: "/#{id}/").get(XKCD::Comic) 63 | 64 | p representation.image_url 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /fixtures/async/rest/a_wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "sus/fixtures/async/http/server_context" 7 | 8 | require "async/rest/resource" 9 | require "async/rest/representation" 10 | 11 | module Async 12 | module REST 13 | AWrapper = Sus::Shared("a wrapper") do 14 | include Sus::Fixtures::Async::HTTP::ServerContext 15 | 16 | let(:resource) {Async::REST::Resource.open(bound_url)} 17 | let(:representation) {Async::REST::Representation[wrapper]} 18 | 19 | let(:middleware) do 20 | Protocol::HTTP::Middleware.for do |request| 21 | if request.headers["content-type"] == wrapper.content_type 22 | # Echo it back: 23 | Protocol::HTTP::Response[200, request.headers, request.body] 24 | else 25 | Protocol::HTTP::Response[400, {}, ["Invalid content type!"]] 26 | end 27 | end 28 | end 29 | 30 | let(:payload) {{username: "Frederick", password: "Fish"}} 31 | 32 | it "can post payload representation" do 33 | instance = representation.post(resource, payload) 34 | 35 | expect(instance).to be_a(representation) 36 | expect(instance.value).to be == payload 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | group :maintenance, optional: true do 11 | gem "bake-modernize" 12 | gem "bake-gem" 13 | 14 | gem "utopia-project" 15 | end 16 | 17 | group :test do 18 | gem "sus" 19 | gem "covered" 20 | gem "decode" 21 | gem "rubocop" 22 | 23 | gem "sus-fixtures-async-http" 24 | 25 | gem "bake-test" 26 | gem "bake-test-external" 27 | end 28 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains the design of the `async-rest` gem and how to use it to access RESTful APIs. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ``` shell 10 | $ bundle add async-rest 11 | ``` 12 | 13 | ## Core Concepts 14 | 15 | The `async-rest` gem has two core concepts: 16 | 17 | - A {ruby Async::REST::Resource} instance represents a specific resource and a delegate (HTTP connection) for accessing that resource. 18 | - A {ruby Async::REST::Representation} instance represents a specific representation of a resource - usually a specific request to a URL that returns a response with a given content type. 19 | 20 | Just as a webpage has hyperlinks, forms and buttons for connecting information and performing actions, a representation may also carry associated links to actions that can be performed on a resource. However, many services define a fixed set of actions that can be performed on a given resource using a schema. As such, the `async-rest` gem does not have a standard mechanism for discovering actions on a resource at runtime (or follow a design that requires this). 21 | 22 | ## Usage 23 | 24 | Generally speaking, you should model your interface around representations. Each representation should be a subclass of {ruby Async::REST::Representation} and define methods that represent the actions that can be performed on that resource. 25 | 26 | ```ruby 27 | require 'async/rest' 28 | 29 | module DNS 30 | class Query < Async::REST::Representation[Async::REST::Wrapper::JSON] 31 | def question 32 | value[:Question] 33 | end 34 | 35 | def answer 36 | value[:Answer] 37 | end 38 | end 39 | 40 | class Client < Async::REST::Resource 41 | # This is the default endpoint to use unless otherwise specified: 42 | ENDPOINT = Async::HTTP::Endpoint.parse('https://dns.google/resolve') 43 | 44 | # Resolve a DNS query. 45 | def resolve(name, type) 46 | Query.get(self.with(parameters: { name: name, type: type })) 47 | end 48 | end 49 | end 50 | 51 | DNS::Client.open do |client| 52 | query = client.resolve('example.com', 'AAAA') 53 | 54 | puts query.question 55 | # {:name=>"example.com.", :type=>28} 56 | puts query.answer 57 | # {:name=>"example.com.", :type=>28, :TTL=>13108, :data=>"2606:2800:220:1:248:1893:25c8:1946"} 58 | end 59 | ``` 60 | 61 | It should be noted that the above client is not a representation, but a resource. That is because `https://dns.google.com/resolve` is a fixed endpoint that does not have a schema for discovering actions at runtime. The `resolve` method is a convenience method that creates a new representation and performs a GET request to the endpoint. -------------------------------------------------------------------------------- /lib/async/rest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require_relative "rest/version" 7 | require_relative "rest/representation" 8 | require_relative "rest/wrapper" 9 | -------------------------------------------------------------------------------- /lib/async/rest/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2023, by Samuel Williams. 5 | 6 | module Async 7 | module REST 8 | class Error < StandardError 9 | end 10 | 11 | class RequestError < Error 12 | end 13 | 14 | class UnsupportedError < Error 15 | end 16 | 17 | class ResponseError < Error 18 | def initialize(response) 19 | super(response.read) 20 | 21 | @response = response 22 | end 23 | 24 | def to_s 25 | "#{@response}: #{super}" 26 | end 27 | 28 | attr :response 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/async/rest/representation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | # Copyright, 2021, by Terry Kerr. 6 | 7 | require_relative "error" 8 | require_relative "resource" 9 | require_relative "wrapper/json" 10 | 11 | module Async 12 | module REST 13 | # A representation of a resource has a value at the time the representation was created or fetched. It is usually generated by performing an HTTP request. 14 | # 15 | # REST components perform actions on a resource by using a representation to capture the current or intended state of that resource and transferring that representation between components. A representation is a sequence of bytes, plus representation metadata to describe those bytes. Other commonly used but less precise names for a representation include: document, file, and HTTP message entity, instance, or variant. 16 | # 17 | # A representation consists of data, metadata describing the data, and, on occasion, metadata to describe the metadata (usually for the purpose of verifying message integrity). Metadata is in the form of name-value pairs, where the name corresponds to a standard that defines the value's structure and semantics. Response messages may include both representation metadata and resource metadata: information about the resource that is not specific to the supplied representation. 18 | class Representation 19 | WRAPPER = Wrapper::JSON.new 20 | 21 | def self.[] wrapper 22 | klass = Class.new(self) 23 | 24 | if wrapper.is_a?(Class) 25 | wrapper = wrapper.new 26 | end 27 | 28 | klass.const_set(:WRAPPER, wrapper) 29 | 30 | return klass 31 | end 32 | 33 | class << self 34 | ::Protocol::HTTP::Methods.each do |name, method| 35 | define_method(method.downcase) do |resource, payload = nil, &block| 36 | self::WRAPPER.call(resource, method, payload) do |response| 37 | return self.for(resource, response, &block) 38 | end 39 | end 40 | end 41 | end 42 | 43 | # Instantiate a new representation from a resource and response. 44 | # 45 | # If a block is given, it is called with the resource and response, and the return value is used as the representation. 46 | # 47 | # @returns [Representation] the representation of the resource. 48 | def self.for(resource, response, &block) 49 | if block_given? 50 | return yield(resource, response, self) 51 | else 52 | return self.new(resource, value: response.read, metadata: response.headers) 53 | end 54 | end 55 | 56 | # @param resource [Resource] the RESTful resource that this representation is of. 57 | # @param metadata [Hash | HTTP::Headers] the metadata associated with the representation. 58 | # @param value [Object] the value of the representation. 59 | def initialize(resource, value: nil, metadata: {}) 60 | @resource = resource 61 | 62 | @value = value 63 | @metadata = metadata 64 | end 65 | 66 | def with(klass = nil, **options) 67 | if klass 68 | klass.new(@resource.with(**options)) 69 | else 70 | self.class.new(@resource.with(**options)) 71 | end 72 | end 73 | 74 | def [] **parameters 75 | self.with(parameters: parameters) 76 | end 77 | 78 | def close 79 | @resource.close 80 | end 81 | 82 | attr :resource 83 | attr :metadata 84 | 85 | private def get 86 | self.class::WRAPPER.call(@resource) do |response| 87 | if response.success? 88 | @metadata = response.headers 89 | @value = response.read 90 | else 91 | raise ResponseError, response 92 | end 93 | end 94 | end 95 | 96 | def value? 97 | !@value.nil? 98 | end 99 | 100 | def value 101 | @value ||= self.get 102 | end 103 | 104 | # Provides a way to mutate the value of the representation. 105 | module Mutable 106 | def post(value) 107 | self.class.post(@resource, value) do |resource, response| 108 | @value = response.read 109 | 110 | self 111 | end 112 | end 113 | 114 | def delete 115 | self.class.delete(@resource) 116 | end 117 | 118 | def assign(value) 119 | if value 120 | self.post(value) 121 | else 122 | self.delete 123 | end 124 | end 125 | end 126 | 127 | def inspect 128 | "\#<#{self.class} #{@resource.path.inspect} value=#{@value.inspect}>" 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/async/rest/resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "async" 7 | require "async/http/client" 8 | require "async/http/endpoint" 9 | 10 | require "protocol/http/accept_encoding" 11 | require "protocol/http/reference" 12 | 13 | module Async 14 | module REST 15 | # A resource is an abstract reference to some named entity, typically a URL with associated metatadata. Generally, your entry to any web service will be a resource, and that resource will create zero or more representations as you navigate through the service. 16 | # 17 | # The key abstraction of information in REST is a resource. Any information that can be named can be a resource: a document or image, a temporal service (e.g. "today's weather in Los Angeles"), a collection of other resources, a non-virtual object (e.g. a person), and so on. In other words, any concept that might be the target of an author's hypertext reference must fit within the definition of a resource. A resource is a conceptual mapping to a set of entities, not the entity that corresponds to the mapping at any particular point in time. 18 | class Resource < ::Protocol::HTTP::Middleware 19 | ENDPOINT = nil 20 | 21 | # Connect to the given endpoint, returning the HTTP client and reference. 22 | # @parameter endpoint [Async::HTTP::Endpoint] used to connect to the remote system and specify the base path. 23 | # @returns [Tuple(Async::HTTP::Client, ::Protocol::HTTP::Reference)] the client and reference. 24 | def self.connect(endpoint) 25 | reference = ::Protocol::HTTP::Reference.parse(endpoint.path) 26 | 27 | return ::Protocol::HTTP::AcceptEncoding.new(HTTP::Client.new(endpoint)), reference 28 | end 29 | 30 | # Create a new resource for the given endpoint. 31 | def self.open(endpoint = self::ENDPOINT, **options) 32 | if endpoint.is_a?(String) 33 | endpoint = Async::HTTP::Endpoint.parse(endpoint) 34 | end 35 | 36 | client, reference = connect(endpoint) 37 | 38 | resource = self.new(client, reference, **options) 39 | 40 | return resource unless block_given? 41 | 42 | Sync do 43 | yield resource 44 | ensure 45 | resource.close 46 | end 47 | end 48 | 49 | def self.with(parent, headers: {}, **options) 50 | reference = parent.reference.with(**options) 51 | headers = parent.headers.merge(headers) 52 | 53 | self.new(parent.delegate, reference, headers) 54 | end 55 | 56 | # @parameter delegate [Async::HTTP::Middleware] the delegate that will handle requests. 57 | # @parameter reference [::Protocol::HTTP::Reference] the resource identifier (base request path/parameters). 58 | # @parameter headers [::Protocol::HTTP::Headers] the default headers that will be supplied with the request. 59 | def initialize(delegate, reference = ::Protocol::HTTP::Reference.parse, headers = ::Protocol::HTTP::Headers.new) 60 | super(delegate) 61 | 62 | @reference = reference 63 | @headers = headers 64 | end 65 | 66 | attr :reference 67 | attr :headers 68 | 69 | def with(**options) 70 | self.class.with(self, **options) 71 | end 72 | 73 | def path 74 | @reference.path 75 | end 76 | 77 | def inspect 78 | "\#<#{self.class} #{@reference.inspect} #{@headers.inspect}>" 79 | end 80 | 81 | def to_s 82 | "\#<#{self.class} #{@reference.to_s}>" 83 | end 84 | 85 | def call(request) 86 | request.path = @reference.with(path: request.path).to_s 87 | request.headers = @headers.merge(request.headers) 88 | 89 | super 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/async/rest/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | module Async 7 | module REST 8 | VERSION = "0.19.1" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/async/rest/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "wrapper/form" 7 | require_relative "wrapper/json" 8 | require_relative "wrapper/url_encoded" 9 | -------------------------------------------------------------------------------- /lib/async/rest/wrapper/form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require_relative "json" 7 | require_relative "url_encoded" 8 | 9 | module Async 10 | module REST 11 | module Wrapper 12 | class Form < Generic 13 | DEFAULT_CONTENT_TYPES = { 14 | JSON::APPLICATION_JSON => JSON::Parser, 15 | URLEncoded::APPLICATION_FORM_URLENCODED => URLEncoded::Parser, 16 | } 17 | 18 | def initialize(content_types = DEFAULT_CONTENT_TYPES) 19 | @content_types = content_types 20 | end 21 | 22 | def prepare_request(request, payload) 23 | @content_types.each_key do |key| 24 | request.headers.add("accept", key) 25 | end 26 | 27 | if payload 28 | request.headers["content-type"] = URLEncoded::APPLICATION_FORM_URLENCODED 29 | 30 | request.body = ::Protocol::HTTP::Body::Buffered.new([ 31 | ::Protocol::HTTP::URL.encode(payload) 32 | ]) 33 | end 34 | end 35 | 36 | def parser_for(response) 37 | media_type, _ = response.headers["content-type"].split(";") 38 | if media_type && parser = @content_types[media_type] 39 | return parser 40 | end 41 | 42 | return super 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/async/rest/wrapper/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | module Async 7 | module REST 8 | module Wrapper 9 | class Generic 10 | def retry_after_duration(value) 11 | retry_after_time = Time.httpdate(value) 12 | return [retry_after_time - Time.now, 0].max 13 | rescue ArgumentError 14 | return value.to_f 15 | end 16 | 17 | def response_for(resource, request) 18 | while true 19 | response = resource.call(request) 20 | 21 | if response.status == 429 22 | if retry_after = response.headers["retry-after"] 23 | sleep(retry_after_duration(retry_after)) 24 | else 25 | # Without the `retry-after` header, we can't determine how long to wait, so we just return the response. 26 | return response 27 | end 28 | else 29 | return response 30 | end 31 | end 32 | end 33 | 34 | def call(resource, method = "GET", payload = nil, &block) 35 | request = ::Protocol::HTTP::Request[method, nil] 36 | 37 | self.prepare_request(request, payload) 38 | 39 | response = self.response_for(resource, request) 40 | 41 | # If we exit this block because of an exception, we close the response. This ensures we don't have any dangling connections. 42 | begin 43 | self.process_response(request, response) 44 | 45 | yield response 46 | rescue 47 | response.close 48 | 49 | raise 50 | end 51 | end 52 | 53 | # @param payload [Object] a request payload to send. 54 | # @param headers [Protocol::HTTP::Headers] the mutable HTTP headers for the request. 55 | # @return [Body | nil] an optional request body based on the given payload. 56 | def prepare_request(request, payload) 57 | request.body = ::Protocol::HTTP::Body::Buffered.wrap(payload) 58 | end 59 | 60 | # @param request [Protocol::HTTP::Request] the request that was made. 61 | # @param response [Protocol::HTTP::Response] the response that was received. 62 | # @return [Object] some application specific representation of the response. 63 | def process_response(request, response) 64 | wrap_response(response) 65 | end 66 | 67 | def parser_for(response) 68 | # It's not always clear why this error is being thrown. 69 | return nil 70 | end 71 | 72 | # Wrap the response body in the given klass. 73 | def wrap_response(response) 74 | if body = response.body 75 | if parser = parser_for(response) 76 | response.body = parser.new(body) 77 | end 78 | end 79 | 80 | return response 81 | end 82 | 83 | class Unsupported < ::Protocol::HTTP::Body::Wrapper 84 | def join 85 | raise UnsupportedError, super 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/async/rest/wrapper/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "json" 7 | 8 | require "protocol/http/body/wrapper" 9 | require "protocol/http/body/buffered" 10 | 11 | require_relative "generic" 12 | 13 | module Async 14 | module REST 15 | module Wrapper 16 | class JSON < Generic 17 | APPLICATION_JSON = "application/json".freeze 18 | APPLICATION_JSON_STREAM = "application/json; boundary=NL".freeze 19 | 20 | def initialize(content_type = APPLICATION_JSON) 21 | @content_type = content_type 22 | end 23 | 24 | attr :content_type 25 | 26 | def split(*arguments) 27 | @content_type.split 28 | end 29 | 30 | def prepare_request(request, payload) 31 | request.headers["accept"] ||= @content_type 32 | 33 | if payload 34 | request.headers["content-type"] = @content_type 35 | 36 | request.body = ::Protocol::HTTP::Body::Buffered.new([ 37 | ::JSON.dump(payload) 38 | ]) 39 | end 40 | end 41 | 42 | class Parser < ::Protocol::HTTP::Body::Wrapper 43 | def join 44 | ::JSON.parse(super, symbolize_names: true) 45 | end 46 | end 47 | 48 | def parser_for(response) 49 | if content_type = response.headers["content-type"] 50 | if content_type.start_with? @content_type 51 | return Parser 52 | end 53 | end 54 | 55 | return super 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/async/rest/wrapper/url_encoded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2019-2024, by Samuel Williams. 5 | 6 | require "json" 7 | 8 | require "protocol/http/body/wrapper" 9 | require "protocol/http/body/buffered" 10 | 11 | require_relative "generic" 12 | 13 | module Async 14 | module REST 15 | module Wrapper 16 | class URLEncoded < Generic 17 | APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded".freeze 18 | 19 | def initialize(content_type = APPLICATION_FORM_URLENCODED) 20 | @content_type = content_type 21 | end 22 | 23 | attr :content_type 24 | 25 | def split(*arguments) 26 | @content_type.split 27 | end 28 | 29 | def prepare_request(request, payload) 30 | request.headers["accept"] ||= @content_type 31 | 32 | if payload 33 | request.headers["content-type"] = @content_type 34 | 35 | request.body = ::Protocol::HTTP::Body::Buffered.new([ 36 | ::Protocol::HTTP::URL.encode(payload) 37 | ]) 38 | end 39 | end 40 | 41 | class Parser < ::Protocol::HTTP::Body::Wrapper 42 | def join 43 | ::Protocol::HTTP::URL.decode(super, symbolize_keys: true) 44 | end 45 | end 46 | 47 | def parser_for(response) 48 | if content_type = response.headers["content-type"] 49 | if content_type.start_with? @content_type 50 | return Parser 51 | end 52 | end 53 | 54 | return super 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2018-2024, by Samuel Williams. 4 | Copyright, 2019, by Cyril Roelandt. 5 | Copyright, 2020-2021, by Olle Jonsson. 6 | Copyright, 2021, by Terry Kerr. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Async::REST 2 | 3 | Roy Thomas Fielding's thesis [Architectural Styles and the Design of Network-based Software Architectures](https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm) describes [Representational State Transfer](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) which comprises several core concepts: 4 | 5 | - `Resource`: A conceptual mapping to one or more entities. 6 | - `Representation`: An instance of a resource at a given point in time. 7 | 8 | This gem models these abstractions as closely and practically as possible and serves as a basis for building asynchronous web clients. 9 | 10 | [![Development Status](https://github.com/socketry/async-rest/workflows/Test/badge.svg)](https://github.com/socketry/async-rest/actions?workflow=Test) 11 | 12 | ## Usage 13 | 14 | Please see the [project documentation](https://socketry.github.io/async-rest/) for more details. 15 | 16 | - [Getting Started](https://socketry.github.io/async-rest/guides/getting-started/index) - This guide explains the design of the `async-rest` gem and how to use it to access RESTful APIs. 17 | 18 | ## See Also 19 | 20 | - [async-ollama](https://github.com/socketry/async-ollama) - A client for Ollama, a local large language model server. 21 | - [async-discord](https://github.com/socketry/async-discord) - A client for Discord, a popular chat platform. 22 | - [cloudflare](https://github.com/socketry/cloudflare) - A client for Cloudflare, a popular CDN and DDoS protection service. 23 | - [async-slack](https://github.com/socketry/async-slack) - A client for Slack, a popular chat platform. 24 | 25 | ## Contributing 26 | 27 | We welcome contributions to this project. 28 | 29 | 1. Fork it. 30 | 2. Create your feature branch (`git checkout -b my-new-feature`). 31 | 3. Commit your changes (`git commit -am 'Add some feature'`). 32 | 4. Push to the branch (`git push origin my-new-feature`). 33 | 5. Create new Pull Request. 34 | 35 | ### Developer Certificate of Origin 36 | 37 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 38 | 39 | ### Community Guidelines 40 | 41 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 42 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /test/async/rest/dns.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2018-2024, by Samuel Williams. 5 | 6 | require "sus/fixtures/async/reactor_context" 7 | require "async/rest/resource" 8 | require "async/rest/representation" 9 | 10 | module DNS 11 | class Query < Async::REST::Representation[Async::REST::Wrapper::JSON] 12 | def question 13 | value[:Question] 14 | end 15 | 16 | def answer 17 | value[:Answer] 18 | end 19 | end 20 | end 21 | 22 | describe Async::REST::Resource do 23 | include Sus::Fixtures::Async::ReactorContext 24 | 25 | let(:url) {"https://dns.google.com/resolve"} 26 | let(:resource) {subject.open(url)} 27 | 28 | it "can get resource" do 29 | # The first argument is the representation class to use: 30 | query = DNS::Query.get(resource.with(parameters: {name: "example.com", type: "AAAA"})) 31 | 32 | expect(query.value).to have_keys(:Question, :Answer) 33 | 34 | resource.close 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/async/rest/representation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require "async/rest/representation" 7 | 8 | describe Async::REST::Representation do 9 | let(:base) {Class.new(subject)} 10 | let(:representation_class) {base[Async::REST::Wrapper::JSON]} 11 | 12 | with ".[]" do 13 | it "uses specified base class" do 14 | expect(representation_class::WRAPPER).to be_a(Async::REST::Wrapper::JSON) 15 | expect(representation_class.superclass).to be_equal(base) 16 | end 17 | end 18 | 19 | with ".for" do 20 | let(:resource) {Async::REST::Resource.new(nil)} 21 | let(:response) {Protocol::HTTP::Response[200, {}, nil]} 22 | 23 | it "can construct a representation" do 24 | expect(response).to receive(:read).and_return({test: 123}) 25 | expect(response).to receive(:headers).and_return({test: 456}) 26 | 27 | expect(representation_class).to receive(:new) 28 | representation = representation_class.for(resource, response) 29 | 30 | expect(representation.value).to be == {test: 123} 31 | expect(representation.metadata).to be == {test: 456} 32 | end 33 | 34 | it "can construct a representation with a block" do 35 | expect(representation_class).not.to receive(:new) 36 | 37 | representation = representation_class.for(resource, response) do |resource, response| 38 | [resource, response] 39 | end 40 | 41 | expect(representation).to be == [resource, response] 42 | end 43 | end 44 | 45 | with "#with" do 46 | let(:resource) {Async::REST::Resource.new(nil)} 47 | let(:representation) {subject.new(resource)} 48 | 49 | it "uses specified base class wrapper" do 50 | expect(resource).to receive(:with) 51 | custom_representation = representation.with(representation_class) 52 | 53 | expect(custom_representation).to be_a representation_class 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/async/rest/resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "async/rest/resource" 7 | 8 | describe Async::REST::Resource do 9 | let(:url) {"http://example.com"} 10 | let(:resource) {subject.open(url)} 11 | 12 | it "can update path" do 13 | expect(resource.reference.path).to be == "/" 14 | 15 | foo_resource = resource.with(path: "/foo") 16 | expect(foo_resource.reference.path).to be == "/foo" 17 | 18 | bar_resource = foo_resource.with(path: "bar") 19 | expect(bar_resource.reference.path).to be == "/foo/bar" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/async/rest/wrapper/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "async/rest/a_wrapper" 7 | require "async/rest/wrapper/generic" 8 | 9 | describe Async::REST::Wrapper::Generic do 10 | let(:wrapper) {subject.new} 11 | 12 | with "#retry_after_duration" do 13 | it "can parse integer" do 14 | expect(wrapper.retry_after_duration("123")).to be == 123 15 | end 16 | 17 | it "can parse date in the past" do 18 | date = Time.now 19 | 20 | # Technically, this should always be in the past: 21 | expect(wrapper.retry_after_duration(date.httpdate)).to be == 0.0 22 | end 23 | 24 | it "can parse date in the future" do 25 | date = Time.now + 60 26 | 27 | expect(wrapper.retry_after_duration(date.httpdate)).to be > 0.0 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/async/rest/wrapper/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "async/rest/a_wrapper" 7 | require "async/rest/wrapper/json" 8 | 9 | describe Async::REST::Wrapper::JSON do 10 | let(:wrapper) {subject.new} 11 | 12 | it_behaves_like Async::REST::AWrapper 13 | end 14 | -------------------------------------------------------------------------------- /test/async/rest/wrapper/url_encoded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "async/rest/a_wrapper" 7 | require "async/rest/wrapper/url_encoded" 8 | 9 | describe Async::REST::Wrapper::URLEncoded do 10 | let(:wrapper) {subject.new} 11 | 12 | it_behaves_like Async::REST::AWrapper 13 | end 14 | --------------------------------------------------------------------------------