(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!}
An entity type also needs to provide instructions for the router on how to retrieve the data for the entity's fields. These instructions will be in the form of a Connector. Specifically, one that is:
- applied to a
Query
field - uses the entity's
key
field as an argument, and - returns the entity type.
In the schema, we can mark that Connector with entity: true
.
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 the @connect(entity: true)
directive. 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, those instructions for fetching data. We have just the Connector for that!
Query.listing
takes in a listingid
(which is the entity's@key
field) and returns aListing
type. So we'll mark this Connector withentity: true
.listings.graphqllisting(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """idtitlenumOfBedscostPerNightclosedForBooking: closedForBookingsdescriptionphotoThumbnaillatitudelongitude"""entity: true)
extend schema@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"])@link(url: "https://specs.apollo.dev/connect/v0.1"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"""entity: true)}"A particular intergalactic location available for booking"type Listing @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}""")
Back in Sandbox, when we run the GetListingDetails
operation, we can see it'll only make one call, since the Query.listing
Connector now includes all of the fields for Listing
type in its selection
.
Note that we do still need to keep the Listing.amenities
Connector in our schema, to handle the Query.featuredListings
path, which does not return the full amenities
information in the response object. It only returns the amenity id
property.
If we remove the Listing.amenities
Connector, we'll get a SATISFIABILITY
error, which you'll learn about later on in this lesson.
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 Query.listing
Connector, following the instructions marked by entity: true
.
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?
Let's remove entity: true
and the @key
fields.
type Query {# ... featuredListings"A specific listing"listing(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """# ..."""- entity: true)}"A particular intergalactic location available for booking"- type Listing @key(fields: "id") {+ type Listing {}
Save our changes, and... we have 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, along with the Connector that provides the instructions, can step in to handle the rest of those fields for us.
Let's make sure we undo our changes, and bring that entity back!
type Query {# ... featuredListings"A specific listing"listing(id: ID!): Listing@connect(source: "listings"http: { GET: "/listings/{$args.id}" }selection: """# ..."""+ entity: true)}"A particular intergalactic location available for booking"+ type Listing @key(fields: "id") {}
extend schema@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"])@link(url: "https://specs.apollo.dev/connect/v0.1"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"""entity: true)}"A particular intergalactic location available for booking"type Listing @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. - An entity type also needs to provide instructions for the router on how to retrieve the data for the entity's fields. To define the Connector with this role, we use
entity: true
. - 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.