Scopes
Scopes
This document provides an overview on how to define scopes on the Viaduct GraphQL schema.
The scoop on scopes
In order to make schema visibility control more explicit and intuitive, we introduced Scopes.
Scopes are a mechanism for encapsulating parts of your schema for information-hiding purposes. Two commonly-used scopes are viaduct
, which has a bigger schema available to all of your internal systems, and viaduct:public
, a smaller schema available publicly to your frontend clients. Almost all data types will have viaduct
scope, which allows them to be queried by backend clients. Some data types, or a subset of fields within data types that can be queried by frontend clients, also have the viaduct:public
scope.
Scopes leverage GraphQL type extensions to separate fields within a type that belong to different scopes. This convention avoids the need for annotating each field in the schema with @scope
directives, and provides a way to separate different types of fields in the SDL to optimize for human readability.
For example, if we are to expose only field1
from Foo
to viaduct:public
but not field2
or field3
, we will organize our schema like this:
type Foo @scope(to: ["viaduct", "viaduct:public"]) {
field1: Bar
}
extend type Foo @scope(to: ["viaduct"]) {
field2: Int
field3: String
}
type Bar @scope(to: ["viaduct", "viaduct:public"]) {
field4: Boolean
field5: String
}
As you will also learn from the later sections, the scope validation system will also enforce explicitness so that each type (including object
, input
, interface
, union
, and enum
) and their extensions will always have at least one scope value.
[alert]
Either nothing is scoped or everything has a scope applied to it. There is no default scope.
[/alert]
Multiple Schemas
A single instance of the Viaduct framework can expose multiple schemas. Within Airbnb, for instance, the Viaduct service itself, exposes a different, more complete schema to internal clients than it exposes to external Web and mobile clients. Scopes also provide encapsulation that allows us to hide implementation details present in your central schema.
Every schema exported by an instance of the Viaduct framework is called a scope set. A scope set is identified by a schema ID. The particular scope set seen by a given request to the Viaduct framework is controlled by the schemaId
field of ExecutionInput
. In the above example, viaduct
and viaduct:public
are both schema IDs. You can use as many schema IDs as you like with whatever naming scheme fits your use case.
Guidelines for annotating types with @scope
Always append @scope
to the main type
Whenever you are creating a new type (including object type
, input type
, interface
, union
, and enum
), always append @scope(to: ["scope1", "scope2", ...])
to the type itself. (Replace "scope1"
, "scope2"
, etc. with your desired scopes)
All the attributes within the main type will share and be exposed to ALL the scopes defined in the @scope
values.
# field1 and field2 will be visible to both Viaduct data API and public API
type Foo @scope(to: ["viaduct", "viaduct:public"]) {
field1: Int
field2: String
}
# CONSTANT1 and CONSTANT2 will be visible to Viaduct data API, as well as both foo and bar.
enum Bar @scope(to: ["viaduct", "foo", "bar"]) {
CONSTANT1
CONSTANT2
}
# field from Baz is only visible to Viaduct data API
input Baz @scope(to: ["viaduct"]) {
field: Boolean
}
Create type extensions and move fields with narrower scopes to this extension
If you need to limit the visible scopes for certain fields, create a type extension, move those fields over, and append @scope
with proper scopes.
For example, the following definition will expose field3
to private
only.
type Foo @scope(to: ["public", "private"]) {
field1: Int
field2: String
}
extend type Foo @scope(to: ["private"]) {
field3: Boolean
}
Validation
In GraphQL SDL, scopes are referenced by their string value, and in Kotlin are referenced by the strongly typed enum member. The SDL will be validated at build time to ensure invalid scope names were not referenced.
In addition to detecting invalid scope names, the Viaduct Bazel validators will perform other static analysis on the schema in order to detect invalid or confusing scope usage.
Detecting inaccessible fields when referencing another type
Static analysis tooling will detect @scope
usages that cause inaccessible fields. Take the following schema excerpt:
type User @scope(to: ["scope1","scope2"]) {
id: ID!
firstName: String
lastName: String
}
type Listing @scope(to: ["scope3"]) {
user: User # this field would never be accessible
}
In the above example, the User type is only available in the scope1
and scope2
scopes, and the Listing type is only available in the scope3
scope. The user
field in Listing
is of type User
, but User
is not visible to the scopes associated with the Listing
type. Thus, filtering the schema to any of the three scopes used in this schema excerpt would result in the user
field within Listing
being filtered out of the schema.
This invariant can be corrected in two ways:
- The
Listing
type’s scope should be modified to includescope1
orscope2
, OR - The
User
type’s scope should be modified to include thescope3
scope.
Auto prune a type when all of its fields are out of scope
When all the fields of a type are out of scope, this type will not be accessible, even if it is within the scope. Therefore, Viaduct prunes such empty types recursively in the generated schema. For example:
type StaySpace @scope(to: ["viaduct", "listing-block", "viaduct:private"]) {
spaceId: Long
metadata: SpaceMetadata
}
type SpaceMetadata @scope(to: ["viaduct", "listing-block"]) {
bathroom: StayBathroomMetadata
bedroom: StayBedroomMetadata
}
type StayBathroomMetadata @scope(to: ["viaduct", "viaduct:private"]) {
spaceName: String
}
type StayBedroomMetadata @scope(to: ["viaduct", "viaduct:private"]) {
spaceName: String
}
Filtering the above schema excerpt to listing-block
will make SpaceMetadata
an empty type, since both StayBathroomMetadata
and StayBedroomMetadata
will not be in the scope. Thus, SpaceMetadata
attribute will be pruned from StaySpace
in the listing-block
scope, as it will not be reachable, despite that this type has annotated with listing-block
scope.
Detect invalid scope usage within a type
When leveraging a type extension to define fields that are available in a specific scope, it’s essential that the scope specified in the directive on the type extension be one of the scopes specified in the original definition of the type.
Take for example the following schema excerpt:
type User @scope(to: ["viaduct","user-block"]) {
id: ID!
firstName: String
lastName: String
}
extend type User @scope(to: ["viaduct:internal-tools"]) {
aSpecialInternalField: String
}
This example shows an incorrect usage of the scope directive on the type extension, as the viaduct:internal-tools
scope is not specified as a scope on the original User type definition.
The correct version of the above example would be:
type User @scope(to: ["viaduct","viaduct:internal-tools", "user-block"]) {
id: ID!
firstName: String
lastName: String
}
extend type User @scope(to: ["viaduct:internal-tools"]) {
aSpecialInternalField: String
}
This validation rule enforces the convention that common fields be defined in the original type definition, and fields that are defined in specific scopes be specified using type extensions.
Troubleshooting
If you get an error like Unable to find concrete type ... for interface ... in the type map
or Unable to find concrete type ... for union ... in the type map
, it’s because the interface or union type is defined in schema module A, and the concrete type that implements the interface or extends the union is defined in schema module B, and B does not depend on A.
For example, this is not allowed because UnionA
is defined in the data module and its member TypeA
is defined in the entity module. The entity module does not depend on the data module.
# modules/entity
type TypeA {
fieldA: String
}
# modules/data
type TypeB {
fieldB: String
}
union UnionA = TypeB
extend UnionA = TypeA # <-- not ok
To fix it, make sure you define the concrete type of interface or union in a schema module that is dependent on the schema module where the interface or union type is defined.
For example, you can “lift” up the type definition for UnionA
to the entity module:
# modules/entity
type TypeA {
fieldA: String
}
union UnionA = TypeA
# modules/data
type TypeB {
fieldB: String
}
extend type UnionA = TypeB # <-- ok
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.