GraphQL @ Shopify

GraphQL @ Shopify

F34d97ba1bfea0ff5e35a9c198562402?s=128

Marc-Andre Giroux

October 26, 2016
Tweet

Transcript

  1. G R APHQL S H O P IF Y

  2. MA RC- ANDRE GIRO UX @__xuorig__ MONTREA L, C ANADA

  3. None
  4. G RAPH QL CORE TEAM

  5. Goal: Make it easy for any team to build their

    own schemas
  6. Goal: Make it easy for mobile developers to extend the

    schemas
  7. Defining the Schema(s)

  8. gem 'graphql' ❤

  9. ShopType = ObjectType.define do name "Shop" description "An awesome shop!"

    field :name, types.String, "The shops's name” # `!` marks this field as non-null: field :currency, !types.Int # Returns a list of `ProductType`s field :products, types[PersonType] end
  10. GraphModel / GraphApi

  11. GraphQL gem GraphModel GraphApi

  12. module GraphApi class Shop < GraphApi::ObjectType # Types are declared

    using symbols or type class field :name, :string # Explicit null kwarg to define non-null fields field :currency, :string, null: false field :products, [Product] end end
  13. I can’t believe it’s not butter ActiveRecord!

  14. module GraphApi class ProductVariant < GraphApi::ObjectType belongs_to :product, Product end

    end
  15. module GraphApi class Product < GraphApi::ObjectType cache_has_many :variants, [ProductVariant] end

    end
  16. module GraphApi class Product < GraphApi::ObjectType paginated :variants, ProductVariant end

    end
  17. query { shop { products { image { url }

    } } }
  18. query { shop { products { image { url }

    } } }
  19. query { shop { products { image { url }

    } } } SELECT `shops`.* FROM `shops` WHERE `shops`.`id` = 1
  20. query { shop { products { image { url }

    } } } SELECT `products`.* FROM `products` AND `products`.`shop_id` = 1
  21. query { shop { products { image { url }

    } } } SELECT `product_images`.* FROM `product_images` WHERE `product_images`.`product_id` = 1 SELECT `product_images`.* FROM `product_images` WHERE `product_images`.`product_id` = 2 SELECT `product_images`.* FROM `product_images` WHERE `product_images`.`product_id` = 3
  22. Products Image Image Image product1.image product3.image product2.image

  23. Batching

  24. https://github.com/Shopify/graphql-batch

  25. loader = GraphModel::AssociationLoader.for(record.class, :image) loader.load(record) => #<GraphQL::Batch::Promise @callbacks=[], @state=:pending> Return

    a Promise
  26. With Batching Products Image Image Image loader.load(1) loader.load(2) loader.load(3) =>

    #<Promise> => #<Promise> => #<Promise>
  27. class AssociationLoader < GraphQL::Batch::Loader def load(record) # => Cache and

    return a Promise end def perform(records) # Prefetch the associations and fulfill all promises! @model.prefetch_associations(@association_name, records) records.each { |record| fulfill(record, read_association(record)) } end end Defining a Loader
  28. Promise.rb + Custom Executor

  29. module GraphApi class Product < GraphApi::ObjectType field :image, ProductImage, preload:

    :image end end The API
  30. module GraphApi class Product < GraphApi::ObjectType field :image, ProductImage, preload:

    :image end end The API
  31. prefetch(obj, arguments, context).then do resolve(object, arguments, context) end Wrap resolve

    with promise
  32. query { shop { products { image { url }

    } } } With Batching
  33. query { shop { products { image { url }

    } } } SELECT `shops`.* FROM `shops` WHERE `shops`.`id` = 1
  34. query { shop { products { image { url }

    } } } SELECT `products`.* FROM `products` AND `products`.`shop_id` = 1
  35. query { shop { products { image { url }

    } } } SELECT `product_images`.* FROM `product_images` WHERE `product_images`.`product_id` IN (1, 2, 3)
  36. query { shop { products { image { url }

    } } } SELECT `product_images`.* FROM `product_images` WHERE `product_images`.`product_id` IN (1, 2, 3)
  37. Authorization / Authentication

  38. module GraphApi class Order < GraphApi::ObjectType required_access(:orders) field :email, :string

    end end Type Access
  39. module GraphApi class Order < GraphApi::ObjectType required_access(:orders) field :email, :string

    end end Type Access
  40. module GraphApi class Order < GraphApi::ObjectType required_access(:orders, write: true) field

    :email, :string end end Type Access
  41. module GraphApi class Order < GraphApi::ObjectType required_access(:orders) do |order| order.visible?

    end field :email, :string end end Type Access
  42. context = { access: GraphApi::AccessControl.new(@access_token, @user) } AccessControl

  43. Evil Clients

  44. class Query def result Timeout.timeout(@timeout) { @query.result } rescue Timeout::Error

    { "errors" => [{ "message" => "Timeout" }] } end end Timeouts
  45. class Query def result TimeThrottle.for(key, options) { @query.result } rescue

    TimeThrottle { "errors" => [{ "message" => "Throttled" }] } end end Throttling
  46. Developer Experience

  47. Checked-In IDL

  48. Checked-In IDL

  49. Protecting the Schema

  50. Protecting the Schema

  51. Protecting the Schema

  52. Protecting the Schema

  53. Conventions • Mutation actions are suffixed productCreate, not createProduct •

    Mutations return a `userErrors` field on errors • Support Relay spec (deletedId, newEdge, clientMutationId)
  54. Conventions

  55. Instrumentation

  56. Deprecated Field Usage

  57. Mobile Clients

  58. Overview • Background • GraphQL Code Generator • Query Builders

    • Response Classes • Caching and Data Consistency
  59. Mobile Shopify App • Rebuilt to provide full Shopify Admin

    • Fast on slow networks by reducing response sizes (REST -> GraphQL) Since Sept. 27
  60. React-Native Experiment • ❤ GraphQL • ❤ Components • Quick

    to build, hard to polish • Switched to using native Android and iOS APIs • Need Java and Swift GraphQL clients
  61. GraphQL Client Goals • Simplicity • Avoid unnecessary complexity •

    Quick to implement • Easy to understand • Productive mobile developer • Embrace static type system • IDE Integration (Android Studio & Xcode) • Build for long term • Components to decouple large codebase • Reduce schema evolution friction
  62. GraphQL Schema The Killer Feature

  63. Generate code from the schema script/update_schema Query Builders Classes Response

    Classes Schema
  64. Query Builders

  65. Swift Query Builder ApiSchema.buildQuery() { $0 .product(id: productId) { $0

    .id() .title() .images(first: 1) { $0 .id() .src() } } } { product(id: "gid://shopify/Product/42") { id title images(first: 1) { id src } } } (Pretty Printed)
  66. Java Query Builder APISchema.query(q -> q .product(productId, p -> p

    .id() .title() .images(1, img -> img .id() .src() ) ) ) • Requires java 8 lambda expressions: • Retrolambda backports to java 5-7 • New Jack Android toolchain { product(id: "gid://shopify/Product/42") { id title images(first: 1) { id src } } } (Pretty Printed)
  67. Simple Query Composition func loadProduct() { let query := ApiSchema.buildQuery

    { $0 .product(id: productId, self.productDetailQuery) .shop(self.shopDetailQuery) } … } func productDetailQuery(_ product: ApiSchema.ProductQuery) { product .id() .title() …
  68. Inline Fragments on Type ApiSchema.buildQuery { $0 .event(id: eventId) {

    $0 .message() .onCommentEvent { $0 .author() } } } { event(id: “gid://shopify/Event/42") { __typename id message ... on CommentEvent { author } } } (Pretty Printed)
  69. Type-Safety and IDE Integration

  70. Query Builders - Summary • Simplicity • No GraphQL variables,

    named fragments or directives • No IDE plugin required • Productive mobile developer • Type-safe • IDE features just work • Build for long term • Can declare data dependancies close to their use • script/update_schema to adapt to schema changes (e.g. deprecations)
  71. Response Classes

  72. Response objects guard let response = response.product else { logger.error("tried

    to view product details for deleted product") return } variants = product.variants.edges.map { $0.node } • Validates and deserializes fields based on their type in constructor • Field methods have appropriate return types • Leverages language type system, including swift optional types • Runtime error for accessing unqueried field • Replace model layer
  73. Custom Scalars GraphQLGenerator::Swift.new(schema, nest_under: 'ApiSchema', custom_scalars: [ GraphQLGenerator::Swift::Scalar.new( type_name: 'Money',

    swift_type: 'NSDecimalNumber', deserialize_expr: ->(expr) { "NSDecimalNumber(string: #{expr}, locale: GraphQL.posixLocale)" }, serialize_expr: ->(expr) { "#{expr}.description(withLocale: GraphQL.posixLocale)" } ) ] ).save(output_directory)
  74. Unknown Value/Type switch variant.inventoryPolicy { case .deny: // … case

    .unknownValue // generated enum value // handle values added in the future } // no default to handle all known cases // type check on GraphQL interface if let comment = event as? ApiSchema.CommentEvent { appendMessage(comment.message, author: comment.author) } else { // use interface fields for other/unknown object types appendMessage(comment.message) }
  75. Response Classes - Summary • Simplicity • Replace hand written

    model layer • Productive mobile developer • Use field methods without coercion • Type system imposes constraints (e.g. enums, optionals) • IDE features work • Build for long term • Unknown types/values leave room for schema evolution • Server-side computed fields avoid inaccuracies as business logic changes
  76. Caching and Data Consistency

  77. Response Caching • Response cache, keyed by query string, persisted

    to disk • Optimistically render using cached response, but request still sent extension ProductDetailsViewController: Relayable { func handleRelayQuery( query: ApiSchema.QueryRootQuery, response: ApiSchema.QueryRoot?, error: GraphQueryError?, cached: Bool) { session.relayContainer.queryGraph(query: query, relayable: self)
  78. Active Queries • List of active query stored in memory

    to render data changes • Weak reference to cleanup active queries after their view is freed extension ProductDetailsViewController: Relayable { func handleRelayQuery( query: ApiSchema.QueryRootQuery, response: ApiSchema.QueryRoot?, error: GraphQueryError?, cached: Bool) { session.relayContainer.queryGraph(query: query, relayable: self)
  79. Mutations • Mutations apply to all active queries • Find

    nodes in all active queries that match a node in the payload by id: • Deep merge fields from payload node on active query node • Call handleRelayQuery to update view for active query session.relayContainer.mutateGraph(mutationQuery: mutation) { … } extension ProductDetailsViewController: Relayable { func handleRelayQuery( query: ApiSchema.QueryRootQuery, response: ApiSchema.QueryRoot?, error: GraphQueryError?, cached: Bool) {
  80. Optimistic Updates • Optimistic update actions apply to all matching

    nodes in active queries • Rollback optimistically updated active queries from response cache before applying updates from mutation response let optimisticUpdate = RelayAction.Destroy(product) self.session.relayContainer.mutateGraph( mutationQuery: mutation, updates: [optimisticUpdate]) { (mutation, error) in APISchema.Order optimisticUpdate = new APISchema.Order(orderId()) .setClosed(true) .setClosedAt(SimpleRelativeDateUtil.now().getTimeInMillis());
  81. Caching & Data Consistency - Summary • Simplicity • Cache

    whole responses • Productive mobile developer • Type checked optimistic updates • Build for long term • Data consistency without coupling
  82. Follow our work https://engineering.shopify.com