Hands-On: How To Design, Launch, and Query a GraphQL API Using Apollo Server

Continued from page 1.

The parameter parent indicates the parent object related to the resolver. (We'll have a general discussion of the parent parameter at another time. In this case, there is no parent in play with addMovie.) The parameter args indicates the parameter information that gets passed by the caller of the mutation. In IMBOB the type definition for the mutation addMovie is as follows:

addMovie(movie: MovieInput!): Movie

Thus, an instance of type MovieInput is passed by Apollo Server to the args parameter of the resolver.

The type, MovieInput, is defined like so:

input MovieInput{
  title: String!
  releaseDate: Date!
  genre: Genre
  directors: [KnownPersonInput]
  actors: [ActorInput]
}

Where, as indicated by the exclamation point, the fields title and releaseDate are required to be provided when executing the mutation. The other fields in MovieInput are optional.

Since the movie to be added is new to the system, a unique identifier must be created by the API and added to the MovieInput object that is bound to the args parameter. Assigning the unique identifier is done at line 2 in Listing 4 above.

Once the MovieInput object is given a unique identifier, it can be added to the IMBOB data store, which is done at line 3 of Listing 4. Then, after the movie as been added to data store successfully, IMBOB publishes a message to any subscribers interested in knowing that a movie has been added to the system. This is done at line 4 in Listing 3 above, when executing the publishMovieEvent(MOVIE_EVENT_TYPE_ADD, movie) function. This is where all the action happens. So let's take a look at the details in Listing 5 below.

Listing 5: The method, publishMovieEvent does the work of sending a message to subscribers listening to subscriptions related to movie activity

Listing 5: The method, publishMovieEvent does the work of sending a message to subscribers listening to subscriptions related to movie activity

In the method, publishMovieEvent the actual publishing of a message happens at line 29 in Listing 4.

Lines 11 - 26 in Listing 4 configures the message that's going to be published. Let's take a look at the details.

The IMBOB API function, publishMovieEvent has two parameters, eventType and movie. The parameter, eventType describes a constant value that's relevant only to the internals of IMBOB. The eventType indicates whether the message will be published to the subscription onMovieAdded or onMovieUpdated. The parameter, movie contains the movie information that will be assigned to the body of the message. As you can read in the code comments, line 8 sets the default subscription channel to MOVIE_CHANNEL. Then in lines 11 -15, if the movie object has a genre field, the channel is reassigned a value accordingly. Line 18 calls the custom method, createEvent which is a factory method that creates an Event object. (You can view the code for createEvent in Listing 5 below.)

Listing 5: createEvent is a factory method that creates Event objects in a uniform manner.

Listing 5: createEvent is a factory method that creates Event objects in a uniform manner.

Going back to Listing 4, once everything is set up, it's time to create the object, subscriptionDefinitionObject starting at line 24 in Listing 4. The object, subscriptionDefinitionObject fulfills a need that is particular to the Apollo Server PubSub.publish() method. PubSub.publish() has two parameters like so:

PubSub.publish(channelName, subscriptionDefinitionObject)

The parameter, channelName, indicates the subscription channel to which to send the message. The parameter, subscriptionDefinitionObject describes the actual named subscription along with the message that will be sent.

(Please be advised that subscriptionDefinitionObject is a term created in this article to describe the JSON structure that Apollo Server PubSub requires in order to send messages to subscribers. The concepts relevant to subscriptionDefinitionObject are similar to explanations found in other GraphQL documentation. But, as of this writing there is no standard term offered by Apollo Server for naming the required JSON structure.)

In order to understand, subscriptionDefinitionObject, you need to understand its JSON structure. This structure is special to the way that Apollo Server's PubSub object published messages.

Conceptually, the structure of the subscriptionDefinitionObject is:

{subscriptionName: {payloadField_1: info, payloadField_2: info, payloadField_X: info}}

WHERE

subscriptionName, is a field named according the exact subscription of interest

{payloadField_1: info, payloadField_2: info, payloadField_X: info}, is a JSON object that contains the fields that describe the particular segments of the payload overall.

Thus, concretely, if we want to send a message to the onMovieAdded subscription, we need to create an subscriptionDefinitionObject on the server side that might look like this:

{
  "onMovieAdded": {
    "id": "4ad5e551-69d9-4af5-8a21-782f0b6ac57a",
    "name": "MOVIE_EVENT_TYPE_ADD",
    "createdAt": "Wed May 01 2019 12:01:48 GMT-0700 (PDT)",
    "storedAt": "Wed May 01 2019 12:01:48 GMT-0700 (PDT)",
    "body": {
        "title": "The Great Escape",
        "releaseDate": "1963-07-04",
        "id": "0268fcca-8687-43fe-ba51-62455103d45b"
      }
  }
}

WHERE

"onMovieAdded" is the field name that is the actual name of the subscription

Then, once Apollo Server publishes the message, the following JSON would get sent to listeners to the subscription:

{
  "data": {
    "onMovieAdded": {
      "id": "4ad5e551-69d9-4af5-8a21-782f0b6ac57a",
      "name": "MOVIE_EVENT_TYPE_ADD",
      "createdAt": "Wed May 01 2019 12:01:48 GMT-0700 (PDT)",
      "storedAt": "Wed May 01 2019 12:01:48 GMT-0700 (PDT)",
      "body": {
        "title": "The Great Escape",
        "releaseDate": "1963-07-04",
        "id": "0268fcca-8687-43fe-ba51-62455103d45b"
      }
    }
  }
}

These lines of code from Listing 4, at lines 25-26 above describe the actual work of adding a field name for a particular subscription to the subscriptionDefinitionObject and then assigning a message to that field

  //assign the event to the appropriate subscription
   if(MOVIE_EVENT_TYPE_ADD) subscriptionDefinitionObj.onMovieAdded = event;
   if(MOVIE_EVENT_TYPE_UPDATE) subscriptionDefinitionObj.onMovieUpdated = event;

Once the subscriptionDefinitionObject is created and configured, it is published by the Apollo Server PubSub object like so:

await pubsub.publish(channel, subscriptionDefinitionObj);

Adding Security

API security is a very complex issue to which there is no single approach or solution. The subject is worthy of an entire book all to itself. We therefore won't be offering an exhaustive, in-depth overview of all it takes to secure APIs. Instead, we'll cover one of the most common and important components of API security; user authentication.

Implementing authentication in the IMBOB GraphQL API is a twofold undertaking. We need to secure both the API and Subscription servers. There is a common task that applies to securing both in which code inspects security information that is submitted by the user as a part of the HTTP request. The location of this security information varies by API Server or Subscription Server. In terms of the API Server, the security information is located in the HTTP request captured in the context object that's passed to the API schema during construction.

In terms of the Subscription Server, the authentication information is provided in the connectionParams parameter object that's passed to a subscription's onConnect field. (Be advised, the subscription object associated with the onConnect field in not the same as the Subscription type defined in the typeDefs file.)

The techniques for inspecting HTTP requests to determine security information is shown in Figure 11, below.

Figure 11: The security overview for IMBOB

Figure 11: The security overview for IMBOB

Let's take a look at the details of each technique. First, we'll start with security the API server.

Authenticating with the API Server

A query or mutation that is executed against a GraphQL API is represented as a request in the API's context. Part of the work for creating a schema for an API server is to declare a context field in the schema object that will be passed onto the server during implementation of the API. You'll create an anonymous function with two parameters, req and res, and assign that function to the context field. The parameter, req represents the current request submitted. The parameter, res represents the HTTP response that will be returned to the calling client eventually after the query or mutation represented by the request is processed. (See Figure 12.)

Figure 12: The context field makes request information available for inspection and processing.

Figure 12: The context field makes request information available for inspection and processing.

The work of assigning the incoming request to the req parameter and managing the response, res, is handled by the internals of Apollo Server (but should be handled by the internals of other GraphQL implementations as well.)

One of the many ways to facilitate security authorization is to add authentication information to the headers of an HTTP request. With regard to providing access information to the IMBOB API, we add that information to the header of the request representing the given query or request. When using GraphQL Playground, we type the JSON {"authorization":"ch3ddarch33s3"} into the HTTP HEADERS pane of the UI as shown below in Figure 13.

Figure 13: To authenticate to an API in GraphQL Playground, submit the authorization information in the HTTP HEADERS pane

Figure 13: To authenticate to an API in GraphQL Playground, submit the authorization information in the HTTP HEADERS pane

Should we need to access the API in code, for example in a Node.js unit test, we would write:

graphQLClient = new GraphQLClient(serverConfig.serverUrl, {
   headers: {
       authorization: `${config.ACCESS_TOKEN}`,
   },
});

Where the value of config.ACCESS_TOKEN is displayed above in the upper right of Figure 11.

Once the request is submitted, Apollo Server makes the request headers available to the schema's context function by way of the req parameter. As you can see in Figure 11 above, the context function has the logic to examine the request and determine if the required authentication information is present. If it is, the request is processed. If not, an error is thrown.

Securing the Subscription Server

Securing the subscription server requires a different approach. Subscription connections are continuous using the websocket protocol. It's like plugging an electric appliance into a wall socket. Once the plug is inserted and the appliance is turned on, the electricity flows until the appliance is turned off or the plug is pulled.

In terms of the Apollo Subscription Server, security analysis is performed according to the functional logic assigned to the onConnect field of the subscription object that is passed to the schema's constructor.

Just as Apollo passes a request onto the schemas context function to provide authentication information to the API Server, for the Subscription Server, it passes header information to a parameter named, connectionParams in the function assigned to the onConnect field. (See Figure 14, below)

Figure 14: The subscription field, onConnect allows you to inspect and process authentication information that Apollo Server will send to the onConnect handler, by way of parameter, connectionParams

Figure 14: The subscription field, onConnect allows you to inspect and process authentication information that Apollo Server will send to the onConnect handler, by way of parameter, connectionParams

The connectionParams object has a field, authorization, that will contain the information declared in the authorization, key-value header pair. Submitting authorization information to a subscription under GraphQL Playground is similar to the technique used when executing a query or mutation. Only, in terms of subscriptions, you provide the JSON-based authentication information when you register the subscription, as shown in Figure 15 below.

Figure 15: You provide authentication information in the HTTP HEADERS pane in GraphQL Playground when you register a subscription

Figure 15: You provide authentication information in the HTTP HEADERS pane in GraphQL Playground when you register a subscription

Authenticating to the Subscription Server in code, for example when executing a unit test in the Mocha/Chai framework looks like the code shown in Listing 6 below.

Listing 6: The code for submitting authentication information to a GraphQL Subscription Server

Listing 6: The code for submitting authentication information to a GraphQL Subscription Server

Now that we've covered the concepts and techniques for providing basic security to both an API and Subscription Server running under Apollo, let's take a look at the steps required to actually get the servers up and running.

Creating the API and Subscription Servers

Listing 7 below shows the code from server.js of IMBOB. This the code that initializes both the API and Subscription servers. Overall the steps to create the server are to import the Apollo Server object that is part of the apollo-server NPM package that's downloaded via NPM install. Lines 4 - 8 create the objects that are needed by the schema. These objects will be used for both the API and Subscription Server. Lines 23 - 29 creates the schema, that contains the typeDefs, resolvers, and schemaDirectives objects the API requires. The schema is created by using the function, makeExecutableSchema() which is part of the graphql-tools library.

Line 52 calls initGlobalDataSync(), which is a function special to IMBOB. This function does the work required to create the data layer for IMBOB and make it accessible by way of private helper functions which are used throughout the source code. (All IMBOB data is stored in files that are local to the API. This way the API carries its own data independent of any service or database making it easy to use and replenish. Please be advised that this technique is used for instructional purposes only. GraphQL API data management in the real world is a very complex topic. A production level implementation will store data in one of a variety of database technologies or through a separate storage API altogether.)

[[Listing 7]]

Listing 7: The Node.js code for defining a GraphQL schema and using it to launch the IMBOB API and Subscription servers

Line 31 in Listing 7, above, creates the actual server object that will create both the API and Subscription servers. As you can see, the schema and subscription objects that were created earlier get passed into the ApolloServer constructor as fields in constructor object starting at line 32. Also, the context field within the constructor object is assigned an anonymous Javascript function that provides authentication behavior.

Once the server is created, server.listen(PORT) is called at line 56 where PORT is the port number on which both the API server and Subscription server are listening for incoming data. The method server.listen() returns a Node.js promise. That result of the promise, which is a JSON object that contains the URL of the API server and the URL of the Subscription server is captured in a then clause. The then clause assigns the server URL and subscription URL to an environment variable, SERVER_CONFIG. Assigning the URLs to an environment variable makes them accessible globally throughout the application. Also, the then clause sends some startup information to the console. Line 64 exports the server. This is done to make both the API and Subscription servers available for instantiation in the IMBOB unit tests.

Using Pagination

In the real world, most clients using GraphQL will be working with arrays of data that are very big. For example, imagine that you're Facebook and you want to get a list of all items relevant to a particular user's news feed. This list can contain tens of thousands of items and keeps growing perpetually. Getting the entire list in one call is impractical.

The alternative is to get the information you need in chunks, from a particular starting point. This technique is called pagination. To better understand pagination, let's look at this example. Imagine that we have a list with one hundred items. Your client code sends a JSON object that's configured to say to a GraphQL API query, "get me 10 items from the list starting at Item 0." The API responds with 10 items, staring at point zero, the start of the list. Also, the GraphQL query returns as part of its response a piece of information that says, "The next item in the list in Item 10." Thus, when you make the query again, you tell the API, "get me 10 items from the list starting at Item 10." You use the technique to get back 10 items at a time, moving your "cursor' to the next starting position in the list as reported in the last query's response. This is how pagination works. You tell the query the starting point and the number of items to return. Then, the query acts accordingly.

Pagination is a technique that is used in a variety of APIs and database technologies. It's been around for a while. Pagination can be used under GraphQL, but it is not a built in part of the specification. Thus, when creating a GraphQL API using Apollo Server, you need to create your own pagination mechanisms.

IMBOB does support pagination through custom coding. The way pagination information is passed to an IMBOB API query is by way of the GraphQL Input Type, custom to IMBOB, CursorPaginationInput.

Figure 16 below, shows the IMBOB documentation for the query, persons(). Notice that query has a parameter paginationSpec of type, CursorPaginationInput. CursorPaginationInput is the custom object that contains the information the query needs to support pagination. CursorPaginationInput tells the query the starting point and the number of items to return.

Figure 16: IMBOB defines the Input type, CursorPaginationInput to facilitate using pagination with a very large list of data

Figure 16: IMBOB defines the Input type, CursorPaginationInput to facilitate using pagination with a very large list of data.

Listing 8 below shows an example of a paginated query executed against the IMBOB API. The query is persons. Notice that the pagination information defined by CursorPaginationInput is assigned to the query parameter, paginationSpec at Listing 8, line 2. While other IMBOB queries we've seen previously in this article define return-fields such as title, releaseDate, directors, and actors, the query persons takes a different approach. Notice that persons declare two fields to return, pageInfo and collection. The field, collection has the subordinate fields, firstName, lastName, and id. This might seem strange, but there is a reason.

Listing 8: A query that defined pagination information using the CursorPaginationInput type and assigns that information to the query parameter, paginationSpec.

Listing 8: A query that defined pagination information using the CursorPaginationInput type and assigns that information to the query parameter, paginationSpec.

The reason why the firstName, lastName, and id is declared as a subordinate to the field, collection is revealed in Listing 9, below. Notice that the type, persons, has two fields. One field is collection, which is an array of Person objects. This is essentially the information we want. The other field, pageInfo describes the pagination state. Thus, the type, Persons contains pagination state information as well as an array of Person objects sized according to the configuration information in the paginationSpec parameter that's passed to the persons query.

persons (paginationSpec: CursorPaginationInput): Persons

type Persons {
   collection: [Person]
   pageInfo: PageInfo!
}

type PageInfo {
   endCursor: String
   hasNextPage: Boolean
}

Listing 9: The query persons, returns not only an array of Person objects, but also a PageInfo object that describes the current state of pagination activity

Notice above in Listing 9 that the type PageInfo has a field endCursor. The field endCursor indicates the value of the unique identifier of the last item returned in the array of Person objects. When a client wants to requery the API for more data, the endCursor unique identifier value will be assigned to the field, CursorPaginationInput.after, as shown above in Listing 8. Effectively, we're telling the query to get the next X number of items starting after the person who has the unique defined by endCursor.

Now, the very important thing to understand here is that the pagination structures used in IMBOB are special to this API. Other APIs will implement pagination in ways to meet their particular needs. However, what is true in general for all APIs is that pagination information must be exchanged between query and response. There is no magic. Each API will have a special way, using special types to implement pagination. But in terms of a GraphQL query, pagination is not automatic. Pagination control must be deliberately included as part of the APIs logic.

Listing 10 below shows two sets of queries and responses using pagination for the query, persons. The first query defines only the value of CursorPaginationInput.first, with no value for CursorPaginationInput.after. By default IMBOB will start at the first item in the list, as is special to the default query logic programmed into this API. The second query uses the additional details in the pageinationSpec definition and returns items accordingly. These additional details are extracted from the pageInfo field.

Query 1
{
  persons(paginationSpec:{first:3, sortFieldName:"lastName"}){
    pageInfo{
      endCursor
    }
    collection{
      firstName
      lastName
      id
    }
  }
Result 1
{
  "data": {
    "persons": {
      "pageInfo": {
        "endCursor": "7f1e7daf-376b-4a0d-937f-2d2d09c290f6"
      },
      "collection": [
        {
          "firstName": "Ben",
          "lastName": "Affleck",
          "id": "b026dd59-98e1-46ac-9ade-5d8794265599"
        },
        {
          "firstName": "Casey",
          "lastName": "Affleck",
          "id": "68db2026-1d6c-4e27-abdb-43994d517513"
        },
        {
          "firstName": "Roslyn",
          "lastName": "Armstrong",
          "id": "7f1e7daf-376b-4a0d-937f-2d2d09c290f6"
        }
      ]
    }
  }
}
Query 2
				
{
  persons(paginationSpec:{after:"7f1e7daf-376b-4a0d-937f-2d2d09c290f6",
          first:3, 
          sortFieldName:"lastName"}){
    pageInfo{
      endCursor
    }
    collection{
      firstName
      lastName
      id
    }
  }
Result 2
{
  "data": {
    "persons": {
      "pageInfo": {
        "endCursor": "9eb66b6f-5871-4b53-8bcb-64e8ac7586b4"
      },
      "collection": [
        {
          "firstName": "Hal",
          "lastName": "Ashby",
          "id": "b5f0b9da-8ed1-4743-920e-f85d78a62131"
        },
        {
          "firstName": "Anthony",
          "lastName": "Asquith",
          "id": "ad382532-4d32-4bbe-b3ea-69213be4703a"
        },
        {
          "firstName": "Brando",
          "lastName": "Bartell",
          "id": "9eb66b6f-5871-4b53-8bcb-64e8ac7586b4"
        }
      ]
    }
  }
}

Listing 10: An example running the query, Persons multiple times using pagination

As you can see, pagination is a complex topic in GraphQL. The basic intention of this discussion is to explain that in order to support pagination, any API running under GraphQL will need to create a pagination structure that meets its particular need and that once the structure is determined, information exchange about the pagination state needs to be ongoing between query and response.

Using Directives to Validate Viewing Permissions

The last topic we're going to cover is how IMBOB supports GraphQL directives. A directive is a feature of GraphQL that allows users and developers to decorate code to alter operational behavior. Directives are similar in concept to attributes in C# and annotations in Java. You can think of a directive as way by which a developer can go into existing GraphQL type definition source code and "mark" it so that a rule is applied to a type or one of its fields. Of course, a developer must be actually program behavior for the defined directive somewhere in the API code.

A simple scenario for a directive is as follows. As you saw earlier in the IMBOB demonstration application there is a GraphQL type, person that's defined as follows:

type Person {
  id: ID
  firstName: String
  lastName: String
  dob: Date
  email: String
}

Imagine that one day the Head of Security at IMBOB decides that only users who have permission to access personal information of a person can view the email address of the person. Or, to put it another way, only users that have the special permission "PersonalScope" can view email information.

So, the security team at IMBOB goes through the IMBOB user system and identifies users entitled to "PersonalScope" and alters their profile accordingly.

However, there is still an outstanding problem: how to ensure that only users with "PersonalScope" can actually view personal information. To apply validation code in each resolver that uses a person type will be a laborious undertaking. It's much more efficient to have a single point of validation for all objects to ensure that only the right people can view email information. This is where a directive comes in.

A directive allows a developer to go into the person type in the IMBOB code base and make a single change, like so:

type Person {
  id: ID
  firstName: String
  lastName: String
  dob: Date
  email: String @requiresPersonalScope
}

That single change makes it so that any user who has "PersonalScope" can view the email information. Those that do not have "PersonalScope" will not be allowed to access to email information.

This is the theory. But, there is no magic. A developer will need to create the code that makes is so the directive @requiresPersonalScope actually does the work of enforcing the rule.

Creating the Directive

The first step to create the directive is for the developer to declare it within the GraphQL type system, like so:

directive @requiresPersonalScope on FIELD_DEFINITION

WHERE

directive is a reserved keyword under GraphQL

@requiresPersonalScope is the actual name of the directive

on FIELD_DEFINITION indicates that the directive is to assigned to a type's field at design time.

Once the directive has been declared within IMBOB GraphQL's type system, the developer needs to program the behavior that will enforce the rule the directive represents.

Programming the Directive

The way IMBOB implements the rule for @requiresPersonalScope is a technique that is special to Apollo Server. The developer creates a Javascript class, RequirePersonalScope whose behavior backs the rule the directive represents, This class will be assigned to the field requiresPersonalScope that is part of the schemaDirectives field of the schema used by Apollo Server, as shown below in Listing 11.

cconst schema = makeExecutableSchema({
   typeDefs,
   resolvers,
   schemaDirectives: {
       requiresPersonalScope: RequiresPersonalScope
   }
});

Listing 11: The directive requiresPersonalScope is declared as part of the schema definition.

The field requiresPersonalScope corresponds to the directive @requiresPersonalScope that was declared in the typedef file earlier. The fields in the schemaDirective object is the mechanism that binds the name of a directive to the actual directive behavior.

Once the directive requiresPersonalScope is defined, the developer needs to program the behavior for it. Listing 12 below, shows the code that's implemented for the @requiresPersonalScope behavior. The code is from the module, directives.js that is part of the IMBOB source on GitHub.

Listing 12: The Javascript class RequiresPersonalScope that enforces the rule for the directive @requiresPersonalScope

Listing 12: The Javascript class RequiresPersonalScope that enforces the rule for the directive @requiresPersonalScope

Conceptually what's going in Listing 12 is that at runtime ApolloServer will "automatically" pass to RequiresPersonalScope any object that has a field that was marked at design-time with the directive @requiresPersonalScope. This "magic" is part of the internals of Apollo Server. The code not only knows about the marked field (in this case, email), but it also knows about the request that executed the query. The way that RequiresPersonalScope knows about the request is by inspecting the global context object also supplied by Apollo server. Context carries the request with it in either its req or request field as shown in the isValidToken function at line 52 of Listing 12 above. Once the request is known, the security access token can be inspected and the user can be determined. Then it's just a matter of determining if the user has "PersonalScope" as shown in Line 54 of Listing 12. If the user does indeed have PersonalScope, the value of the marked field is revealed as shown in Figure 17 below.

Figure 17: The IMBOB @requiresPersonalScope directive exposes sensitive information only to users who have the necessary permissions.

Figure 17: The IMBOB @requiresPersonalScope directive exposes sensitive information only to users who have the necessary permissions.

If the user does not have "PersonalScope, a "forbidden" message is assigned to the file value as shown in Line 44 of Listing 12. The "forbidden" message is then displayed as the field value instead of the sensitive information as shown below in Figure 18.

Figure 18: Users without the required permissions do not get to see sensitive information.

Figure 18: Users without the required permissions do not get to see sensitive information.

The beauty of implementing a security policy by using a directive such as P@requiresPersonalScope, or for that matter any other directive intended as a PFIELD_DESCRIPTION, is that they can be assigned to any field in the type system. It's a great efficiency. Instead of having to go through each resolver and implement code to support a rule, all the developer needs to do is define the directive behavior once in a single module and then apply it to fields that need to support that particular rule. It's a real benefit.

Putting it All Together

This has been a voluminous installment of the series. We really did cover things soup to nuts. We covered real implementations of types, queries, mutations, and resolvers. We also covered creating and using subscriptions to program asynchronous messaging out of the API in real time. We looked at how to implement authentication. Finally we wrapped up with a discussion of implementing pagination in GraphQL and using GraphQL directives.

In the next installment of this series we're going to step back from heads down work with GraphQL and take a higher level view of an important aspect of GraphQL. We're going to look at how GraphQL fits into the promise of the Semantic Web.

NextPart 4 -- How GraphQL Delivers on the Original Promise of the Semantic Web. Or Not.

Be sure to read the next GraphQL article: How GraphQL Delivers on the Original Promise of the Semantic Web. Or Not.

 

Comments (0)