]> Dogcows Code - chaz/graphql-client/commitdiff
initial commit
authorCharles McGarvey <chazmcgarvey@brokenzipper.com>
Sat, 14 Mar 2020 05:47:08 +0000 (23:47 -0600)
committerCharles McGarvey <chazmcgarvey@brokenzipper.com>
Sat, 14 Mar 2020 05:48:54 +0000 (23:48 -0600)
.editorconfig [new file with mode: 0644]
.gitignore [new file with mode: 0644]
Changes [new file with mode: 0644]
Makefile [new file with mode: 0644]
bin/graphql [new file with mode: 0755]
dist.ini [new file with mode: 0644]
lib/GraphQL/Client.pm [new file with mode: 0644]
lib/GraphQL/Client/http.pm [new file with mode: 0644]
lib/GraphQL/Client/https.pm [new file with mode: 0644]

diff --git a/.editorconfig b/.editorconfig
new file mode 100644 (file)
index 0000000..e5c1fe1
--- /dev/null
@@ -0,0 +1,20 @@
+
+# Please follow these code style guidelines. You can use this file to
+# automatically configure your editor.
+# For instructions, see: http://editorconfig.org/
+
+[*]
+charset                     = utf8
+end_of_line                 = lf
+insert_final_newline        = true
+trim_trailing_whitespace    = true
+
+[{**.pl,**.pm,**.pod,**.t}]
+indent_style    = space
+indent_size     = 4
+max_line_length = 100
+
+[{.editorconfig,**.ini}]
+indent_style    = space
+indent_size     = 4
+
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..eb4bee2
--- /dev/null
@@ -0,0 +1,13 @@
+*#
+*.bs
+*.o
+*.tar*
+*~
+/.build
+/.perl-version
+/GraphQL-Client-*
+/MYMETA.*
+/blib
+/cover_db
+/local*
+/pm_to_blib
diff --git a/Changes b/Changes
new file mode 100644 (file)
index 0000000..d4835f5
--- /dev/null
+++ b/Changes
@@ -0,0 +1,5 @@
+Revision history for GraphQL-Client.
+
+{{$NEXT}}
+  * Initial public release
+
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..d12cda5
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,33 @@
+
+# This is not a Perl distribution, but it can build one using Dist::Zilla.
+
+COVER   = cover
+CPANM   = cpanm
+DZIL    = dzil
+PERL    = perl
+PROVE   = prove
+
+all: dist
+
+bootstrap:
+       $(CPANM) $(CPANM_FLAGS) -n Dist::Zilla
+       $(DZIL) authordeps --missing |$(CPANM) $(CPANM_FLAGS) -n
+       $(DZIL) listdeps --develop --missing |$(CPANM) $(CPANM_FLAGS) -n
+
+clean:
+       $(DZIL) $@
+
+cover:
+       $(COVER) -test
+
+dist:
+       $(DZIL) build
+
+distclean: clean
+       rm -rf cover_db
+
+test:
+       $(PROVE) -l$(if $(findstring 1,$(V)),v) t
+
+.PHONY: all bootstrap clean cover dist distclean test
+
diff --git a/bin/graphql b/bin/graphql
new file mode 100755 (executable)
index 0000000..071cac7
--- /dev/null
@@ -0,0 +1,193 @@
+#!/usr/bin/env perl
+# PODNAME: graphql
+# ABSTRACT: Command-line GraphQL client
+
+use warnings;
+use strict;
+
+use Getopt::Long;
+use GraphQL::Client;
+use Scalar::Util qw(reftype);
+
+our $VERSION = '999.999'; # VERSION
+
+my $url;
+my $transport   = {};
+my $query       = '-';
+my $variables   = {};
+my $format      = 'json:pretty';
+my $unpack      = 0;
+my $outfile;
+GetOptions(
+    'url|u=s'           => \$url,
+    'transport|t=s%'    => \$transport,
+    'query|mutation=s'  => \$query,
+    'variable|var|d=s%' => \$variables,
+    'format|f=s'        => \$format,
+    'unpack!'           => \$unpack,
+    'output|o=s'        => \$outfile,
+) or die "Invalid options.\n";
+
+$url    = shift if !$url;
+$query  = shift if !$query || $query eq '-';
+
+my $client = GraphQL::Client->new(
+    %$transport,
+    url     => $url,
+);
+$client->transport;     # just make sure we can load the transport
+
+if (!$query || $query eq '-') {
+    print STDERR "Interactive mode engaged! Waiting for a query on <STDIN>...\n" if -t STDIN;
+    $query = do { local $/; <> };
+}
+
+my $resp = $client->request($query, $variables);
+my $err  = $resp->{errors};
+my $data = !$unpack || $err ? $resp : $resp->{data};
+
+if ($outfile) {
+    open(my $out, '>', $outfile) or die "Open $outfile failed: $!";
+    *STDOUT = $out;
+}
+
+$format = lc($format);
+if ($format eq 'json') {
+    require JSON::MaybeXS;
+    print JSON::MaybeXS->new(utf8 => 1)->encode($data);
+}
+elsif ($format eq 'json:pretty') {
+    require JSON::MaybeXS;
+    print JSON::MaybeXS->new(utf8 => 1, pretty => 1)->encode($data);
+}
+elsif ($format eq 'yaml') {
+    require YAML;
+    print YAML::Dump($data);
+}
+else {
+    require Data::Dumper;
+    print Data::Dumper::Dumper($data);
+}
+
+exit($unpack && $err ? 1 : 0);
+
+=head1 SYNOPSIS
+
+    graphql <URL> <QUERY> [--var key=value]... [--transport key=value]...
+            [--[no-]unpack] [--format json|json:pretty|yaml]
+
+=head1 DESCRIPTION
+
+C<graphql> is a command-line program for executing queries and mutations on
+a L<GraphQL|https://graphql.org/> server.
+
+=head1 OPTIONS
+
+=head2 --url STR
+
+The URL of the GraphQL server endpoint.
+
+If no C<--url> option is given, the first argument is assumed to be the URL.
+
+Alias: C<-u>
+
+=head2 --query STR
+
+The query or mutation to execute.
+
+If no C<--query> option is given, the first argument (after URL) is assumed to
+be the query.
+
+If the value is C<-> (which is the default), the query will be read from
+C<STDIN>.
+
+See: L<https://graphql.org/learn/queries/>
+
+Alias: C<--mutation>
+
+=head2 --variable KEY=VALUE
+
+A key-value pair
+
+See: L<https://graphql.org/learn/queries/#variables>
+
+Aliases: C<--var>, C<-d>
+
+=head2 --transport KEY=VALUE
+
+Key-value pairs for configuring the transport (usually HTTP).
+
+Alias: C<-t>
+
+=head2 --unpack
+
+Enables C<unpack> mode.
+
+By default, the response structure is printed as-is from the server, and the
+program exits 0.
+
+When C<unpack> mode is enabled, if the response completes with no errors, only
+the data section of the response is printed and the program exits 0. If the
+response has errors, the whole response structure is printed as-is and the
+program exits 1.
+
+=head2 --format STR
+
+Sets the output format. Possible values include:
+
+=for :list
+* C<json:pretty> (default)
+* C<json>
+* C<yaml>
+* C<dump>
+
+Alias: C<-f>
+
+=head1 EXAMPLES
+
+Different ways to provide the query/mutation:
+
+    graphql http://localhost:4000/graphql {hello}
+
+    echo {hello} | graphql http://localhost:4000/graphql
+
+    graphql http://localhost:4000/graphql <<END
+    > {hello}
+    > END
+
+    graphql http://localhost:4000/graphql
+    Interactive mode engaged! Waiting for a query on <STDIN>...
+    {hello}
+    ^D
+
+This example shows the effect of L<--unpack>:
+
+    graphql http://localhost:4000/graphql {hello}
+
+    # Output:
+    {
+        "data" : {
+            "hello" : "Hello world!"
+        }
+    }
+
+    graphql --unpack http://localhost:4000/graphql {hello}
+
+    # Output:
+    {
+        "hello" : "Hello world!"
+    }
+
+Execute a query with variables:
+
+    graphql unpack http://localhost:4000/graphql <<END --var episode=JEDI
+    > query HeroNameAndFriends($episode: Episode) {
+    >   hero(episode: $episode) {
+    >     name
+    >     friends {
+    >       name
+    >     }
+    >   }
+    > }
+    > END
+
diff --git a/dist.ini b/dist.ini
new file mode 100644 (file)
index 0000000..3937ffe
--- /dev/null
+++ b/dist.ini
@@ -0,0 +1,7 @@
+name                = GraphQL-Client
+author              = Charles McGarvey <chazmcgarvey@brokenzipper.com>
+copyright_holder    = Charles McGarvey
+copyright_year      = 2020
+license             = Perl_5
+
+[@Author::CCM]
diff --git a/lib/GraphQL/Client.pm b/lib/GraphQL/Client.pm
new file mode 100644 (file)
index 0000000..345fd28
--- /dev/null
@@ -0,0 +1,248 @@
+package GraphQL::Client;
+# ABSTRACT: A GraphQL client
+
+use warnings;
+use strict;
+
+use Module::Load qw(load);
+use Throw;
+
+our $VERSION = '999.999'; # VERSION
+
+sub _croak { use Carp; goto &Carp::croak }
+
+sub new {
+    my $class = shift;
+    bless {@_}, $class;
+}
+
+sub request {
+    my $self = shift;
+    my ($query, $variables, $options) = @_;
+
+    my $transport_opts = {%{$options || {}}};
+    my $operation_name = delete($transport_opts->{operation_name}) // delete($transport_opts->{operationName});
+
+    my $request = {
+        query => $query,
+        $variables ? (variables => $variables) : (),
+        $operation_name ? (operationName => $operation_name) : (),
+    };
+
+    my $resp = $self->transport->request($request, $transport_opts);
+    return $self->_handle_response($resp);
+}
+
+sub _handle_response {
+    my $self = shift;
+    my ($resp) = @_;
+
+    if (eval { $resp->isa('Future') }) {
+        return $resp->followed_by(sub {
+            my $f = shift;
+            if (my $exception = $f->failure) {
+                if ($self->unpack || !$exception->{errors}) {
+                    return Future->fail($exception);
+                }
+                return Future->done($exception);
+            }
+            else {
+                my $resp = $f->get;
+                if ($self->unpack) {
+                    if ($resp->{errors}) {
+                        return Future->fail($resp);
+                    }
+                    return Future->done($resp->{data});
+                }
+                return Future->done($resp);
+            }
+        });
+    }
+    else {
+        if ($self->unpack) {
+            if ($resp->{errors}) {
+                throw 'The GraphQL server returned errors', {
+                    %$resp,
+                    type => 'graphql',
+                };
+            }
+            return $resp->{data};
+        }
+        return $resp;
+    }
+}
+
+sub url {
+    my $self = shift;
+    $self->{url};
+}
+
+sub class {
+    my $self = shift;
+    $self->{class};
+}
+
+sub transport {
+    my $self = shift;
+    $self->{transport} //= do {
+        my $class = $self->_transport_class;
+        eval { load $class };
+        if (my $err = $@) {
+            warn $err if $ENV{GRAPHQL_CLIENT_DEBUG};
+            _croak "Failed to load transport for \"${class}\"";
+        }
+        $class->new(%$self);
+    };
+}
+
+sub unpack {
+    my $self = shift;
+    $self->{unpack} //= 0;
+}
+
+sub _url_protocol {
+    my $self = shift;
+
+    my $url = $self->url;
+    my ($protocol) = $url =~ /^([^+:]+)/;
+
+    return $protocol;
+}
+
+sub _transport_class {
+    my $self = shift;
+
+    return _expand_class($self->{class}) if $self->{class};
+
+    my $protocol = $self->_url_protocol;
+    _croak 'Failed to determine transport from URL' if !$protocol;
+
+    my $class = lc($protocol);
+    $class =~ s/[^a-z]/_/g;
+
+    return _expand_class($class);
+}
+
+sub _expand_class {
+    my $class = shift;
+    $class = "GraphQL::Client::$class" unless $class =~ s/^\+//;
+    $class;
+}
+
+1;
+__END__
+
+=head1 SYNOPSIS
+
+    my $client = GraphQL::Client->new();
+
+    my $data = $client->request(q[
+        query GetHuman {
+            human(id: $human_id) {
+                name
+                height
+            }
+        }
+    ], {
+        human_id => 1000,
+    });
+
+=head1 DESCRIPTION
+
+=method new
+
+    $client = GraphQL::Client->new(%attributes);
+
+Construct a new client.
+
+=method request
+
+    $response = $client->request($query);
+    $response = $client->request($query, \%variables);
+    $response = $client->request($query, \%variables, \%transport_options);
+
+Get a response from the GraphQL server.
+
+By default, the response will either be a hashref with the following structure or a L<Future> that
+resolves to such a hashref, depending on the transport and how it is configured.
+
+    {
+        data   => {
+            field1  => {...}, # or [...]
+            ...
+        },
+        errors => [
+            { message => 'some error message blah blah blah' },
+            ...
+        ],
+    }
+
+Note: Setting the L</unpack> attribute affects the response shape.
+
+=attr url
+
+The URL of a GraphQL endpoint, e.g. C<"http://myapiserver/graphql">.
+
+This is required.
+
+=attr class
+
+The package name of a transport.
+
+By default this is automatically determined from the protocol portion of the L</url>.
+
+=attr transport
+
+The transport object.
+
+By default this is automatically constructed based on the L</class>.
+
+=attr unpack
+
+Whether or not to "unpack" the response, which enables a different style for error-handling.
+
+Default is 0.
+
+See L</ERROR HANDLING>.
+
+=head1 ERROR HANDLING
+
+There are two different styles for handling errors.
+
+If L</unpack> is 0 (off), every response -- whether success or failure -- is enveloped like this:
+
+    {
+        data   => {...},
+        errors => [...],
+    }
+
+where C<data> might be missing or undef if errors occurred (though not necessarily) and C<errors>
+will be missing if the response completed without error.
+
+It is up to you to check for errors in the response, so your code might look like this:
+
+    my $response = $client->request(...);
+    if (my $errors = $response->{errors}) {
+        # handle errors
+    }
+    my $data = $response->{data};
+    # do something with $data
+
+If C<unpack> is 1 (on), then L</request> will return just the data if there were no errors,
+otherwise it will throw an exception. So your code would look like this:
+
+    my $data = eval { $client->request(...) };
+    if (my $error = $@) {
+        # handle errors
+    }
+    # do something with $data
+
+Or if you want to handle errors in a different stack frame, your code is simply this:
+
+    my $data = $client->request(...);
+    # do something with $data
+
+Both styles map to L<Future> responses intuitively. If C<unpack> is 0, the response always resolves
+to the envelope structure. If C<unpack> is 1, successful responses will resolve to just the data and
+errors will fail/reject.
+
diff --git a/lib/GraphQL/Client/http.pm b/lib/GraphQL/Client/http.pm
new file mode 100644 (file)
index 0000000..5b9b634
--- /dev/null
@@ -0,0 +1,185 @@
+package GraphQL::Client::http;
+# ABSTRACT: GraphQL over HTTP
+
+use warnings;
+use strict;
+
+use HTTP::AnyUA::Util qw(www_form_urlencode);
+use HTTP::AnyUA;
+
+our $VERSION = '999.999'; # VERSION
+
+sub new {
+    my $class = shift;
+    bless {@_}, $class;
+}
+
+sub request {
+    my $self = shift;
+    my ($request, $options) = @_;
+
+    my $url     = $options->{url} || $self->url;
+    my $method  = $options->{method} || $self->method;
+
+    my $data = {%$request};
+
+    if ($method eq 'GET' || $method eq 'HEAD') {
+        $data->{variables} = $self->json->encode($data->{variables}) if $data->{variables};
+        my $params  = www_form_urlencode($data);
+        my $sep     = $url =~ /\?/ ? '&' : '?';
+        $url .= "${sep}${params}";
+    }
+    else {
+        my $encoded_data = $self->json->encode($data);
+        $options->{content} = $encoded_data;
+        $options->{headers}{'content-length'} = length $encoded_data;
+        $options->{headers}{'content-type'}   = 'application/json';
+    }
+
+    return $self->_handle_response($self->_any_ua->request($method, $url, $options));
+}
+
+sub _handle_response {
+    my $self = shift;
+    my ($resp) = @_;
+
+    my $handle_error = sub {
+        my $resp = shift;
+
+        return {
+            errors => [
+                {
+                    message => "HTTP transport returned $resp->{status}: $resp->{content}",
+                    x_transport_response => $resp,
+                },
+            ],
+        };
+    };
+    my $handle_response = sub {
+        my $resp = shift;
+
+        return $handle_error->($resp) if !$resp->{success};
+        return $self->json->decode($resp->{content});
+    };
+
+    if ($self->_any_ua->response_is_future) {
+        return $resp->transform(
+            done => $handle_response,
+            fail => $handle_error,
+        );
+    }
+    else {
+        return $handle_response->($resp);
+    }
+}
+
+sub ua {
+    my $self = shift;
+    $self->{ua} //= do {
+        require HTTP::Tiny;
+        HTTP::Tiny->new(
+            agent   => "perl-graphql-client/$VERSION",
+        );
+    };
+}
+
+sub url {
+    my $self = shift;
+    $self->{url};
+}
+
+sub method {
+    my $self = shift;
+    $self->{method} // 'POST';
+}
+
+sub json {
+    my $self = shift;
+    $self->{json} //= do {
+        require JSON::MaybeXS;
+        JSON::MaybeXS->new(utf8 => 1);
+    };
+}
+
+sub _any_ua {
+    my $self = shift;
+    $self->{_any_ua} //= HTTP::AnyUA->new(ua => $self->ua);
+}
+
+1;
+__END__
+
+=head1 SYNOPSIS
+
+    my $transport = GraphQL::Client::http->new(
+        url     => 'http://localhost:5000/graphql',
+        method  => 'POST',
+    );
+
+    my $data = $client->request($query, $variables, $operation_name, $options);
+
+=head1 DESCRIPTION
+
+You probably shouldn't use this directly. Instead use L<GraphQL::Client>.
+
+C<GraphQL::Client::http> is a GraphQL transport for HTTP. GraphQL is not required to be transported
+via HTTP, but this is definitely the most common way.
+
+This also serves as a reference implementation for future GraphQL transports.
+
+=method new
+
+    $transport = GraphQL::Client::http->new(%attributes);
+
+Construct a new GraphQL HTTP transport.
+
+=method request
+
+    $response = $client->request(\%data, \%options);
+
+Get a response from the GraphQL server.
+
+The C<%data> structure must have a C<query> key whose value is the query or mutation string. It may
+optionally have a C<variables> hashref an an C<operationName> string.
+
+The C<%options> structure contains options passed through to the user agent.
+
+The response will either be a hashref with the following structure or a L<Future> that resolves to
+such a hashref:
+
+    {
+        data   => {...},
+        errors => [...],
+    }
+
+=attr ua
+
+A user agent, such as:
+
+=for :list
+* instance of a L<HTTP::Tiny> (this is the default if no user agent is provided)
+* instance of a L<Mojo::UserAgent>
+* the string C<"AnyEvent::HTTP">
+* and more...
+
+See L<HTTP::AnyUA/"SUPPORTED USER AGENTS">.
+
+=attr method
+
+The HTTP method to use when querying the GraphQL server. Can be one of:
+
+=for :list
+* C<GET>
+* C<POST> (default)
+
+=attr json
+
+The L<JSON::XS> (or compatible) object used for encoding and decoding data structures to and from
+the GraphQL server.
+
+Defaults to a L<JSON::MaybeXS>.
+
+=head1 SEE ALSO
+
+L<https://graphql.org/learn/serving-over-http/>
+
diff --git a/lib/GraphQL/Client/https.pm b/lib/GraphQL/Client/https.pm
new file mode 100644 (file)
index 0000000..56f2672
--- /dev/null
@@ -0,0 +1,21 @@
+package GraphQL::Client::https;
+# ABSTRACT: GraphQL over HTTPS
+
+use warnings;
+use strict;
+
+use parent 'GraphQL::Client::http';
+
+our $VERSION = '999.999'; # VERSION
+
+sub new {
+    my $class = shift;
+    GraphQL::Client::http->new(@_);
+}
+
+1;
+__END__
+
+=head1 SEE ALSO
+
+L<GraphQL::Client::http>
This page took 0.0381 seconds and 4 git commands to generate.