diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9750250..2bde12f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: - { ruby: 3.1 } - { ruby: 3.2 } - { ruby: 3.3 } + - { ruby: 3.4 } - { ruby: 4.0 } - { ruby: "ruby-head", ignore: true } - { ruby: "jruby-9.4.8.0", ignore: true } diff --git a/.gitignore b/.gitignore index 3c4cfd5..49b7df3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ .byebug_history .history/ .DS_Store +.solargraph.yml +.vscode/ +.idea/ + # Used by dotenv library to load environment variables. # .env @@ -50,3 +54,6 @@ Gemfile.lock # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc + +# AI agent instructions — local only, not for the upstream repo +CLAUDE.md diff --git a/.rubocop.yml b/.rubocop.yml index 81a370a..6116fdc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,8 @@ +inherit_from: .rubocop_todo.yml + AllCops: TargetRubyVersion: 2.3 - -Lint/SplatKeywordArguments: - Enabled: false + NewCops: disable Style/FrozenStringLiteralComment: Enabled: false @@ -13,7 +13,7 @@ Style/Documentation: Style/SafeNavigation: Enabled: false -Metrics/LineLength: +Layout/LineLength: Enabled: false Metrics/MethodLength: @@ -24,5 +24,3 @@ Metrics/BlockLength: Metrics/AbcSize: Enabled: false - -inherit_from: .rubocop_todo.yml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 530f19b..e75f044 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,48 +1,72 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-08-14 17:57:21 -0400 using RuboCop version 0.56.0. +# on 2026-06-11 10:55:02 UTC using RuboCop version 1.87.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. # Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. +Gemspec/RequiredRubyVersion: + Exclude: + - 'graphlient.gemspec' + +# Offense count: 1 +# Configuration parameters: AllowedMethods. +# AllowedMethods: enums +Lint/ConstantDefinitionInBlock: + Exclude: + - 'spec/graphlient/static_client_query_spec.rb' + +# Offense count: 1 +Lint/MixedRegexpCaptureTypes: + Exclude: + - 'lib/graphlient/query.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Lint/NonDeterministicRequireOrder: + Exclude: + - 'spec/spec_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError Lint/UnusedMethodArgument: Exclude: - 'lib/graphlient/adapters/http/http_adapter.rb' -# Offense count: 2 -Metrics/CyclomaticComplexity: - Max: 8 +# Offense count: 1 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ClassLength: + Max: 104 # Offense count: 1 -Metrics/PerceivedComplexity: - Max: 8 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 9 # Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle. +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules. # SupportedStyles: nested, compact +# SupportedStylesForClasses: ~, nested, compact +# SupportedStylesForModules: ~, nested, compact Style/ClassAndModuleChildren: Exclude: - 'spec/graphlient/static_client_query_spec.rb' -# Offense count: 2 -Style/MethodMissingSuper: - Exclude: - - 'lib/graphlient/extensions/query.rb' - - 'lib/graphlient/query.rb' - -# Offense count: 5 +# Offense count: 8 Style/MultilineBlockChain: Exclude: - 'spec/graphlient/client_query_spec.rb' - 'spec/graphlient/client_schema_spec.rb' -# Offense count: 39 -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Metrics/LineLength: - Max: 181 +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: literals, strict +Style/MutableConstant: + Exclude: + - 'lib/graphlient/query.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e35b5..e0aef2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ * [#117](https://github.com/ashkan18/graphlient/pull/117): Migrate Danger to use `danger-pr-comment` reusable workflow, add Ruby 4.0 to CI, fix tests on Ruby 3.4+ and 4.0 - [@dblock](https://github.com/dblock). * [#113](https://github.com/ashkan18/graphlient/pull/113): Fix CI builds - [@yuki24](https://github.com/yuki24). * [#112](https://github.com/ashkan18/graphlient/pull/112): Update graphql-client github repository links in README - [@th1988](https://github.com/th1988). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Client#to_query_string(**kargs, &block)`: builds the full GraphQL query document from the DSL block and returns a `String` without touching the schema or Faraday HTTP adapter. Enables DSL-only mode where HTTP dispatch is handled externally - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `Query#spread(fragment_name)`: emits `...FragmentName` into the query string as a named fragment spread. Alternative to the `___Const` convention for use cases where `graphql-client` constant parsing is not available - [@rellampec](https://github.com/rellampec). +* [#115](https://github.com/ashkan18/graphlient/pull/115): `spec/spec_helper.rb`: rescue `LoadError` on `byebug` require so specs run on platforms where byebug is not installable (e.g. Windows with Ruby 3.2) - [@rellampec](https://github.com/rellampec). ### 0.8.0 (2024/01/06) * [#110](https://github.com/ashkan18/graphlient/pull/110): Ensure correct Faraday JSON response body parsing with invalid response header - [@taylorthurlow](https://github.com/taylorthurlow). diff --git a/Dangerfile b/Dangerfile index 38781a5..10e3501 100644 --- a/Dangerfile +++ b/Dangerfile @@ -16,9 +16,7 @@ warn("There're library changes, but not tests. That's OK as long as you're refac # -------------------------------------------------------------------------------------------------------------------- # You've made changes to specs, but no library code has changed? # -------------------------------------------------------------------------------------------------------------------- -if !has_app_changes && has_spec_changes - message('We really appreciate pull requests that demonstrate issues, even without a fix. That said, the next step is to try and fix the failing tests!', sticky: false) -end +message('We really appreciate pull requests that demonstrate issues, even without a fix. That said, the next step is to try and fix the failing tests!', sticky: false) if !has_app_changes && has_spec_changes # -------------------------------------------------------------------------------------------------------------------- # Have you updated CHANGELOG.md? diff --git a/Gemfile b/Gemfile index 2a9875a..4474f8e 100644 --- a/Gemfile +++ b/Gemfile @@ -6,13 +6,13 @@ gem 'rake' group :development, :test do gem 'activesupport', '< 6' - gem 'mutex_m' + gem 'mutex_m' # activesupport 5.x depends on mutex_m, removed from stdlib in Ruby 3.4 gem 'ostruct' end group :development do gem 'byebug', platform: :ruby - gem 'rubocop', '0.56.0' + gem 'rubocop', '~> 1.0' end group :test do diff --git a/README.md b/README.md index 9789209..143ac8d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ A friendlier Ruby client for consuming GraphQL-based APIs. Built on top of your - [Error Handling](#error-handling) - [Executing Parameterized Queries and Mutations](#executing-parameterized-queries-and-mutations) - [Parse and Execute Queries Separately](#parse-and-execute-queries-separately) + - [Build Query Strings without Validation](#build-query-strings-without-validation) - [Dynamic vs. Static Queries](#dynamic-vs-static-queries) - [Generate Queries with Graphlient::Query](#generate-queries-with-graphlientquery) + - [Fragment Spreads](#fragment-spreads) - [Create API Client Classes with Graphlient::Extension::Query](#create-api-client-classes-with-graphlientextensionquery) - [Swapping the HTTP Stack](#swapping-the-http-stack) - [Testing with Graphlient and RSpec](#testing-with-graphlient-and-rspec) @@ -278,6 +280,53 @@ GRAPHQL client.execute query, ids: [42] ``` +### Build Query Strings without Validation + +`Client#to_query_string` serializes a DSL block into a GraphQL query string and +returns it as a plain `String`. It uses the same DSL serializer (`Graphlient::Query`) +that powers `client.query` and `client.parse`, but it stops there — no schema is +loaded, no graphql-client validation runs, no HTTP call is made. + +```ruby +client = Graphlient::Client.new('https://example.com/graphql', + headers: { 'Authorization' => 'Bearer 123' } +) + +query_str = client.to_query_string do + query(id: :int) do + invoice(id: :id) do + id + feeInCents + end + end +end + +# => "query($id: Int){\n invoice(id: $id){\n id\n feeInCents\n }\n }" +``` + +For fragment-free queries the string can be fed back to `client.execute`: + +```ruby +client.execute(query_str, id: 42) +``` + +Queries containing fragment spreads (`spread :Name` → `...Name`) cannot go back +through the gem — graphql-client requires fragments to be pre-registered module +constants, not named strings. Pass those directly to your own HTTP client instead. + +This is also the escape hatch if you want to replace the graphql-client dependency +entirely. `to_query_string` gives you a standard GraphQL document — from there you +own the transport: Faraday, Net::HTTP, anything else. You get full control over +headers, retries, connection pooling, and middleware without any graphql-client +overhead. + +```ruby +conn = Faraday.new('https://example.com/graphql', + headers: { 'Authorization' => 'Bearer 123', 'Content-Type' => 'application/json' } +) +response = conn.post('/', { query: query_str, variables: { id: 42 } }.to_json) +``` + ### Dynamic vs. Static Queries Graphlient uses [graphql-client](https://github.com/github-community-projects/graphql-client), which [recommends](https://github.com/github-community-projects/graphql-client/blob/master/guides/dynamic-query-error.md) building queries as static module members along with dynamic variables during execution. This can be accomplished with graphlient the same way. @@ -397,6 +446,40 @@ invoice.fee_in_cents # 20000 ``` +### Fragment Spreads + +Use `spread` to emit a named fragment spread (`...FragmentName`) inside a DSL block. +This is a cleaner alternative to the `___Const__Name` triple-underscore convention. + +```ruby +query_str = client.to_query_string do + query do + invoice(id: 10) do + id + spread :InvoiceFields # → ...InvoiceFields + end + end +end +``` + +Multiple spreads at the same level are supported: + +```ruby +client.to_query_string do + query do + invoice(id: 10) do + spread :CoreFields + spread :AuditFields + end + end +end +``` + +`spread` works in both `to_query_string` and the standard `parse`/`query` execution +paths. The caller is responsible for appending the fragment definition to the query +string before sending it to the server, or for using a framework layer that handles +fragment assembly automatically. + ### Create API Client Classes with Graphlient::Extension::Query You can include `Graphlient::Extensions::Query` in your class. This will add a new `method_missing` method to your context which will be used to generate GraphQL queries. diff --git a/lib/graphlient/adapters/http/http_adapter.rb b/lib/graphlient/adapters/http/http_adapter.rb index 15a351a..ab2b186 100644 --- a/lib/graphlient/adapters/http/http_adapter.rb +++ b/lib/graphlient/adapters/http/http_adapter.rb @@ -19,6 +19,7 @@ def execute(document:, operation_name: nil, variables: {}, context: {}) response = connection.request(request) raise Graphlient::Errors::HttpServerError, response unless response.is_a?(Net::HTTPOK) + JSON.parse(response.body) end diff --git a/lib/graphlient/client.rb b/lib/graphlient/client.rb index 16d618d..46da074 100644 --- a/lib/graphlient/client.rb +++ b/lib/graphlient/client.rb @@ -20,6 +20,12 @@ def parse(query_str = nil, &block) raise Graphlient::Errors::ClientError, e.message end + def to_query_string(**_kargs, &block) + Graphlient::Query.new do + instance_eval(&block) + end.to_s + end + def execute(query, variables = nil) query_params = {} query_params[:context] = @options if @options @@ -30,6 +36,7 @@ def execute(query, variables = nil) # see https://github.com/github-community-projects/graphql-client/pull/132 # see https://github.com/exAspArk/graphql-errors/issues/2 raise Graphlient::Errors::ExecutionError, rc if errors_in_result?(rc) + rc rescue GraphQL::Client::Error => e raise Graphlient::Errors::ClientError, e.message @@ -64,7 +71,7 @@ def raise_error_if_invalid_configuration! end def schema_path - return options[:schema_path].to_s if options[:schema_path] + options[:schema_path].to_s if options[:schema_path] end def client diff --git a/lib/graphlient/errors/error.rb b/lib/graphlient/errors/error.rb index fc93ceb..36419f9 100644 --- a/lib/graphlient/errors/error.rb +++ b/lib/graphlient/errors/error.rb @@ -2,6 +2,7 @@ module Graphlient module Errors class Error < StandardError attr_reader :inner_exception + def initialize(message, inner_exception = nil) super(message) diff --git a/lib/graphlient/errors/graphql_error.rb b/lib/graphlient/errors/graphql_error.rb index 72e7e35..7f90735 100644 --- a/lib/graphlient/errors/graphql_error.rb +++ b/lib/graphlient/errors/graphql_error.rb @@ -2,6 +2,7 @@ module Graphlient module Errors class GraphQLError < Error attr_reader :response + def initialize(response) super('the server responded with a GraphQL error') @response = response diff --git a/lib/graphlient/query.rb b/lib/graphlient/query.rb index 6735954..9c4b7bc 100644 --- a/lib/graphlient/query.rb +++ b/lib/graphlient/query.rb @@ -39,6 +39,13 @@ def to_s query_str.strip end + # Appends a named fragment spread (`...fragment_name`) to the query string. + # Use instead of the deprecated `___Const` convention. + def spread(fragment_name) + @query_str << "\n#{indent}...#{fragment_name}" + @query_str << "\n#{indent}" + end + private def evaluate(&block) @@ -49,6 +56,7 @@ def evaluate(&block) def resolve_fragment_constant(value) return nil unless (match = value.to_s.match(FRAGMENT_DEFITION)) + raw_const = match[:const].gsub('__', '::') @context[@last_block].eval(raw_const).tap do |const| msg = "Expected constant #{raw_const} to be GraphQL::Client::FragmentDefinition. Given #{const.class}" diff --git a/lib/graphlient/schema.rb b/lib/graphlient/schema.rb index f155d93..776878e 100644 --- a/lib/graphlient/schema.rb +++ b/lib/graphlient/schema.rb @@ -20,6 +20,7 @@ def initialize(http, path) def dump! raise MissingConfigurationError, PATH_ERROR_MESSAGE unless path + GraphQL::Client.dump_schema(http, path) end end diff --git a/spec/graphlient/adapters/http/faraday_adapter_spec.rb b/spec/graphlient/adapters/http/faraday_adapter_spec.rb index 05ecb19..8902129 100644 --- a/spec/graphlient/adapters/http/faraday_adapter_spec.rb +++ b/spec/graphlient/adapters/http/faraday_adapter_spec.rb @@ -145,8 +145,13 @@ end specify do - expected_error_message = "Connection refused - #{error_message}" - expect { client.schema }.to raise_error(Graphlient::Errors::ConnectionFailedError, expected_error_message) + # Use a regex so the test passes on both Linux ("Connection refused - ...") + # and Windows (where Errno::ECONNREFUSED prepends a different OS message). + # Both platforms include the core error_message string in the result. + expect { client.schema }.to raise_error( + Graphlient::Errors::ConnectionFailedError, + /#{Regexp.escape(error_message)}/ + ) end end diff --git a/spec/graphlient/client_dsl_spec.rb b/spec/graphlient/client_dsl_spec.rb new file mode 100644 index 0000000..00cfbf5 --- /dev/null +++ b/spec/graphlient/client_dsl_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +describe Graphlient::Client do + let(:client) { described_class.new('http://example.com/graphql') } + + describe '#to_query_string' do + it 'returns a String' do + result = client.to_query_string do + query do + invoice(id: 10) do + id + feeInCents + end + end + end + expect(result).to be_a String + end + + it 'builds a simple query without HTTP or schema access' do + result = client.to_query_string do + query do + invoice(id: 10) do + id + feeInCents + end + end + end + expect(result).to include('query') + expect(result).to include('invoice') + expect(result).to include('feeInCents') + end + + it 'builds a parameterized query with variable declarations' do + result = client.to_query_string do + query(id: :int) do + invoice(id: :id) do + id + feeInCents + end + end + end + expect(result).to include('$id: Int') + expect(result).to include('invoice(id: $id)') + end + + it 'builds a mutation query string' do + result = client.to_query_string do + mutation(input: :CreateInvoiceInput!) do + createInvoice(input: :input) do + invoice do + id + end + errors + end + end + end + expect(result).to include('mutation') + expect(result).to include('createInvoice') + expect(result).to include('$input: CreateInvoiceInput!') + end + + it 'does not interact with the HTTP adapter' do + expect_any_instance_of(Graphlient::Adapters::HTTP::FaradayAdapter).not_to receive(:execute) + client.to_query_string do + query do + invoice(id: 1) { id } + end + end + end + + context 'with spread helper' do + it 'includes a named fragment spread in the query string' do + result = client.to_query_string do + query do + invoice(id: 10) do + spread :InvoiceFields + end + end + end + expect(result).to include('...InvoiceFields') + end + + it 'supports multiple spreads at the same level' do + result = client.to_query_string do + query do + nodes do + spread :FragmentA + spread :FragmentB + end + end + end + expect(result).to include('...FragmentA') + expect(result).to include('...FragmentB') + end + end + end +end diff --git a/spec/graphlient/static_client_query_spec.rb b/spec/graphlient/static_client_query_spec.rb index 5a47e46..b3b560f 100644 --- a/spec/graphlient/static_client_query_spec.rb +++ b/spec/graphlient/static_client_query_spec.rb @@ -3,17 +3,15 @@ describe Graphlient::Client do describe 'parse and execute' do module Graphlient::Client::Spec + # schema_path avoids an HTTP schema fetch at file-load time. + # No Rack adapter needed here; parsing is local and execution is handled + # by the stub_request below. Client = Graphlient::Client.new( 'http://graph.biz/graphql', + schema_path: 'spec/support/fixtures/invoice_api.json', headers: { 'Authorization' => 'Bearer 1231' }, allow_dynamic_queries: false - ) do |client| - client.http do |h| - h.connection do |c| - c.adapter Faraday::Adapter::Rack, Sinatra::Application - end - end - end + ) StringQuery = Client.parse <<~GRAPHQL query($some_id: Int) { @@ -34,6 +32,15 @@ module Graphlient::Client::Spec end end + # Route execution requests through the Sinatra dummy app via WebMock. + # to_rack works reliably across Ruby/gem versions; Faraday::Adapter::Rack + # can be intercepted before dispatch on some Ruby 3.x + WebMock combinations. + before do + stub_request(:post, 'http://graph.biz/graphql') + .with(headers: { 'Authorization' => 'Bearer 1231' }) + .to_rack(Sinatra::Application) + end + it 'defaults allow_dynamic_queries to false' do expect(Graphlient::Client::Spec::Client.send(:client).allow_dynamic_queries).to be false end @@ -50,7 +57,7 @@ module Graphlient::Client::Spec context 'with both string- and block-based queries' do it 'gets identical results parsing equivalent string- and block-based queries' do - block_response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::BlockQuery, some_id: 42) + block_response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::BlockQuery, some_id: 42) string_response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::StringQuery, some_id: 42) expect(string_response.to_h).to eq block_response.to_h end @@ -59,7 +66,7 @@ module Graphlient::Client::Spec context 'executing a query' do it 'succeeds with expected feeInCents' do response = Graphlient::Client::Spec::Client.execute(Graphlient::Client::Spec::BlockQuery, some_id: 42) - invoice = response.data.invoice + invoice = response.data.invoice expect(invoice.id).to eq '42' expect(invoice.fee_in_cents).to eq 20_000 end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e73d719..16d88b2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,7 +3,11 @@ require 'rubygems' require 'rspec' require 'graphlient' -require 'byebug' if RUBY_ENGINE != 'jruby' +begin + require 'byebug' if RUBY_ENGINE != 'jruby' +rescue LoadError + # byebug not available on all platforms (e.g. Windows x64-mingw-ucrt) +end require 'rack/test' require 'webmock/rspec' require 'vcr' diff --git a/spec/support/context/dummy_client.rb b/spec/support/context/dummy_client.rb index 4101bff..7433dbf 100644 --- a/spec/support/context/dummy_client.rb +++ b/spec/support/context/dummy_client.rb @@ -14,13 +14,20 @@ def app } end + # Route all requests through the Sinatra dummy app via WebMock's to_rack adapter. + # This is more reliable than configuring Faraday::Adapter::Rack directly because + # WebMock intercepts Faraday requests before they reach the adapter layer on some + # Ruby/gem version combinations; to_rack routes at the WebMock level instead. + before do + stub_request(:post, endpoint) + .with(headers: { 'Authorization' => 'Bearer 1231' }) + .to_rack(app) + end + + # No schema_path: the to_rack stub above routes schema introspection to the + # Sinatra app as well, so graphql-client gets the full live schema including + # test-only fields (executionErrorInvoice, partialSuccess etc.). let(:client) do - Graphlient::Client.new(endpoint, headers: headers) do |client| - client.http do |h| - h.connection do |c| - c.adapter Faraday::Adapter::Rack, app - end - end - end + Graphlient::Client.new(endpoint, headers: headers) end end diff --git a/spec/support/dummy_schema.rb b/spec/support/dummy_schema.rb index f99b596..90c4243 100644 --- a/spec/support/dummy_schema.rb +++ b/spec/support/dummy_schema.rb @@ -1,7 +1,7 @@ require_relative 'types/invoice_type' require_relative 'queries/query' -require_relative 'types/mutation_type.rb' +require_relative 'types/mutation_type' require 'graphql/errors' diff --git a/spec/support/queries/query.rb b/spec/support/queries/query.rb index a714c8e..0f0141b 100644 --- a/spec/support/queries/query.rb +++ b/spec/support/queries/query.rb @@ -21,6 +21,7 @@ class Query < GraphQL::Schema::Object def invoice(id: nil) return nil if id.nil? + OpenStruct.new( id: id, fee_in_cents: 20_000 @@ -31,7 +32,7 @@ def not_null_invoice(*) nil end - def execution_error_invoice(id: nil, execution_errors:) + def execution_error_invoice(execution_errors:, id: nil) execution_errors.add(GraphQL::ExecutionError.new('Execution Error')) invoice(id: id)