(Text covers the exact same content as the video)
Overview
Let's dig a little deeper into our Connector calls, how to optimize them, and how to deal with a common error.
In this lesson, we will:
- Learn about entities and how to define them in the schema
- Learn about the satisfiability error
More listing fields
We've got more to add to listings: some important attributes to showcase our destination.
In our schema, we'll add four new fields to our
Listing
type: description, photo, and coordinates for latitude and longitude.listings.graphql"The listing's description"description: String"The thumbnail image for the listing"photoThumbnail: String"Latitude coordinates for the destination"latitude: Float"Longitude coordinates for the destination"longitude: FloatThese should already exist in our REST API. First, let's take a look at the
GET /listings/:id
endpoint: https://airlock-listings.demo-api.apollo.dev/listings/listing-1. We've got all four properties available in the response!{"costPerNight": 120,"title": "Cave campsite in snowy MoundiiX","locationType": "CAMPSITE","description": "Enjoy this amazing cave campsite in snow MoundiiX, where you'll be one with the nature and wildlife in this wintery planet. All space survival amenities are available. We have complementary dehydrated wine upon your arrival. Check in between 34:00 and 72:00. The nearest village is 3AU away, so please plan accordingly. Recommended for extreme outdoor adventurers.","id": "listing-1","numOfBeds": 2,"closedForBookings": false,"photoThumbnail": "https://res.cloudinary.com/apollographql/image/upload/v1644350721/odyssey/federation-course2/illustrations/listings-01.png","hostId": "user-1","isFeatured": true,"latitude": 1023.4,"longitude": -203.4,"amenities": [{"id": "am-2","category": "Accommodation Details","name": "Towel"},{"id": "am-10","category": "Space Survival","name": "Oxygen"}// more amenities]}So we know that we can add these to our selection for the
Query.listing
Connector, in a one-to-one mapping.listings.graphqllisting(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude""")But what about the other path that returns a Listing type:
Query.featuredListings
? Jumping over to the endpoint for that Connector: https://airlock-listings.demo-api.apollo.dev/featured-listingsGET /featured-listings[{"id": "listing-1","title": "Cave campsite in snowy MoundiiX","numOfBeds": 2,"costPerNight": 120,"closedForBookings": false,"amenities": [{"id": "am-2"},{"id": "am-10"}// more amenities]}// more listing objects]
The response doesn't seem to have any of those fields.
This is a common pattern in REST APIs, where endpoints might not return the same properties. So how can we account for this discrepancy? We'll turn to the power of entities.
Entities in GraphQL
An entity is an object that can be fetched using a unique identifier. You can think of it like a row in a database, where we can retrieve a user by ID, or a product by its UPC.
In a GraphQL API, we typically have multiple, separate data sources that populate different fields of an entity.
For example, listing information like title and description might come from one REST API, but host and guest information for that same listing might come from another. Or, as we saw earlier, one endpoint might only return a subset of listing information, compared to another, from that same API, that returns the full listing object.
Entity schema syntax
To define an entity type in our schema, we use the @key
directive, followed by the fields of its unique identifier.
type Product @key(fields: "upc") {upc: ID!}
We'll also need to define how to retrieve the data for the entity's fields. We'll do this with a Connector applied to the entity type, where we'll define the details of the request to the endpoint, and map the response to our entity's fields.
type Product @key(fields: "upc")@connect(# ...){upc: ID!}
Note: The @connect
directive can attach to any plain object type too; it doesn't need to be an entity!
Every field in the entity type needs to be included in the Connector's selection
mapping, unless that field has its own Connector. We can even define multiple Connectors on the same type, which is useful when different fields need to pull data from different sources.
If you're worked with GraphQL Federation before, then you'll be familiar with these instructions we're referring to. In federation, they're referred to as a reference resolver or entity resolver. In Apollo Server specifically, that's the __resolveReference
function.
With Apollo Connectors, we're replacing the logic within the __resolveReference
function with a Connector. Most of the time, the logic is the same. We're sending a call to an endpoint that lets us get all the properties of a specific entity based on its key field.
The Listing
entity
Let's see this in action with the Listing
type.
First, we'll define it as an entity by applying
@key
and choosing theid
field as its unique identifier.listings.graphqltype Listing @key(fields: "id") {id: ID!# ... other Listing fields}Next up, the Connector. Let's attach the
@connect
directive to theListing
type and start to fill in the basics. Thesource
will point tolistings.
listings.graphqltype Listing@connect(source: "listings"http: { GET: "" }selection: """# TODO""")@key(fields: "id"){# ... Listing fields}For the endpoint, we've already used one in previous lessons, the
GET
endpoint to retrieve a specific listing:/listings/:id
. We'll use the variable$this.id
interpolated into the path to access the value of this listing'sid
field.listings.graphqltype Listing@connect(source: "listings"http: { GET: "/listings/{$this.id}" }selection: """# TODO""")@key(fields: "id"){# ... Listing fields}Finally, the
selection
. This Connector needs to return data for all the fields in the type. Let's use the same mapping we defined in theQuery.listing
field. We're going to leave outamenities
information since that field has its own Connector attached.listings.graphqltype Listing@connect(source: "listings"http: { GET: "/listings/{$this.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude""")@key(fields: "id"){# ... Listing fields}
extend schema@link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"])@link(url: "https://specs.apollo.dev/connect/v0.2"import: ["@source", "@connect"])@source(name: "listings"http: { baseURL: "https://airlock-listings.demo-api.apollo.dev" })type Query {"A curated array of listings to feature on the homepage"featuredListings: [Listing!]!@connect(source: "listings"http: { GET: "/featured-listings" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookings""")"A specific listing"listing(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude""")}"A particular intergalactic location available for booking"type Listing@connect(source: "listings"http: { GET: "/listings/{$this.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude""")@key(fields: "id") {id: ID!"The listing's title"title: String!"The number of beds available"numOfBeds: Int"The cost per night"costPerNight: Float"Indicates whether listing is closed for bookings (on hiatus)"closedForBooking: Boolean"The listing's description"description: String"The thumbnail image for the listing"photoThumbnail: String"Latitude coordinates for the destination"latitude: Float"Longitude coordinates for the destination"longitude: Float"The amenities available for this listing"amenities: [Amenity!]!@connect(source: "listings"http: { GET: "/listings/{$this.id}/amenities" }selection: """idnamecategory""")}type Amenity {id: ID!"The amenity category the amenity belongs to"category: String!"The amenity's name"name: String!}
Checking our work
Let's save our changes before switching over to the client side. Back in Sandbox, we'll start with the operation for a single listing and add those new fields to our query.
query GetListingWithAmenities($listingId: ID!) {listing(id: $listingId) {idtitlenumOfBedscostPerNightclosedForBookingdescriptionlatitudelongitudephotoThumbnailamenities {idcategoryname}}}
And the response is looking good!
{"data": {"listing": {"id": "listing-9","title": "The Nostromo in LV-426","numOfBeds": 4,"costPerNight": 474,"closedForBooking": false,"description": "Ever wondered what it must be like to be aboard The Nostromo, minus the Xenomorph? Now you can find out!","latitude": 123.989,"longitude": 534.98,"photoThumbnail": "https://res.cloudinary.com/apollographql/image/upload/v1644353889/odyssey/federation-course2/illustrations/listings-09.png","amenities": [{"id": "am-1","category": "Accommodation Details","name": "Interdimensional wifi"},{"id": "am-2","category": "Accommodation Details","name": "Towel"},{"id": "am-3","category": "Accommodation Details","name": "Universal remote"},{"id": "am-4","category": "Accommodation Details","name": "Adjustable gravity"},{"id": "am-5","category": "Accommodation Details","name": "Quantum microwave"},{"id": "am-6","category": "Accommodation Details","name": "Retractable moonroof"},{"id": "am-7","category": "Accommodation Details","name": "Wormhole trash chute"},{"id": "am-10","category": "Space Survival","name": "Oxygen"},{"id": "am-22","category": "Outdoors","name": "Hydroponic garden"},{"id": "am-23","category": "Outdoors","name": "Space view"},{"id": "am-24","category": "Outdoors","name": "Time travel paradoxes"},{"id": "am-15","category": "Space Survival","name": "Water recycler"},{"id": "am-16","category": "Space Survival","name": "Panic button"},{"id": "am-17","category": "Space Survival","name": "Emergency life support systems"}]}}}
Let's check out what's happening behind the scenes in the Connectors Debugger.
We've got two calls: one to our GET /listing/:id
endpoint, and another for GET /listing/:id/amenities
.
By the way, you can avoid that extra amenities
call. Check out the section below for more details.
If you looked carefully, you might have seen that our GET /listings/:id
endpoint actually includes amenities
information!
Let's add it to our selection to avoid two separate calls.
Find the Query.listing
Connector, and add amenities
to the selection
mapping.
If we save our changes right now, we'll get a helpful message from Rover. It makes sense: Amenity
is an object type, so we need to include its fields in the data we return. Specifically, we want to map each amenity's id
, category
and name
from the response object to our schema; a one to one mapping that we've already seen before.
But this time, because we're returning an object type, we'll add curly braces after amenities
, indicating that we're going one level deeper, into our Amenity
type. Inside, we can define that one to one mapping.
listing(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitudeamenities {idnamecategory}""")
We'll do the same thing for the Listing
entity Connector.
type Listing@connect(source: "listings"http: { GET: "/listings/{$this.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitudeamenities {idnamecategory}""")@key(fields: "id"){# ... Listing fields}
Back in Sandbox, when we run the GetListingWithAmenities
operation, we can see it'll only make one call, since both Connectors now include all of the fields for Listing
type in its selection
.
With these changes, we can safely remove the Listing.amenities
Connector in our schema; everything is handled by the Listing
entity connector!
We can also make one more tweak to save an extra call. In the Query.featuredListings
Connector, we can add the amenities { id }
mapping to our selection
. The response from GET /featured-listings
includes just the amenity id
after all!
featuredListings: [Listing!]!@connect(source: "listings"http: { GET: "/featured-listings" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsamenities {id}""")
As you expand your graph with more types and fields, it's helpful to audit your existing Connectors and look for ways to save an extra network hop!
Next, let's look at the Query.featuredListings
path. We can switch over to our first operation, GetFeaturedListings
. We'll add those four new fields, and check out the response.
query FeaturedListings {featuredListings {idtitlenumOfBedscostPerNightclosedForBookingdescriptionlatitudelongitudephotoThumbnail}}
Looks like things are working!
{"data": {"featuredListings": [{"id": "listing-1","title": "Cave campsite in snowy MoundiiX","numOfBeds": 2,"costPerNight": 120,"closedForBooking": false,"description": "Enjoy this amazing cave campsite in snow MoundiiX, where you'll be one with the nature and wildlife in this wintery planet. All space survival amenities are available. We have complementary dehydrated wine upon your arrival. Check in between 34:00 and 72:00. The nearest village is 3AU away, so please plan accordingly. Recommended for extreme outdoor adventurers.","latitude": 1023.4,"longitude": -203.4,"photoThumbnail": "https://res.cloudinary.com/apollographql/image/upload/v1644350721/odyssey/federation-course2/illustrations/listings-01.png"},{"id": "listing-2","title": "Cozy yurt in Mraza","numOfBeds": 1,"costPerNight": 592,"closedForBooking": true,"description": "Thiz cozy yurt has an aerodyne hull and efficient sublight engines. It is equipped with an advanced sensor system and defensive force shield. Meteor showers are quite common, please rest assured that our Kevlar-level shields will keep you safe from any space debris. Mraza suns are known to have high levels of UV hyper radiation, which we counteract with the yurt's UV protection shield.","latitude": 102.11,"longitude": -1234.22,"photoThumbnail": "https://res.cloudinary.com/apollographql/image/upload/v1644350839/odyssey/federation-course2/illustrations/listings-02.png"},{"id": "listing-3","title": "Repurposed mid century aircraft in Kessail","numOfBeds": 5,"costPerNight": 313,"closedForBooking": false,"description": "Enjoy this floaty, repurposed aircraft reminiscent of Earth's former converted airstreams. Includes lake access!","latitude": -17.56,"longitude": -2126.78,"photoThumbnail": "https://res.cloudinary.com/apollographql/image/upload/v1644353887/odyssey/federation-course2/illustrations/listings-03.png"}]}}
Let's see what's happening under the hood.
We can see our original call to the GET /featured-listings
endpoint, then an additional call for each specific listing.
This is our entity in action, with the router and Connectors handling the API orchestration for us. The first Connector mapping did not include those new fields. So in order to satisfy the rest of the client's query, the router knows it can reach out to the Listing
entity type Connector for the rest of the fields.
A world without entities
One last thing. Let's take a peek into an alternate universe: what would have happened if we didn't create the Listing
as an entity with its own Connector?
We would have run into SATISFIABILITY
errors.
error[E029]: Encountered 4 build errors while trying to build a supergraph.Caused by:SATISFIABILITY_ERROR: The following supergraph API query:{featuredListings {description}}cannot be satisfied by the subgraphs because:- from subgraph "listings":- cannot find field "Listing.description".- cannot move to subgraph "listings", which has field "Listing.description", because type "Listing" has no @key defined in subgraph "listings".- from subgraph "listings": cannot find field "Listing.description".SATISFIABILITY_ERROR: The following supergraph API query:{featuredListings {latitude}}# ... same errors for longitude and photoThumbnail
Satisfiability errors
Before Rover can mark a schema as valid, it checks if all the paths to a specific field can be satisfied. This ensures that the router can take on any operation the client sends its way.
In our case, the error is pointing to the featuredListings
path in particular, stating that the router won't be able to satisfy client requests that ask for description, photo, and coordinates that come through the featuredListings
entry point. And we already know that particular Connector can't retrieve the data for those fields.
But we don't need to worry: the Listing
entity Connector can step in to handle the rest of those fields for us.
extend schema@link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"])@link(url: "https://specs.apollo.dev/connect/v0.2"import: ["@source", "@connect"])@source(name: "listings"http: { baseURL: "https://airlock-listings.demo-api.apollo.dev" })type Query {"A curated array of listings to feature on the homepage"featuredListings: [Listing!]!@connect(source: "listings"http: { GET: "/featured-listings" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookings""")"A specific listing"listing(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude""")}"A particular intergalactic location available for booking"type Listing@connect(source: "listings"http: { GET: "/listings/{$this.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude""")@key(fields: "id") {id: ID!"The listing's title"title: String!"The number of beds available"numOfBeds: Int"The cost per night"costPerNight: Float"Indicates whether listing is closed for bookings (on hiatus)"closedForBooking: Boolean"The listing's description"description: String"The thumbnail image for the listing"photoThumbnail: String"Latitude coordinates for the destination"latitude: Float"Longitude coordinates for the destination"longitude: Float"The amenities available for this listing"amenities: [Amenity!]!@connect(source: "listings"http: { GET: "/listings/{$this.id}/amenities" }selection: """idnamecategory""")}type Amenity {id: ID!"The amenity category the amenity belongs to"category: String!"The amenity's name"name: String!}
We've only just scratched the surface of how entities help enable API orchestration. As we grow and add more domains, we'll explore more of what entities have to offer.
Practice
Key takeaways
- An entity is an object that can be fetched using a unique identifier. To define an entity type in the schema, we use the
@key
directive along with the field(s) acting as its unique identifier. - The
@connect
directive attaches to an entity type to provide instructions for the router on how to retrieve the data for the entity's fields. - Satisfiability errors occur when a field in an operation can't be reached through the schema's Connectors.
Up next
One last lesson to go. We've tackled queries to retrieve data—now it's time to switch our attention over to manipulating data!
Share your questions and comments about this lesson
Your feedback helps us improve! If you're stuck or confused, let us know and we'll help you out. All comments are public and must follow the Apollo Code of Conduct. Note that comments that have been resolved or addressed may be removed.
You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.