From ccc22991c310cc1a8436b2e0e032cbf61f07ec8e Mon Sep 17 00:00:00 2001 From: Jeff Keen Date: Fri, 4 Sep 2026 12:02:45 -0500 Subject: [PATCH 1/5] docs: label the working copy with the latest release and keep 2.0 as a cut version The docs build stamps the newest v2 tag into the working copy's version label and also runs on release. bin/cut-docs-version freezes a copy for releases that change the docs pervasively. --- .github/workflows/docs.yml | 8 + bin/cut-docs-version | 24 + website/docusaurus.config.js | 14 +- .../concepts/backends-and-models.md | 122 +++ .../version-2.0/concepts/endpoints.md | 192 ++++ .../version-2.0/concepts/links.md | 242 +++++ .../version-2.0/concepts/overview.md | 80 ++ .../version-2.0/concepts/persisting.md | 376 +++++++ .../version-2.0/concepts/relationships.md | 628 ++++++++++++ .../version-2.0/concepts/resources.md | 769 +++++++++++++++ .../version-2.0/getting-started/first-api.md | 289 ++++++ .../getting-started/installation.md | 186 ++++ website/versioned_docs/version-2.0/intro.md | 311 ++++++ .../version-2.0/js/authentication.md | 63 ++ website/versioned_docs/version-2.0/js/ddau.md | 20 + .../version-2.0/js/extra-params.md | 41 + .../versioned_docs/version-2.0/js/index.md | 112 +++ .../version-2.0/js/installation.md | 120 +++ .../version-2.0/js/middleware.md | 72 ++ .../versioned_docs/version-2.0/js/models.md | 202 ++++ .../versioned_docs/version-2.0/js/reads.md | 494 ++++++++++ .../version-2.0/js/state-syncing.md | 100 ++ .../versioned_docs/version-2.0/js/writes.md | 373 +++++++ .../version-2.0/reference/vandal.md | 63 ++ .../version-2.0/reference/why.md | 13 + .../version-2.0/topics/authorization.md | 155 +++ .../version-2.0/topics/caching.md | 55 ++ .../topics/customizing-sideloads.md | 156 +++ .../version-2.0/topics/debugging.md | 242 +++++ .../version-2.0/topics/error-handling.md | 279 ++++++ .../version-2.0/topics/etags.md | 46 + .../topics/hopping-relationships.md | 149 +++ .../version-2.0/topics/json-attributes.md | 77 ++ .../version-2.0/topics/openstruct-models.md | 50 + .../version-2.0/topics/remote-resources.md | 291 ++++++ .../version-2.0/topics/testing.md | 931 ++++++++++++++++++ .../topics/without-activerecord.md | 324 ++++++ .../version-2.0/tutorial/index.md | 58 ++ .../version-2.0/tutorial/step_0.md | 93 ++ .../version-2.0/tutorial/step_1.md | 199 ++++ .../version-2.0/tutorial/step_2.md | 312 ++++++ .../version-2.0/tutorial/step_3.md | 142 +++ .../version-2.0/tutorial/step_4.md | 135 +++ .../version-2.0/tutorial/step_5.md | 69 ++ .../version-2.0/tutorial/step_6.md | 82 ++ .../version-2.0/tutorial/step_7.md | 205 ++++ .../version-2.0/tutorial/step_8.md | 128 +++ .../version-2.0/tutorial/step_9.md | 171 ++++ .../versioned_docs/version-2.0/upgrading.md | 416 ++++++++ .../version-2.0-sidebars.json | 84 ++ website/versions.json | 3 + 51 files changed, 9761 insertions(+), 5 deletions(-) create mode 100755 bin/cut-docs-version create mode 100644 website/versioned_docs/version-2.0/concepts/backends-and-models.md create mode 100644 website/versioned_docs/version-2.0/concepts/endpoints.md create mode 100644 website/versioned_docs/version-2.0/concepts/links.md create mode 100644 website/versioned_docs/version-2.0/concepts/overview.md create mode 100644 website/versioned_docs/version-2.0/concepts/persisting.md create mode 100644 website/versioned_docs/version-2.0/concepts/relationships.md create mode 100644 website/versioned_docs/version-2.0/concepts/resources.md create mode 100644 website/versioned_docs/version-2.0/getting-started/first-api.md create mode 100644 website/versioned_docs/version-2.0/getting-started/installation.md create mode 100644 website/versioned_docs/version-2.0/intro.md create mode 100644 website/versioned_docs/version-2.0/js/authentication.md create mode 100644 website/versioned_docs/version-2.0/js/ddau.md create mode 100644 website/versioned_docs/version-2.0/js/extra-params.md create mode 100644 website/versioned_docs/version-2.0/js/index.md create mode 100644 website/versioned_docs/version-2.0/js/installation.md create mode 100644 website/versioned_docs/version-2.0/js/middleware.md create mode 100644 website/versioned_docs/version-2.0/js/models.md create mode 100644 website/versioned_docs/version-2.0/js/reads.md create mode 100644 website/versioned_docs/version-2.0/js/state-syncing.md create mode 100644 website/versioned_docs/version-2.0/js/writes.md create mode 100644 website/versioned_docs/version-2.0/reference/vandal.md create mode 100644 website/versioned_docs/version-2.0/reference/why.md create mode 100644 website/versioned_docs/version-2.0/topics/authorization.md create mode 100644 website/versioned_docs/version-2.0/topics/caching.md create mode 100644 website/versioned_docs/version-2.0/topics/customizing-sideloads.md create mode 100644 website/versioned_docs/version-2.0/topics/debugging.md create mode 100644 website/versioned_docs/version-2.0/topics/error-handling.md create mode 100644 website/versioned_docs/version-2.0/topics/etags.md create mode 100644 website/versioned_docs/version-2.0/topics/hopping-relationships.md create mode 100644 website/versioned_docs/version-2.0/topics/json-attributes.md create mode 100644 website/versioned_docs/version-2.0/topics/openstruct-models.md create mode 100644 website/versioned_docs/version-2.0/topics/remote-resources.md create mode 100644 website/versioned_docs/version-2.0/topics/testing.md create mode 100644 website/versioned_docs/version-2.0/topics/without-activerecord.md create mode 100644 website/versioned_docs/version-2.0/tutorial/index.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_0.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_1.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_2.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_3.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_4.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_5.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_6.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_7.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_8.md create mode 100644 website/versioned_docs/version-2.0/tutorial/step_9.md create mode 100644 website/versioned_docs/version-2.0/upgrading.md create mode 100644 website/versioned_sidebars/version-2.0-sidebars.json create mode 100644 website/versions.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1149054e..e434a976 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,6 +12,9 @@ on: - 'docs/**' - 'website/**' - '.github/workflows/docs.yml' + release: + types: [published] + workflow_dispatch: workflow_dispatch: {} # One static group: deploys from different branches force-push the same @@ -39,6 +42,11 @@ jobs: working-directory: website run: npm ci + - name: Find the latest release + run: | + git fetch --tags --quiet + echo "GRAPHITI_RELEASE=$(git tag --list 'v2*' --sort=-v:refname | head -1 | sed 's/^v//')" >> "$GITHUB_ENV" + - name: Build working-directory: website run: npm run build diff --git a/bin/cut-docs-version b/bin/cut-docs-version new file mode 100755 index 00000000..72ba284f --- /dev/null +++ b/bin/cut-docs-version @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'TEXT' +Usage: bin/cut-docs-version e.g. bin/cut-docs-version 2.1 + +Freezes docs/ as website/versioned_docs/version-, served at //. +docs/ itself keeps serving at the root as "Latest". Run it before merging docs +whose syntax the current release lacks, naming the release being left behind. +Review the new files and commit them. +TEXT +} + +case "${1:-}" in + ""|-h|--help) usage; exit 0 ;; +esac + +cd "$(dirname "$0")/../website" +[ -d node_modules ] || npm ci +npm run docusaurus docs:version "$1" + +echo +echo "Cut $1. Review website/versioned_docs/version-$1 and commit it." diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index d72d4848..edf98202 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -53,11 +53,15 @@ const config = { routeBasePath: '/', sidebarPath: require.resolve('./sidebars.js'), editUrl: 'https://github.com/graphiti-api/graphiti/tree/main/docs/', - // Unversioned docs serve at the root, and would otherwise be - // labelled "Next" in the dropdown. Once `npm run docusaurus - // docs:version 2.0` cuts a version, that becomes the root and the - // working copy moves to /next. - versions: {current: {label: '2.0'}}, + // The working copy documents the latest release, and the docs build + // stamps that release's number into its label (see docs.yml). Cut + // versions (bin/cut-docs-version) freeze a copy for readers on + // an older line. + lastVersion: 'current', + versions: { + current: {label: process.env.GRAPHITI_RELEASE ? `Latest (${process.env.GRAPHITI_RELEASE})` : 'Latest', badge: false}, + '2.0': {badge: false}, + }, }, blog: false, theme: {customCss: require.resolve('./src/css/custom.css')}, diff --git a/website/versioned_docs/version-2.0/concepts/backends-and-models.md b/website/versioned_docs/version-2.0/concepts/backends-and-models.md new file mode 100644 index 00000000..7d3313eb --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/backends-and-models.md @@ -0,0 +1,122 @@ +--- +title: 'Backends and Models' +--- + +# Backends and Models + +A Resource queries a **Backend** and returns **Models** from what comes back. Graphiti serializes the Models. + +With ActiveRecord those are the same object. `Employee` is both the thing you query and the thing you render, and you can skip most of this page. It matters when they're separate: a search index, an HTTP service, a document store. Then the Backend is whatever you query, and the Model is whatever you hand back. + +## Scopes {#scopes} + +A **scope** is whatever your backend needs to run a query. Graphiti doesn't care what it is. For ActiveRecord it's an `ActiveRecord::Relation`. Here it's a plain hash: + +```ruby +class EmployeeResource < ApplicationResource + self.adapter = Graphiti::Adapters::Null + + attribute :name, :string + + def base_scope + { conditions: {}, sort: {} } + end + + filter :name do + eq do |scope, value| + scope[:conditions].merge!(value) + scope + end + end + + sort :name do |scope, direction| + scope[:sort] = { name: direction } + scope + end + + def resolve(scope) + results = Backend.query(scope) + results.map { |result| Employee.new(result) } + end +end +``` + +`base_scope` is the starting point, each `filter` and `sort` block mutates it based on request params, and `resolve` runs the query and returns Models. + +**Every block must return the scope.** Returning the result of `merge!` or an assignment instead of the scope itself is the most common way to break this. + +Writing that per Resource gets old. Once the pattern stabilizes, move it into an [Adapter](/topics/without-activerecord#adapters) and Resources go back to being declarative: + +```ruby +class EmployeeResource < ApplicationResource + self.adapter = BackendAdapter + attribute :name, :string +end +``` + +## What a Model has to do {#model-requirements} + +**Respond to `id`, uniquely.** Graphiti uses `model.id` to tell records apart when rendering. Duplicate ids produce wrong output, not an error. + +If the underlying record has no id, generate one: + +```ruby +def id + @id ||= SecureRandom.uuid +end +``` + +**Respond to its readable attributes.** `attribute :name, :string` calls `model.name`. If your Model doesn't have that method, pass a block instead: + +```ruby +attribute :name, :string do + @object.full_name +end +``` + +**Include `ActiveModel::Validations` if you want validation errors.** Graphiti checks models on write requests and renders a [JSON:API errors payload](http://jsonapi.org/format/#errors) from `model.errors`. Without it, an invalid model saves silently: + +```ruby +class Employee + include ActiveModel::Validations + + validates :name, presence: true +end +``` + +## Writing a Model {#model-implementations} + +Graphiti has no opinion here. A plain class works: + +```ruby +class Employee + attr_accessor :id, :first_name, :last_name, :age + + def initialize(attrs = {}) + attrs.each_pair { |key, value| send(:"#{key}=", value) } + end +end +``` + +[ActiveModel::Model](https://api.rubyonrails.org/classes/ActiveModel/Model.html) gives you the constructor and validations for free: + +```ruby +class Employee + include ActiveModel::Model + + attr_accessor :id, :first_name, :last_name, :age +end +``` + +[Dry::Struct](https://dry-rb.org/gems/dry-struct) adds type enforcement, and dry-types is already a Graphiti dependency: + +```ruby +class Employee < Dry::Struct + attribute :id, Types::Integer + attribute :first_name, Types::String + attribute :last_name, Types::String + attribute :age, Types::Integer +end +``` + +`OpenStruct` also works and is what Graphiti uses internally for remote resources, but it fails quietly in ways the others don't. See [OpenStruct Models](/topics/openstruct-models) before reaching for it. diff --git a/website/versioned_docs/version-2.0/concepts/endpoints.md b/website/versioned_docs/version-2.0/concepts/endpoints.md new file mode 100644 index 00000000..1f845c1d --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/endpoints.md @@ -0,0 +1,192 @@ +--- +title: 'Endpoints' +--- + +## Overview {#overview} + +**Endpoints** expose and customize +[Resources](/concepts/resources). + +Resources themselves can operate completely independently of a request or response: + +```ruby +employees = EmployeeResource.all({ + filter: { title: 'engineer' }, + sort: '-created_at', + page: { size: 10 }, + include: 'positions.department' +}) + +employees.map(&:first_name) # => ['Jane', 'John', ...] +employees.to_json # => { employees: [{ ... }] } +``` + +And Resources connect to other Resources. Our graph of data is defined +**outside** of the actual API. + +Endpoints expose this graph to the world. We might choose to have a `/employees` endpoint that can eager load comments (`?include=comments`), but never expose `/comments` directly. Or, we could do the opposite: expose lazy-loading `/comments`, but disallow eager loading from `/employees`. We can add caching rules, or add an `/exemplary_employees` endpoint with special query overrides. + +Finally, Endpoints are in charge of the [HTTP specification](https://tools.ietf.org/html/rfc2616): +request processing, response codes, caching, MIME types, and so on. If you're thinking +Rails, an Endpoint is the combination of a Route and Controller. + +### Endpoint Logic {#endpoint-logic} + +Often, you won't need to customize Endpoints - especially if you're +using our [Rails Resource +generator](/concepts/resources#generators). Endpoint logic mostly +concerns: + +* Caching +* Side-effect behavior specific to the endpoint (e.g.: sending a +welcome email from `/users#create` but not `/admin/users#create`) +* Authorization (e.g `before_action`) +* Custom query parameter handling +* Validation handling +* Error handling +* Limiting Resource behavior +* Customizing Resource behavior + +If your logic falls elsewhere, consider a Resource or Model. + +### Rails Integration {#rails-integration} + +When using Rails, an endpoint is the combination of a Route and +Controller: + +```ruby +# config/routes.rb +resources :posts, only: [:index] + +# app/controllers/posts_controller.rb +class PostsController < ApplicationController + def index + posts = PostResource.all(params) + + respond_to do |format| + format.jsonapi { render jsonapi: posts } + format.json { render json: posts } + end + end +end +``` + +You'll note that Graphiti hooks into Rails with a mixin (set when using +our application generator): + +```ruby +class ApplicationController < ActionController::API + include Graphiti::Rails::Controller + + # ... code ... +end +``` + +This gives us [#sideload_allowlist](#sideload-allowlist), sets the +[context](/concepts/resources#context), and makes `respond_to` available +in API-only controllers. + +## Customizing Resources {#customizing-resources} + +### Scope Overrides {#scope-overrides} + +One common use case for endpoints is customizing the Resource +[base scope](/concepts/resources#base-scope). This causes a new +"starting point" for query building. + +Consider the endpoints `/posts` (basic CRUD) and `/top_posts`. Though both are associated to PostResource, `/top_posts` ensures that only +Posts with a certain number of upvotes get returned: + +```ruby +def index + base_scope = Post.where("upvotes > ?", 100) + posts = PostResource.all(params, base_scope) + + respond_to do |format| + format.jsonapi { render jsonapi: posts } + format.json { render json: posts } + end +end +``` + +We're able to reuse all the other logic in PostResource - relationships, +filters, sorts, etc - while only returning "Top Posts". + +### Sideload Allowlist {#sideload-allowlist} + +Resources define relationships to other resources. But we may not want +all of those relationships exposed at a given endpoint. + +Let's say we've defined relationships: + +`Employee > Position > Department > Hardware > CostHistory` + +It's reasonable to get an Employee, their Positions, and Departments for +those positions in a single request. But is it really valid to *also* pull down +all the hardware, as well as all the historical data on the cost of that hardware, +in a single request? Allowing the entire graph to be pulled down in a single request can cause excessive load on our +servers (and this is probably a better fit for lazy-loading via +[Links](/concepts/links)). + +Let's instead say that if we're entering the graph at `/employees`, the +furthest we can go is Department: + +```ruby +class EmployeesController < ApplicationController + self.sideload_allowlist = { + index: { positions: 'department' } + } + + # ... code ... +end +``` + +## Caching {#caching} + +### Etags {#etags} + +[ETags](https://robots.thoughtbot.com/introduction-to-conditional-http-caching-with-rails) are an important concept that is often overlooked. Etags tell browsers +that the response to a GET request hasn't changed since the last request and +can be safely pulled from the browser cache. If you care about sparse fieldsets, +you should care about ETags - if you're limiting fields to reduce payload size, +how about a payload size of **zero**? + +ETags are set by default in Rails, by checking the response body. This won't prevent queries from executing, but it will save clients from downloading the response again if nothing has changed. + +Let's manually set an ETag: + +```ruby +def index + posts = PostResource.all(params) + + if stale?(posts.data) + render jsonapi: posts + end +end +``` + +From the [documentation on #stale?](https://api.rubyonrails.org/classes/ActionController/ConditionalGet.html#method-i-stale-3F): + +> *In this case last_modified will be set by calling `maximum(:updated_at)` on the collection (the timestamp of the most recently updated record) and the etag by passing the object itself.* + +Also consider the use case where data is ingested hourly. We can avoid a +query altogether by checking when the last ingestion ran: + +```ruby +def index + if stale?(EmployeeIngestion.last) + employees = EmployeeResource.all(params) + render jsonapi: employees + end +end +``` + +> **CAVEAT**: When setting ETags, consider sideloads. In the above examples +> we are checking to see the last update of an Employee, but we may be +> sideloading (and filtering) Positions as well. Use custom endpoints or +> [Sideload Allowlist](#sideload-allowlist) to mitigate this issue. + +## Testing {#testing} + +If you have custom Endpoint logic, we suggest testing using an [API +Test](/topics/testing#api-tests). diff --git a/website/versioned_docs/version-2.0/concepts/links.md b/website/versioned_docs/version-2.0/concepts/links.md new file mode 100644 index 00000000..90c66f3b --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/links.md @@ -0,0 +1,242 @@ +--- +title: 'Links' +--- + +# Links + +## Overview {#overview} + +A [Link](http://jsonapi.org/format/#document-links) is a URL Graphiti puts in a relationship, pointing at the data so a client can fetch it separately. Every relationship gets one automatically: + +```ruby +class PostResource < ApplicationResource + has_many :comments +end +``` + +`GET /posts/123` renders the `comments` relationship with a `links.related` of `/comments?filter[post_id]=123`. The client follows that URL when it wants the comments, rather than asking for them up front with `?include=comments`. + +### Why links {#why-links} + +The URL matters most when the relationship means something more specific than "all the comments". Say `top_comments` is defined as 100 upvotes or more. A [`params` block](#linking-relationships) puts that into the generated Link, and the client still follows a URL. + +The alternative is for clients to build that query themselves, which means every client (desktop, mobile, third-party) has to know what a "Top Comment" is and ship an update whenever the definition changes. Hiding it behind a dedicated `/top_comments` endpoint moves the problem rather than solving it: clients still have to know to hit a special endpoint, and nothing keeps its definition in sync with the eager-loaded one. + +With a Link, the definition lives in one place. Change it to 500 upvotes, factor in recency, subtract downvotes: clients keep following the same URL. + + +## Linking Relationships {#linking-relationships} + +When defining a relationship, we get a Link for free: + +```ruby +class PostResource < ApplicationResource + has_many :comments +end +``` + +> `/comments?filter[post_id]=123` + +And when customizing a relationship with `params`, our Link will be +updated: + +```ruby +has_many :comments do + params do |hash| + hash[:filter][:upvotes] = { gte: 100 } + end +end +``` + +> `/comments?filter[post_id]=123&filter[upvotes][gte]=100` + +Note: if you use the `scope` block directly, it may cause incorrect links. Avoid using `scope` directly and instead use `params` and `pre_load` if possible. + +To manually generate a Link: + +```ruby +has_many :comments do + link do |post| + helpers = Rails.application.routes.url_helpers + helpers.comments_url(params: { filter: { post_id: post.id } }) + # or + # http://example.com/api/v1/comments?filter[post_id]=123 + end +end +``` + +Every relationship link has one of three modes: `true` (always rendered), `false` (no link at all), or `:on_demand` (rendered when the request asks with `?links=true`). The resource's [`relationship_links`](#relationship-links) sets the default mode, and the `link:` option overrides it per relationship: + +```ruby +has_many :comments, link: false # no link, whatever the resource default +has_many :comments, link: :on_demand # only with ?links=true +``` + +## Resource Endpoints {#resource-endpoints} + +To generate links, we need to associate a Resource to a URL. By default, +this happens automatically: + +```ruby +class ApplicationResource < Graphiti::Resource + # ... code ... + self.endpoint_namespace = '/api/v1' +end + +class PostResource < ApplicationResource + # under the hood: + primary_endpoint 'posts', + [:index, :show, :create, :update, :destroy] +end +``` + +Which would generate links to `/api/v1/posts`. + +### Validation {#validation} + +Associating a Resource to an Endpoint serves two purposes. We've gone +over link generation. But we also want to make sure we're not linking to +something that doesn't actually exist. That's why we perform **Endpoint +Validation**. + +If we tried to access the above resource at a `/comments` endpoint: + +```ruby +class CommentsController < ApplicationController + def index + PostResource.all(params) + # ... + end +end +``` + +We'd get a `Graphiti::Errors::InvalidEndpoint` error. Endpoint +validation ensures that our auto-generated Links are actually valid. + +To change the endpoint associated to a Resource: + +```ruby +primary_endpoint 'special_posts', [:index, :show] +``` + +Or to alter only the **path**: + +```ruby +self.endpoint[:path] = 'special_posts' +``` + +Or to alter only the **actions** supported: + +```ruby +self.endpoint[:actions] = [:index, :show] +``` + +A resource may be accessible by multiple endpoints. Maybe `PostResource` is also used at `/top_posts`. We want to keep all auto-generated links pointing to `/posts` (the primary endpoint), but *allow* accessing `PostResource` from the `/top_posts` endpoint: + +```ruby +secondary_endpoint '/top_posts', [:index] +``` + +## Configuration {#configuration} + +### Relationship Links {#relationship-links} + +`relationship_links` is the default mode for every relationship link on the resource, taking the same three values as the per-relationship `link:` option. To turn links off unless a relationship opts in: + +```ruby +class ApplicationResource < Graphiti::Resource + self.relationship_links = false +end + +class PostResource < ApplicationResource + has_many :comments # no link + has_many :top_comments, link: true # rendered +end +``` + +A relationship with a custom `link do ... end` block is treated as `link: true` under a `false` default, on the theory that writing the block means wanting the link. + +(`self.autolink` was the 1.x name for the `false`/`true` half of this setting. It still works, warns, and will be removed in 3.0.) + +### Endpoint Validation {#endpoint-validation} + +Endpoints are validated in two directions, each with its own setting. + +`validate_requests` guards what comes in. A Resource refuses to serve a request whose path and action are not among its [endpoints](#resource-endpoints), which is what stops one Resource being reached through another's route: + +```ruby +class ApplicationResource < Graphiti::Resource + self.validate_requests = false +end +``` + +`validate_links` guards what goes out. Before rendering a relationship link, Graphiti checks that the target endpoint is actually routable for the action the link needs, which is `:show` for a `belongs_to` and `:index` otherwise. You never serialize a link that 404s. Custom `link do ... end` blocks and remote Resources are skipped: + +```ruby +class ApplicationResource < Graphiti::Resource + self.validate_links = false +end +``` + +Turn off `validate_links` when your links point at endpoints another service serves, and you still want the inbound guard. + +(`self.validate_endpoints` set both at once. It still works, warns, and will be removed in 3.0.) + +### Links-on-Demand {#links-on-demand} + +To only render relationship links when requested in the URL with `?links=true`: + +```ruby +class ApplicationResource < Graphiti::Resource + self.relationship_links = :on_demand +end +``` + +`relationship_links` accepts `true` (always render, the default), `false` (no links), or `:on_demand`. Set it on `ApplicationResource` to apply everywhere, on an individual resource to override, or per relationship with `link:`. A relationship with no link and no ids is left out of the payload. + +### Pagination Links {#pagination-links} + +The page params themselves, and cursors, are covered in [Pagination](/concepts/resources#pagination). + +Requesting large collections can make for slow responses. [Pagination](https://jsonapi.org/format/#fetching-pagination) breaks the response into smaller pieces, and pagination links tell the client how to walk them. They can appear in a response two ways. + +#### Showing by default {#pagination-links-showing-by-default} + +Every collection response returns pagination links: + +```ruby +class ApplicationResource < Graphiti::Resource + self.page_links = true +end +``` + +#### When requested {#pagination-links-when-requested} + +Links are rendered only when the request asks for them with `?page_links=true` (`?pagination_links=true` is still accepted). Worth doing when the collection is large: the `last` link needs a total count, so rendering links costs a `stat(:total, :count)` on every request that gets them. + +```ruby +class ApplicationResource < Graphiti::Resource + self.page_links = :on_demand +end +``` + +Like `relationship_links`, `page_links` accepts `true`, `false` (the default), or `:on_demand`, and can be set per resource. + +Pagination links won't show up for *#show* actions. + +### Custom Endpoint URLs {#custom-endpoint-urls} + +To change the URL associated with a Resource: + +```ruby +class PostResource < ApplicationResource + # Most commonly seen in ApplicationResource + self.endpoint_namespace = '/api/v1' + + primary_endpoint '/posts', [:index, :show] + # OR + self.endpoint[:path] = '/posts' + # OR + self.endpoint[:actions] = [:index, :show] +end +``` diff --git a/website/versioned_docs/version-2.0/concepts/overview.md b/website/versioned_docs/version-2.0/concepts/overview.md new file mode 100644 index 00000000..6b0c5490 --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/overview.md @@ -0,0 +1,80 @@ +--- +title: 'Lifecycle of a Request' +--- + +# Lifecycle of a Request + +A request goes down through a Resource to your data, and comes back up as a serialized response. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Request + Endpoint + Backend + JSON:API + + + Resource + + + + base_scope + filters, + sorts, pagination + resolve(scope) + to your Models + serialize + Adapter + + + response + + +
+ +Graphiti is the highlighted part: the Resource, and the JSON:API it renders. The Endpoint is your Rails controller, which handles routing, response codes and MIME types. The Backend is yours too. + +| Piece | What it does | +| --- | --- | +| [Endpoint](/concepts/endpoints) | Your controller. Graphiti registers its path and actions, which drives link generation and endpoint validation, and lets you vary a Resource's behavior per route. | +| [Resource](/concepts/resources) | Turns request params into a **scope**, resolves that scope into Models, and serializes them on the way back out. | +| Adapter | Reusable glue between a Resource and a Backend. Defaults to `Graphiti::Adapters::ActiveRecord`. | +| [Backend](/concepts/backends-and-models) | Whatever you query: a database, a search index, an HTTP service. | +| [Model](/concepts/backends-and-models) | What you return and serialize. With ActiveRecord, the same object as the Backend. | + +## The graph + +Resources connect to other Resources: + +* **Sideloading**: fetch an employee, her positions, and those positions' departments in one request +* **Sideposting**: *save* an employee and her positions in one request +* **[Links](/concepts/links)**: a URL to lazy-load positions in a separate request + +Query logic written for one Resource applies at every level of that graph, so you can ask for an employee and her last three positions ordered by `created_at`. That's [deep querying](/concepts/relationships#deep-queries). diff --git a/website/versioned_docs/version-2.0/concepts/persisting.md b/website/versioned_docs/version-2.0/concepts/persisting.md new file mode 100644 index 00000000..6350c4ef --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/persisting.md @@ -0,0 +1,376 @@ +--- +title: 'Persisting' +--- + +# Persisting {#persisting} + +This page covers how Graphiti writes data: the persistence lifecycle, sideposting a graph of resources in one request, validation errors, and reading data back after a write. + +Graphiti allows writing a graph of data in a single request. We'll do +the work of parsing the graph and ordering operations, so you can focus +on the part you care about: the logic for actually persisting an object. + +By default, persistence operations are handled by your adapter, and the flow breaks into three steps: build or find the model, assign attributes to it, then save it. + +Attributes are assigned up front, before the persistence hooks run. That means the model exists (populated but unwritten) before anything touches the database, and **the model you inspect is the model that saves**: + +```ruby +employee = EmployeeResource.build(payload) + +employee.data # the model, attributes already assigned, nothing written yet +employee.data.valid? # inspect it, or modify it +employee.save # persists that same instance +``` + +Reading `data` repeatedly returns the same instance, and the attribute callbacks run only once no matter how often you read it. For an update, the proxy reads the persisted record until you apply the payload: + +```ruby +proxy = EmployeeResource.find(payload) +proxy.data.first_name # => "asdf", straight from the database +proxy.assign_attributes(payload) +proxy.data.first_name # => "Jane", assigned but not yet persisted +proxy.save(action: :update) +``` + +`assign_attributes` validates the payload and runs your writable guards, but writes nothing. `#save` will not re-validate a payload it already validated, so inspecting the model costs no extra guard evaluations. `ResourceProxy#update` is the Rails-style shorthand that assigns and saves in one call. + +You can override `#create`, `#update` and `#destroy` on a Resource, but you are encouraged **not** to. Use the hooks below instead. If you do override them, `#create` and `#update` receive an attributes hash while `#destroy` receives an id, and all three **must return the Model instance**. Graphiti processes any `writable: false` or guarded attributes before these methods run, and checks the returned Model for validation errors afterward, rolling back the transaction if any Model in the graph is invalid. + +## Persistence Lifecycle Hooks {#persistence-lifecycle-hooks} + +Let's dive into a persistence request. If you look at the code snippets in +the prior section, the flow breaks down into 3 steps: + +* Build or find the model +* Assign attributes to the model +* Save + +You can hook into each step: + +```ruby +class PostResource < ApplicationResource + before_attributes do |attributes| + # Before attributes have been assigned to the model + end + + after_attributes do |model| + # After attributes have been assigned to the model + end + + around_attributes :do_around_attributes + + def do_around_attributes(attributes) + # before + model_instance = yield attributes + # after + end + + before_save do |model| + # After attributes assigned, but before persisting + end + + after_save do |model| + # After model has been saved + end + + around_save :do_around_save + + def do_around_save(model) + # before + yield model + # after + end + + # This is an *override* + # During #create, build a blank model instance + # By default, we'd call adapter.build(model_class) + def build(model_class) + model_class.new + end + + # This is an *override* + # During #create/#update, assign new attributes to the model instance + # By default, we'd call adapter.assign_attributes(model_instance, attributes) + def assign_attributes(model_instance, attributes) + attributes.each_pair do |key, value| + model_instance.send(:"#{key}=", value) + end + end + + # This is an *override* + # During #create/#update, actually save the model instance + # By default, we'd call adapter.save(model_instance) + def save(model_instance) + model_instance.save + model_instance + end + + + # This is an *override* + # During #destroy, actually save the model instance + # By default, we'd call adapter.destroy(model_instance) + def delete(model_instance) + model_instance.destroy + model_instance + end + + # Finally, you may want to hook around *all* the above steps: + # Only applies to #create/#update + around_persistence :do_around_persistence + + def do_around_persistence(attributes) + attributes[:foo] = 'bar' + model = yield # build/find, assign attrs, save + model.update_counter_cache + end +end +``` + +* All hooks have `only/except` options, e.g. `before_attributes only: [:update]` +* Most hooks can be called with an in-line block, or by passing a method +name (e.g. `before_attributes :do_something`). The exception is `around_*` hooks, which *must* be called with a method name. + +When persisting multiple objects at once, we'll open a database +transaction, process each model individually, ensure all models pass +validation, then close the transaction. This means that if you raise an +error at any point, or any model does not pass validations, the +transaction will be rolled back. + +You may want to perform an operation after all models have been +processed and validated, but before the transaction is closed. One +example is sending an email - you don't want to send if the models were +invalid, so `after_save` wouldn't work. And you still want to do it +*within* the transaction, so if your email server is down and an error +is raised the transaction gets rolled back. + +For this scenario, use `before_commit`: + +```ruby +before_commit do |model| + PostMailer.with(post: model).some_email.deliver +end +``` + +## Sideposting {#sideposting} + +The act of persisting multiple Resources in a single request is called +**Sideposting**. The payload mirrors the **sideloading** payload for +read operations, with minor additions. + +Let's create a Post and associate it to an existing Blog in a single +request: + +```ruby +# POST /api/v1/posts +{ + type: 'posts', + attributes: { title: 'My post' }, + relationships: { + blog: { + data: { + id: '1', + type: 'blogs', + method: 'update' + } + } + } +} +``` + +The critical addition here is the `method` key. When we persist RESTful +Resources, we send a corresponding HTTP verb. This follows the same +pattern, adding a verb for each Resource in the graph. `method` can be +one of: + + * `create` + * `update` + * `destroy` + * `disassociate` (e.g. `null` foreign key) + +When we sidepost, all objects will be persisted within the same database +transaction, which rolls back if an error is raised or any objects are invalid. + +### Create {#create} + +Let's say we want to create a Post and its Blog in a single request. +You'll note that we don't have the `id` key to generate a [Resource Identifier](http://jsonapi.org/format/#document-resource-identifier-objects) (combination of `id` and `type` +that uniquely identifies a Resource). + +To accomodate this, send an ephemeral `temp-id` (any UUID): + +```ruby +{ + # POST /api/v1/posts + { + type: 'posts', + attributes: { title: 'My post' }, + relationships: { + blog: { + data: { + :'temp-id' => 'abc123', + type: 'blogs', + method: 'create' + } + } + }, + included: [ + { + :'temp-id' => 'abc123' + type: 'blogs', + attributes: { name: 'New Blog' } + } + ] + } +} +``` + +This random UUID: + +* Connects relevant sections of the payload. +* Tells clients how to associate their in-memory objects with the ids returned from the server. + +### Expanded Example {#expanded-example} + +Here we're updating a Post, changing the name of its associated Blog, creating a Tag, deleting one Comment, and disassociating (`null` foreign key) a different Comment, all in a single request: + +```ruby +{ + data: { + type: 'posts', + id: 123, + attributes: { title: 'Updated!' }, + relationships: { + blog: { + data: { + type: 'blogs', + id: 123, + method: 'update' + } + }, + tags: { + data: [{ + type: 'tags', + temp-id: 's0m3uu1d', + method: 'create' + }] + }, + comments: { + data: [ + { + type: 'comments', + id: '123', + method: 'destroy' + }, + { + type: 'comments', + id: '456', + method: 'disassociate' + } + ] + } + } + }, + included: [ + { + type: 'tags', + :'temp-id' => 's0m3uu1d', + attributes: { name: 'Important' } + }, + { + type: 'blogs', + id: => '123', + attributes: { name: 'Updated!' } + } + ] +} +``` + +## Validation Errors {#validation-errors} + +When a persistence operation is attempted but the corresponding Resource +is invalid, the transaction will be rolled back and an [errors payload](http://jsonapi.org/format/#errors) will be returned +with a `422` response code: + +```ruby +{ + errors: [{ + code: 'unprocessable_entity', + status: '422', + title: "Validation Error", + detail: "Title can't be blank", + source: { pointer: '/data/attributes/title' }, + meta: { + attribute: :title, + message: "can't be blank", + code: :blank + } + }] +} +``` + +To get this functionality, your Model must adhere to the +[ActiveModel::Validations API](https://api.rubyonrails.org/classes/ActiveModel/Validations.html). + +You get this for free with ActiveRecord, or it can be mixed in to any +PORO: + +```ruby +class Post + include ActiveModel::Validations + validates :title, presence: true +end +``` + +Errors on associations will have a slightly expanded payload: + +```ruby +{ + errors: [{ + code: 'unprocessable_entity', + status: '422', + title: 'Validation Error', + detail: "Name can't be blank", + source: { pointer: '/data/attributes/name' }, + meta: { + relationship: { + attribute: :name, + message: "can't be blank", + code: :blank, + name: :pets, + id: '444', + type: 'pets' + } + } + }] +} +``` + +When [Sideposting](#sideposting), the errors payload will contain all +invalid Resources in the graph. + +## Read on Write {#read-on-write} + +By default, the response of a persistence operation will mirror your +request. But sometimes you need control over the response. The most +common scenario is sideloading an additional entity - imagine creating +an order, and wanting the order's shipping information to come back in +the response. + +You can do this by POSTing the payload as normal, but adding query +parameters to the URL: + +```ruby +# POST /api/v1/orders?include=shipping_information + +{ + type: 'orders', + attributes: { ... } +} +``` + +This will sideload the shipping information in the response. When using +[Spraypaint](/js/), do this with: + +```typescript +order.save({ returnScope: Order.includes('shipping_information') }) +``` diff --git a/website/versioned_docs/version-2.0/concepts/relationships.md b/website/versioned_docs/version-2.0/concepts/relationships.md new file mode 100644 index 00000000..7c32aedb --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/relationships.md @@ -0,0 +1,628 @@ +--- +title: 'Relationships' +--- + +# Relationships {#relationships} + +Resources rarely stand alone. This page covers how to connect them together for sideloading, sideposting, and links. + +Resources can connect to other Resources via **relationships**. +Each relationship determines behavior for: + +* Sideloading (load both Resources in a single request) +* Links (URL to lazy-load in separate request) +* Sideposting (save both in single request) + +When connecting resources, you can imagine the logic similar to +`ActiveRecord`'s `.includes`: + +```ruby +class PostResource < ApplicationResource + has_many :comments +end + +class CommentResource < ApplicationResource + attribute :post_id, :integer, only: [:filterable] + belongs_to :post +end + +PostResource.all(include: 'comments') +# Under the hood: +# CommentResource.all(filter: { post_id: array_of_post_ids }) + +CommentResource.all(include: 'post') +# Under the hood: +# PostResource.all(filter: { id: array_of_comment_ids }) +``` + +> Note the explicit `post_id` filter on `CommentResource` + +## Deep Queries {#deep-queries} + +A query that applies to a relationship is referred to as a **deep +query**. Use the dot-syntax to deep query: + +`/employees?include=positions&filter[positions.title]=Manager` + +`/employees?include=positions.department&filter[positions.department.name]=Engineering` + +The above references the **relationship name**. For simplicity, you can +also pass the JSONAPI type in brackets: + +`/employees?include=positions.department&filter[departments][name]=Engineering` + +Sorting and pagination currently only support the JSONAPI type: + +`/employees?include=positions.department&sort=departments.name` + +`/employees?include=positions.department&page[departments][size]=10` + +## Customizing Relationships {#customizing-relationships} + +The default options you can override are: + +```ruby +has_many :positions, + foreign_key: :employee_id, + primary_key: :id, + resource: EmployeeResource, + readable: true, + writable: true, + link: self.relationship_links, # the resource default mode, normally true + single: false, # only allow this sideload when one employee + resource_ids: false +``` + +`resource_ids` is the one whose default depends on the relationship type: + +| type | renders resource ids by default | +|---|---| +| `belongs_to` | yes, when its foreign key already holds the related id | +| `has_one` | no | +| `has_many` | no | +| `many_to_many` | no | +| `polymorphic_belongs_to` | no | + +`belongs_to` renders them so a client can see which record a relationship points at without following the link: + +```json +"employee": { + "data": { "type": "employees", "id": "1" }, + "links": { "related": "/employees?filter[id]=1" } +} +``` + +That costs nothing, because the id is already on the parent as its foreign key. + +No other relationship type has a free source for its ids. A collection accepts `resource_ids: true`, but that reads the association on every render of every parent record, whether or not the request wants the relationship. That is the N+1 from [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646) on every response. Leave collections off and let clients `?include=` them. + +Not every `belongs_to` can use its foreign key. A `scope` or `params` block or a `base_scope` can filter out the record the key points at, a polymorphic target's type varies per record while rendered ids carry one type for the whole relationship, a remote resource has no local key to read, and a custom `primary_key` points the relationship at some other column. Those load the association instead, so they stay off by default too. + +
+Which `belongs_to` declarations render resource ids, and which do not + +```ruby +# yes. employee_id is the employee's id, so the payload already has it +belongs_to :employee + +# no. nothing renders at all, ids included +belongs_to :employee, readable: false + +# no. employee_id holds a name, not the related id +belongs_to :employee, primary_key: :first_name + +# no. the base scope can exclude the employee the key points at, and +# graphiti cannot know whether it does without running it +belongs_to :employee, base_scope: -> { Employee.all } + +# no. a remote resource has no local foreign key to read +belongs_to :employee, remote: "http://foo.com/employees" + +# no. the record's own class decides its type, so the key gives an id +# with no type to pair it with +belongs_to :employee, resource: CreditCardResource + +# no. the scope can exclude the employee the key points at, and graphiti +# cannot know whether it does without running it +belongs_to :employee do + scope { |ids| {type: :employees, conditions: {id: ids}} } +end + +# no. same, a params filter can exclude the employee the key points at +belongs_to :employee do + params { |hash, positions| hash[:filter][:active] = true } +end + +# no. credit_card_type is local, but rendered ids carry one type for the +# whole relationship and this one's varies per record +polymorphic_belongs_to :credit_card do + group_by(:credit_card_type) do + on(:Visa).belongs_to :visa, resource: VisaResource + end +end +``` + +Watch for the `scope`, `params` and `base_scope` cases. Nothing about those declarations looks like it concerns resource ids, so adding a scope block to filter a relationship also stops its ids from rendering. + +If you keep a `schema.json`, the schema check catches that. A relationship that renders resource ids is marked `linkage: true`, and one that stops rendering them is reported as a breaking change. Gaining them is additive and passes. + +To render ids anyway, opt in on the relationship and accept the query: + +```ruby +belongs_to :employee, resource_ids: true do + scope { |ids| {type: :employees, conditions: {id: ids}} } +end +``` + +Know what that buys for the `scope`, `params` and `base_scope` cases. Rendering reads the association off the model, which does not apply the block, so if the block narrows what sideloading returns, the ids will disagree with it. Opting in there says you know the two agree. A `primary_key`, polymorphic or remote relationship does resolve to the right id this way. + +
+ +### belongs_to_resource_ids_by_default {#belongs-to-resource-ids} + +To change how far a `belongs_to` goes, across a whole API, set it on the resource everything inherits from: + +```ruby +class ApplicationResource < Graphiti::Resource + self.belongs_to_resource_ids_by_default = :foreign_key +end +``` + +| | | +|---|---| +| `:foreign_key` | Default. Render resource ids wherever the foreign key already holds the related id, and never run an extra query. | +| `:always` | Render them for every `belongs_to`, loading the association when the foreign key cannot answer. A query per record, per relationship, on every render. | +| `:never` | Render none. This is the 1.x payload. | + +Subclasses inherit it, and a relationship passing `resource_ids` explicitly still wins. + +All three describe requests that do not include the relationship. A relationship the request does include renders its ids whatever this is set to, `:never` included, because the records are already loaded and sitting in `included`. + +Before flipping the setting, [`bin/rake graphiti:audit`](/topics/debugging#graphiti-audit) reports how every relationship renders resource ids today and which would start loading the association. + +#### What a client sees {#relationship-payload-shapes} + +A client never has to work out which rule applied. The relationship object says what it knows: + +```json +"employee": { "data": { "type": "employees", "id": "1" } } // here is the id +"employee": { "links": { "related": "..." } } // fetch it yourself +``` + +A relationship with neither is left out of the payload. Relationships are linked by default, so the link shape is the one you normally see. + +`self.relationship_placeholders = true` brings back the 1.x shape, `{"meta": {"included": false}}`. It is not part of JSON:API and carries nothing a client can act on. + +The setting covers `belongs_to` and `polymorphic_belongs_to`, and no collection, deliberately. An API-wide `:always` on collections would be the N+1 from [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646) applied everywhere at once. + +`:always` renders ids by loading the association, so a relationship naming a method the model does not have raises on every render once you set it. + +### Conditional Relationships {#conditional-relationships} + +Like attributes, the `readable` and `writable` flags on a relationship accept more than a boolean: pass a symbol, string, or proc and the relationship becomes conditional, evaluated per-request. + +```ruby +class EmployeeResource < ApplicationResource + has_many :salary_histories, readable: :admin?, writable: :admin? + + def admin? + context.current_user.admin? + end +end +``` + +When a readable guard returns `false`, the relationship is omitted from the serialized output and any attempt to sideload it via `?include=` is silently scrubbed from the request. When a writable guard returns `false`, sideposting to that relationship is rejected with an `unwritable_relationship` validation error. + +Unlike attribute guards, relationship guards take no arguments. Include scrubbing happens before any records have been fetched, so there is no model to hand them. Base the decision on `context` alone. + +The guard can live on either side of the relationship. Graphiti first looks for the method on the resource declaring the relationship. If it isn't defined there but is defined on the related resource, the related resource's method is used. Defining the guard on the related resource lets a single guard cover every relationship pointing at it: + +```ruby +class SalaryHistoryResource < ApplicationResource + # Any resource declaring a relationship to SalaryHistoryResource with + # readable: :admin? will use this method, unless it defines its own. + def admin? + context.current_user.admin? + end +end +``` + +> **Upgrading to 1.12:** relationship guards are new enforcement, not a new +> option. Before 1.12, a symbol, string, or proc passed to a relationship's +> `readable`/`writable` was accepted and silently treated as `true`. The guard +> was never called. Those guards now run. If your app already passes one of +> these, a relationship that has been serialized all along may start +> disappearing from responses. +> +> To list every guarded relationship in your app before deploying, run +> `bin/rails runner 'puts Graphiti.guarded_relationships'`. +> +> Apps using `schema.json` also get this for free: guarded relationships are +> flagged in the schema, and the schema check reports them as +> `became guarded`. + +### Customizing Scope {#customizing-scope} + +Use `params` to change the query parameters that will be passed to the +associated Resource: + +```ruby +has_many :active_positions, resource: PositionResource do + params do |hash, employees| + hash[:filter][:active] = true + end +end + +# Would cause the underlying query: +# +# PositionResource.all({ +# filter: { +# employee_id: array_of_employee_ids +# active: true +# } +# }) +``` + +If there is no existing AR association for this we would also need to make it a getter/setter on the model. + +```ruby +# app/models/position.rb +attr_accessor :active_positions +``` + +### Customizing Assignment {#customizing-assignment} + +Once we've fetched primary data and its relationship (e.g. we have an +`employees` array and `positions` array), we need to associate these +objects: + +```ruby +employees.each do |e| + e.positions = positions.select { |p| p.employee_id == e.id } +end +``` + +Occasionally this logic will be non-standard or more complex. Use +`assign_each` to customize, returning all relevant children for the +given parent: + +```ruby +has_many :positions do + assign_each do |employee, positions| + positions.select { |p| p.belongs_to?(employee) } + end +end +``` + +Or if all else fails, use `#assign` to control all the logic: + +```ruby +has_many :positions do + assign do |employees, positions| + employees.each do |employee| + positions.select { |p| p.belongs_to?(employee) } + end + end +end +``` + +**Note**: ActiveRecord will sometimes cause unexpected queries when +assigning. If you're overriding `#assign`, make sure to keep an eye on this. If using `#assign_each`, you're fine because the adapter will take +care of this for you. + +## has_many {#has-many} + +```ruby +has_many :positions +``` + +Defaults to these common options: + +```ruby +has_many :positions, + foreign_key: :employee_id, + primary_key: :id, + resource_ids: false, + resource: PositionResource +``` + +Which would cause the following query when sideloading: + +```ruby +PositionResource.all({ filter: { employee_id => employee_ids } }) +``` + +This means **we need to make sure that filter is supported**: + +```ruby +class PositionResource < ApplicationResource + attribute :employee_id, :integer, only: [:filterable] + # ... code ... +end +``` + +Once we've resolved `employees` and `positions` the resulting objects +would be associated with logic similar to: + +```ruby +employees.each do |e| + e.positions = positions.select { |p| p.employee_id == e.id } +end +``` + +And generate a Link: + +`/positions?filter[employee_id]=1,2,3` + +## belongs_to {#belongs-to} + +```ruby +belongs_to :employee +``` + +Defaults to these common options: + +```ruby +belongs_to :employee, + foreign_key: :employee_id, + primary_key: :id, + resource_ids: true, + resource: EmployeeResource +``` + +Which would cause the following query when sideloading: + +```ruby +EmployeeResource.all({ filter: { id => position_ids } }) +``` + +And assign the resulting objects with logic similar to: + +```ruby +positions.each do |p| + p.employee = employees.find { |e| p.employee_id == e.id } +end +``` + +And generate a Link: + +`/employees?filter[id]=1,2,3` + +## has_one {#has-one} + +`has_one` works exactly like `has_many`, but only one record will be +returned. When sideloading this will be a single element, much like +`belongs_to`. + +There is one small caveat: Links always point to an `index` action, so we can apply filters. That means following *`has_one` Link will lead to +an array*, and you should select the first record. + +### Faux has_one {#faux-has-one} + +A "Faux Has One" occurs when there is more than one record of +associated data, but we only want to return the *first* record in that +array. Consider this `ActiveRecord` relationship: + +```ruby +# app/models/employee.rb +has_many :positions +has_one :current_position, -> { where(created_at: :desc) }, class_name: 'Position' + +Employee.includes('current_position').to_a + +# SELECT * FROM employees +# SELECT * FROM positions WHERE employee_id IN (?) ORDER BY created_at DESC +``` + +When we eager load, *more than one Position is returned from the +database query*. Assigning only the first record and dropping the rest +occurs in ruby, not the database query. + +The same thing happens in Graphiti: + +```ruby +# app/resources/employee_resource.rb +has_many :positions +has_one :current_position, resource: PositionResource do + params do |hash| + hash[:sort] = '-created_at' + end +end + +EmployeeResource.all(include: 'current_position') +# PositionResource.all({ +# filter: { employee_id: employee_ids }, +# sort: '-created_at' +# }) +``` + +Though everything works as expected, a large number of Position records +can incur a performance penalty (as we'd be instantiating a large number +of ActiveRecord objects). + +For this reason, you are encouraged to model Faux Has One's in such a +way that the underlying database query only returns the relevant single +record. Imagine if we had a `historical_index` column on `positions`, where a value of `1` meant "most recent": + +```ruby +# app/models/employee.rb +has_many :positions +has_one :current_position, -> { where(historical_index: 1) }, class_name: 'Position' + +Employee.includes('current_position').to_a + +# SELECT * FROM employees +# SELECT * FROM positions WHERE employee_id IN (?) AND historical_index = 1 +``` + +We've ensured the *query itself* only returns a single record. +Optimizing a Graphiti API is the same as optimizing queries. + +## many_to_many {#many-to-many} + +> This relationship is specific to relational databases that use a "join +> table" between two tables. + +Though you can make this work for other ORMs/clients, it's easiest to +explain by focusing on `ActiveRecord`. + +First, **you must use [has_many :through](https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association) and not has_and_belongs_to_many**: + +```ruby +class Employee < ApplicationRecord + has_many :team_memberships + has_many :teams, through: :team_memberships +end + +class TeamMembership < ApplicationRecord + belongs_to :employee + belongs_to :team +end + +class Team < ApplicationRecord + has_many :team_memberships + has_many :employees, through: :team_memberships +end +``` + +You can always expose `team_memberships` to your API - particularly +useful if that table holds metadata about the relationship. + +Other times, however, clients of the API should not have knowledge of +this implementation detail. In these cases, use `many_to_many`: + +```ruby +class EmployeeResource < ApplicationResource + many_to_many :teams +end +# Generates the Link +# /teams?filter[employee_id]=1,2,3 + +class TeamResource < ApplicationResource + many_to_many :employees +end +# Generates the Link +# /employees?filter[team_id]=1,2,3 +``` + +The `many_to_many` call will automatically add a Filter to the associated resource. The logic for that filter, in the case of `ActiveRecord`: + +```ruby +# app/resources/team_resource.rb + +filter :employee_id, :integer do + eq do |scope, value| + scope + .includes(:team_memberships) + .where(team_memberships: { employee_id: value }) + end +end +``` + +To customize the foreign key, you will need to specify a hash rather +than a symbol. The hash key is the join association name, so the above is +equivalent to + +```ruby +# app/resources/employee_resource.rb + +many_to_many :teams, foreign_key: { team_memberships: :employee_id } +``` + +If using ActiveRecord, and the API relationship name does not match your +Model relationship name, use `:as` to specify the model relationship +that should be used to derive the query: + +```ruby +# The API relationship is "teams", ActiveRecord has "groups" +many_to_many :teams, as: :groups +``` + +## polymorphic_belongs_to {#polymorphic-belongs-to} + +With polymorphic associations, a Resource can belong to more than one other Resource, on a single association. Though these relationships are not specific to `ActiveRecord`, we'll use `ActiveRecord` conventions to describe the use case. + +Given the following [polymorphic ActiveRecords](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations): + +```ruby +class Note < ApplicationRecord + belongs_to :notable, polymorphic: true +end + +class Employee < ApplicationRecord + has_many :notes, as: :notable +end + +class Department < ApplicationRecord + has_many :notes, as: :notable +end + +class Team < ApplicationRecord + has_many :notes, as: :notable +end +``` + +By `ActiveRecord` convention, the `notes` table would have columns `notable_id` and `notable_type`. + +Graphiti has the same concept. In this case we would group all the notes +by a given `notable_type`, and follow a different `belongs_to` +association for each group: + +```ruby +# app/resources/note_resource.rb +polymorphic_belongs_to :notable do + group_by(:notable_type) do + on(:Employee) + on(:Department) + on(:Team) + end +end +``` + +The `on` DSL is shorthand for a `belongs_to` relationship that accepts +all the usual options and customizations: + +```ruby +on(:Employee).belongs_to :employee, + resource: EmployeeResource + # ... etc ... +``` + +In other words: group all Notes by `notable_type`, and for all that have the value of `"Employee"` use the `belongs_to :employee` relationship +for further querying. + +## polymorphic_has_many {#polymorphic-has-many} + +Continuing from the prior section, the corresponding association of a +`polymorphic_belongs_to` is a `polymorphic_has_many`: + +```ruby +class EmployeeResource < ApplicationResource + polymorphic_has_many :notes, as: :notable +end +``` + +Predictably, this causes the query: + +```ruby +NoteResource.all({ + filter: { + notable_type: 'Employee', + notable_id: employee_ids + } +}) +``` + +And the Link + +`/notes?filter[notable_id]=1,2,3&filter[notable_type]=Employee` + +Which means the following filters are required: + +```ruby +class NoteResource < ApplicationResource + attribute :notable_id, :integer, only: [:filterable] + attribute :notable_type, :string, only: [:filterable] + # ... code ... +end +``` diff --git a/website/versioned_docs/version-2.0/concepts/resources.md b/website/versioned_docs/version-2.0/concepts/resources.md new file mode 100644 index 00000000..dd81043b --- /dev/null +++ b/website/versioned_docs/version-2.0/concepts/resources.md @@ -0,0 +1,769 @@ +--- +title: 'Resources' +--- + +# Resources + +A Resource is an abstraction around an API endpoint, the way a Model is an abstraction around a database table. It holds the logic for **querying**, **persisting**, and **serializing** one kind of thing. + +```ruby +class EmployeeResource < ApplicationResource + attribute :first_name, :string + attribute :age, :integer + + has_many :positions +end +``` + +This page is the full reference. For the whole API on one screen, see the [cheatsheet on the home page](/). For how a request flows through a Resource, see [Lifecycle of a Request](/concepts/overview). + +Resources connect to each other. That's covered separately in [Relationships](/concepts/relationships), and writes in [Persisting](/concepts/persisting). + +## Attributes {#attributes} + +```ruby +attribute :first_name, :string +``` + +A **name** (`first_name`) maps to a JSON key. A **Type** (`string`) maps to a JSON value and its coercion rules. + +### Limiting Behavior {#limiting-behavior} + +```ruby +attribute :name, :string, + readable: true, # renders in responses + writable: true, # accepted on create/update + sortable: true, # ?sort=name works + filterable: true, # ?filter[name]=... works + schema: true # exported to schema.json, not affected by only/except +``` + +Turn any flag off directly, or with `only`/`except` shorthand: + +```ruby +attribute :name, :string, sortable: false +attribute :name, :string, only: [:sortable] +attribute :name, :string, except: [:writable] +``` + +**Guards.** `readable` and `writable` also accept a symbol, string, or proc. The behavior applies only when the guard returns `true`, and the guard's arity decides what it receives: + +```ruby +attribute :name, :string, writable: :admin? +attribute :salary, :integer, readable: :visible?, writable: :salary_writable? + +def admin? # no arguments + context.current_user.admin? +end + +def visible?(model) # the model + model.internal == false +end + +def salary_writable?(model, attribute_name) # the model and the attribute name + PolicyChecker.new(model).attribute_writable?(attribute_name) +end +``` + +The model is only looked up when a guard declares a parameter for it, so zero-argument guards cost nothing. On an update it's the persisted record. On a create, it's a new unsaved instance. + +| Guard returns `false` on | Result | +| --- | --- | +| `readable` | The attribute is omitted from the response. | +| `writable` | The request is rejected with an `unwritable_attribute` validation error, before anything is persisted. | + +### Default Behavior {#default-behavior} + +```ruby +# On ApplicationResource, affects every subclass +self.attributes_readable_by_default = false # default true +self.attributes_writable_by_default = false # default true +self.attributes_filterable_by_default = false # default true +self.attributes_sortable_by_default = false # default true +self.attributes_schema_by_default = false # default true +``` + +Each `*_by_default` setting can also be a guard symbol, delegating the check to a method. Useful for wiring every attribute through one authorization system: + +```ruby +self.attributes_readable_by_default = :attribute_readable? + +def attribute_readable?(model_instance, attribute_name) + PolicyChecker.new(model_instance).attribute_readable?(attribute_name) +end +``` + +### Customizing Display {#customizing-display} + +```ruby +attribute :name, :string do + @object.name.upcase # @object is the model instance +end +``` + +### Types {#types} + +| Type | Notes | +| --- | --- | +| `string` | | +| `integer` | | +| `integer_id` | Renders as a string, queries/persists as an integer. Default type for `id`. | +| `uuid` | Like `string`, but only `eq`/`not_eq`, case-sensitive by default. | +| `string_enum` | Like `string`, but only `eq`/`not_eq`/`eql`/`not_eql`, and requires `allow:`. | +| `integer_enum` | Like `integer`, but only `eq`/`not_eq`, and requires `allow:`. | +| `big_decimal` | | +| `float` | | +| `boolean` | | +| `date` | | +| `datetime` | | +| `hash` | | +| `array` | | + +Every type except `boolean`, `hash`, and `array` also has an `array_of_*` variant: `array_of_integers`, `array_of_dates`, `array_of_uuids`, and so on. + +Each Type governs reading, writing, and filtering by wrapping a [Dry Type](https://dry-rb.org/gems/dry-types). Inspect one to see its parts: + +```ruby +Graphiti::Types[:integer_id] + +# { +# params: Dry::Types['coercible.integer'], +# read: Dry::Types['coercible.string'], +# write: Dry::Types['coercible.integer'], +# ... +# } +``` + +Edit an implementation in place. Here, `:string` is made to render as an integer: + +```ruby +Graphiti::Types[:string][:read] = Dry::Types['coercible.integer'] +``` + +#### Disabling Read Typecasting {#typecast-reads} + +To serialize values exactly as the model returns them, skipping the type's `read` coercion: + +```ruby +class ApplicationResource < Graphiti::Resource + self.typecast_reads = false +end +``` + +Defaults to `true`. Like the other class attributes it inherits, so it can be turned off app-wide or per resource. Writes and filters still coerce. + +#### Enum Types {#enum-types} + +`string_enum` and `integer_enum` behave like `string` and `integer`, except declaring one (as an attribute or a filter) requires the `allow:` option, the list of acceptable values: + +```ruby +attribute :status, :string_enum, allow: ['draft', 'published'] +``` + +If your attribute is backed by an ActiveRecord enum, reference the values directly: + +```ruby +# app/models/post.rb +class Post < ApplicationRecord + enum status: { + draft: 0, + published: 1 + } +end + +# app/resources/post_resource.rb +class PostResource < ApplicationResource + attribute :status, :string_enum, allow: Post.statuses.keys +end +``` + +See [Filter Options](#filter-options) for more on `allow`. + +Graphiti does not validate enum values on write. Your model layer is still expected to validate incoming data. + +#### Custom Types {#custom-types} + +[Dry Types supports custom types](https://dry-rb.org/gems/dry-types/main/custom-types/): + +```ruby +# Define the Type +definition = Dry::Types::Nominal.new(String) +type = definition.constructor do |input| + input.upcase +end + +# Register it with Graphiti +Graphiti::Types[:caps_lock] = { + params: type, + read: type, + write: type, + kind: 'scalar', + canonical_name: :caps_lock, + description: 'All capital letters' +} + +# Use in a Resource +attribute :name, :caps_lock +``` + +## Querying {#querying} + +```ruby +class PostResource < ApplicationResource + # Applies to every query: start with a base scope, alter it based on + # the incoming request. Called just like ActiveRecord's Post.all. + def base_scope + Post.all + end + + # Must execute the query and return an array of Model instances. + def resolve(scope) + scope.to_a + end +end +``` + +### Query Interface {#query-interface} + +Resources can query and persist without an API request or response. Pass a [JSONAPI-compliant](http://jsonapi.org) query hash directly: + +```ruby +EmployeeResource.all({ + filter: { first_name: 'Jane' }, + sort: '-created_at', + page: { size: 10, number: 2 } +}) +``` + +The return value from `.all` is a **proxy** object, similar to `ActiveRecord::Relation`. No query fires until you call `.map`, `.data`, or a render method: + +```ruby +employees = EmployeeResource.all +employees.class # Graphiti::ResourceProxy +employees.map(&:first_name) # => ["Jane", "Joe", ...] +employees.data # => [#, #, ...] + +employees.to_jsonapi +employees.to_json +employees.to_xml +``` + +`.find` returns a single record's proxy by id, raising `Graphiti::Errors::RecordNotFound` if none are returned: + +```ruby +employee = EmployeeResource.find(id: 123) +employee.data.first_name # => "Jane" +``` + +### Composing with Scopes {#composing-with-scopes} + +#### #base_scope {#base-scope} + +```ruby +def base_scope + Position.where(active: true) +end +``` + +Override `#base_scope` for logic that should apply to every query. Here, it only ever returns active Positions. + +Pass a second argument to `.all` to override the base scope for a single call: + +```ruby +class InactivePostsController < PostsController + def index + posts = PostResource.all(params, Post.where(active: false)) + render jsonapi: posts + end +end +``` + +### Sort {#sort} + +```ruby +sort :name, :string do |scope, direction| + scope.order(first_name: direction, last_name: direction) +end +``` + +Omit the type if a matching `attribute` is already defined. This overrides its default sort behavior: + +```ruby +attribute :name, :string + +sort :name do |scope, direction| + # ... code ... +end +``` + +`sort` on its own defines a sort-only attribute. Define the `attribute` first if you also need filtering or other behavior. + +#### Sort Options {#sort-options} + +| Option | Description | +| --- | --- | +| `only` | Restrict to a single direction, e.g. `sort :name, only: [:desc]` | + +### Filter {#filter} + +```ruby +filter :name, :string do + eq do |scope, value| + scope.where(first_name: value) + end + + # prefix do ... end + # suffix do ... end + # etc +end +``` + +Omit the type if a matching `attribute` is already defined. This overrides its default filter behavior. `filter` on its own defines a filter-only attribute. Define the `attribute` first if you also need sorting or other behavior. + +Every operator below also has a `not_` counterpart (`not_eq`, `not_prefix`, ...). Values arrive as an array unless the filter is `single: true`. Comma-delimit multiple values in a query string (`/employees?filter[name]=Jane,John`). + +| Type | Default operators | +| --- | --- | +| `string` | `eq`, `eql`, `prefix`, `suffix`, `match` | +| `uuid` | `eq` | +| `string_enum`, `integer_enum` | `eq`, `eql` | +| `integer_id`, `integer`, `big_decimal`, `float`, `date`, `datetime` | `eq`, `gt`, `gte`, `lt`, `lte` | +| `boolean` | `eq` (always `single: true`) | +| `hash` | `eq` | +| `array` | `eq` | + +Define custom operators on the fly: + +```ruby +filter :name do + fuzzy_match do |scope, value| + # ... code ... + end +end +``` + +This supports `filter[name][fuzzy_match]=foo`. + +#### Filter Options {#filter-options} + +| Option | Description | +| --- | --- | +| `only`, `except` | Limit the operators generated from the type's defaults, e.g. `filter :name, :string, only: [:eq, :suffix]` | +| `allow` | Only permit these values, e.g. `filter :size, :string, allow: ['Big', 'Medium', 'Small']` | +| `deny` | Reject these values, e.g. `filter :size, :string, deny: ['X-Large']` | +| `single` | Accept one value instead of an array. `boolean` filters are `single: true` by default. | +| `required` | Reject the request if the filter is absent, e.g. `filter :customer_id, :string, required: true` (equivalently, `attribute :customer_id, :integer, filterable: :required`) | +| `dependent` | Require other filters alongside this one, e.g. `filter :customer_id, :integer, dependent: [:customer_type]` paired with `filter :customer_type, :string, dependent: [:customer_id]`, so querying by id requires type, and vice versa | +| `blanks` | What to do with a blank value. `:literal` (default) takes `"null"` and `""` as strings, `:null` coerces `"null"` to Ruby `nil` so the filter can query for NULL, and `:rejected` raises `InvalidFilterValue` for `nil`, `""`, `[]` or `"null"`. Set `self.filter_blanks_treated_as` on a Resource to change it for all of that Resource's filters. | + +```ruby +# Default behavior +filter :name, :string do + eq do |scope, value| + value # => ["Jane"] + end +end + +# With single: true +filter :name, :string, single: true do + eq do |scope, value| + value # => "Jane" + end +end +``` + +#### Boolean Filter {#boolean-filter} + +Filters with type `boolean` are `single: true` by default. A boolean filter accepting multiple values doesn't make sense. + +#### Hash Filter {#hash-filter} + +Filters with type `hash` parse JSON automatically when passed in a URL query string: + +```ruby +# GET /employees?filter[metadata]={ "foo": 100 } + +filter :metadata, :hash do + eq do |scope, value| + value # => [{ "foo" => 100 }] + end +end +``` + +#### Escaping Values {#escaping-values} + +By default, Graphiti parses a comma-delimited string as an array. Wrap a value in `{{curlies}}` to keep it intact, for a "keyword search" field that could itself contain a comma: + +```ruby +# GET /employees?filter[keywords]={{some,value}} + +filter :keywords, :string do + eq do |scope, value| + value # => "some,value" + end +end +``` + +Or define an array explicitly instead of relying on comma-splitting: + +```ruby +# GET /employees?filter[keywords]=[some,value] + +filter :keywords, :string do + eq do |scope, value| + value # => ["some", "value"] + end +end +``` + +A `single: true` filter skips array parsing entirely and escapes the value for you, filtering on the string as given. + +### Pagination {#pagination} + +Collections are paginated by default, 20 records to a page: + +```ruby +PostResource.all({ page: { number: 2, size: 10 } }) +# GET /posts?page[number]=2&page[size]=10 +``` + +Two settings you might want to adjust, both usually set on `ApplicationResource`: + +```ruby +class ApplicationResource < Graphiti::Resource + self.page_default_size = 10 # unset falls back to 20 + self.page_max_size = 100 # default 1_000 +end +``` + +A request asking for more than `page_max_size` raises `Graphiti::Errors::UnsupportedPageSize` rather than quietly serving it. + +A sideload can be paginated only when there is a single parent record: + +``` +/employees/1?include=positions&page[positions][size]=2 # fine +/employees?include=positions&page[positions][size]=2 # raises UnsupportedPagination +``` + +The second asks for two positions per employee, and one query with one `LIMIT` can only cap the whole result, not each employee's share of it. ActiveRecord has the same limitation. Where you need the shape anyway, a named relationship such as `has_one :top_position` gets you one row per parent. + +To paginate a scope your adapter cannot handle, override it on the Resource: + +```ruby +paginate do |scope, current_page, per_page, offset| + scope.by_page(current_page, per_page) +end +``` + +#### Cursors {#pagination-cursors} + +`page_cursors` renders a cursor in every record's `meta`, and the client pages by handing one back: + +```ruby +class PostResource < ApplicationResource + self.page_cursors = true # default false +end +``` + +```json +{ + "id": "42", + "type": "posts", + "attributes": { "title": "Hello" }, + "meta": { "cursor": "eyJvZmZzZXQiOjQxfQ==" } +} +``` + +```ruby +PostResource.all({ page: { after: "eyJvZmZzZXQiOjQxfQ==", size: 10 } }) +# GET /posts?page[after]=eyJvZmZzZXQiOjQxfQ%3D%3D&page[size]=10 +``` + +`page[before]` walks backwards from a cursor. It cannot be combined with `page[number]`, which raises `Graphiti::Errors::UnsupportedBeforeCursor`. + +The links a client follows to page are separate, and covered in [Pagination Links](/concepts/links#pagination-links). + +### Statistics {#statistics} + +```ruby +stat total: [:count] +stat rating: [:average] +stat likes: [:sum] +stat score: [:maximum] + +stat rating: [:average] do + standard_deviation do |scope, attr| + # your standard deviation code here + end +end +``` + +Every Resource has a `total: :count` statistic by default. Statistics respect filtering but not pagination, so you can show a "Total Posts" count above a paginated grid without a second request: + +```ruby +PostResource.all({ + stats: { total: 'count' } +}) +# GET /posts?stats[total]=count +``` + +```ruby +{ + meta: { + stats: { + total: { + count: 100 + } + } + } +} +``` + +### Extra Fields {#extra-fields} + +```ruby +extra_attribute :net_worth +``` + +Works like `attribute`, except the field is read-only and only returned when explicitly requested: `?extra_fields[employees]=net_worth`. + +Adjust the scope (e.g. to eager-load) only when the extra field is requested: + +```ruby +resource.on_extra_attribute :net_worth do |scope| + scope.includes(:assets) +end +``` + +### #resolve {#resolve} + +`#resolve` must execute the query and return an array of `Model` instances. Override it to add behavior around the default: + +```ruby +def resolve(scope) + Rails.logger.info "begin resolving scope..." + result = super + Rails.logger.info "resolved!" + result +end +``` + +## Configuration {#configuration} + +```ruby +class PostResource < ApplicationResource + self.model = Post + self.type = 'posts' + + # Only used if you care about Links + primary_endpoint '/posts', [:index, :show, :create, :update, :destroy] + + self.default_sort = [{ title: :asc }] # default nil + self.page_default_size = 10 # default 20 +end +``` + +Typically inherited from `ApplicationResource`, where cross-cutting settings live: + +```ruby +class ApplicationResource < Graphiti::Resource + # Required when there's no corresponding model + self.abstract_class = true + + # Subclasses override as needed + self.adapter = Graphiti::Adapters::ActiveRecord + + # Default attribute flags. See #limiting-behavior + self.attributes_readable_by_default = true + self.attributes_writable_by_default = true + self.attributes_sortable_by_default = true + self.attributes_filterable_by_default = true + + # Used for link generation + self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') + # Suggest referencing this in config/routes.rb: + # scope path: '/api/v1' do + # resources :posts + # end + self.endpoint_namespace = '/api/v1' + + # Refuse requests reaching this Resource from a URL it isn't allowlisted for + self.validate_requests = true + + # Refuse to render a link pointing at an endpoint that isn't routable + self.validate_links = true + + # Render relationship links: true, false, or :on_demand + self.relationship_links = true +end +``` + +### Polymorphic Resources {#polymorphic-resources} + +Polymorphic Resources are similar to [ActiveRecord STI](https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html): a single query returns multiple Resource types. Querying `/tasks` can return `bugs`, `features`, and `epics`. + +```ruby +class Employee < ApplicationRecord + has_many :tasks +end + +# tasks table has a 'type' column +class Task < ApplicationRecord + belongs_to :employee +end + +class Bug < Task +end + +# ONLY Feature has #points +class Feature < Task + def points + 5 + end +end + +# ONLY Epic has the milestones relationship +class Epic < Task + has_many :milestones +end + +class Milestone < ApplicationRecord + belongs_to :epic +end +``` + +```ruby +class TaskResource < ApplicationResource + # Reference child classes + self.polymorphic = [ + 'BugResource', + 'FeatureResource', + 'EpicResource' + ] + + attribute :title, :string +end + +class BugResource < TaskResource +end + +class FeatureResource < TaskResource + attribute :points, :integer +end + +class EpicResource < TaskResource + has_many :milestones +end + +class MilestoneResource < TaskResource + belongs_to :epic +end +``` + +`/tasks` returns [JSONAPI types](http://jsonapi.org/format/#document-resource-identifier-objects) of `bugs`, `features`, and `epics`. Only `features` render `points`. Only `epics` render the `milestones` relationship. `/tasks?include=milestones` correctly only queries and renders Milestones for Epics. + +Resources connect to each other through relationships. See [Relationships](/concepts/relationships). + +## Generators {#generators} + +```bash +$ rails generate graphiti:resource NAME [attribute:type] [options] +``` + +```bash +$ rails generate graphiti:resource Employee first_name:string age:integer +``` + +Adds a route, controller, resource, and tests. + +Limit the actions the resource supports with `-a`: + +```bash +$ rails generate graphiti:resource Employee -a index show +``` + +Writing data (creating, updating, and destroying resources, including a graph of them in a single request) is covered in [Persisting](/concepts/persisting). + +## Context {#context} + +```ruby +# app/resources/post_resource.rb +attribute :active, :boolean, writable: :admin? + +def admin? + context.current_user.admin? +end +``` + +Every Resource has access to `#context`. Under Rails, `context` is the controller instance processing the request. + +Put common helpers like `current_user` on `ApplicationResource`, so every Resource can call them: + +```ruby +# app/resources/application_resource.rb +class ApplicationResource < Graphiti::Resource + # ... code ... + def current_user + context.current_user + end +end + +# app/resources/post_resource.rb +class PostResource < ApplicationResource + # ... code ... + def admin? + current_user.admin? + end +end +``` + +Set context manually with `with_context`: + +```ruby +ctx = OpenStruct.new(current_user: User.first) +Graphiti.with_context(ctx) do + # current_user == ctx.current_user + PostResource.all +end +``` + +## Concurrency {#concurrency} + +Under Rails, concurrency turns on by default when `::Rails.application.config.cache_classes` is `true` (the default for staging and production). Sibling sideloads then load concurrently, so a `Post` sideloading `Comments` and `Author` loads both at the same time. Your initializer runs after that default lands, so it always has the last word. That cuts both ways, since an unconditional `c.concurrency = true` forces it on everywhere, development and test included. + +```ruby +# config/initializers/graphiti.rb +Graphiti.configure do |c| + # c.concurrency = false + c.concurrency_max_threads = ENV.fetch("GRAPHITI_CONCURRENCY_MAX_THREADS", 4).to_i +end +``` + +Sideloads share a pool of `concurrency_max_threads` threads (default 4) per process. Whatever the request thread knew, the sideload knows too. `Graphiti.context`, fiber-locals and `ActiveSupport::CurrentAttributes` all carry over so `Current.user` works inside a sideload. Assignments made inside a sideload don't travel back. + +### Sizing the connection pool {#concurrency-pool-sizing} + +Every thread talking to the database holds its own connection, and concurrent sideloads are extra threads. The connection pool has to cover both. + +```yaml +# database.yml +pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i + 4 + 1 %> +``` + +That's web threads plus `concurrency_max_threads` plus a spare. Rails uses the same rule for its [async query executor](https://guides.rubyonrails.org/configuring.html#config-active-record-async-query-executor), and the default of 4 comes from there too. + +When the pool is too small you get `ActiveRecord::ConnectionTimeoutError` ("all pooled connections were in use"). It only shows up once traffic is heavy enough to drain the pool, so an undersized app can run happily for months. (So check `database.yml` and make sure `pool` reads the variable your deploys actually set.) + +The pool that drains is ActiveRecord's connection pool. Web threads and concurrent sideload threads all draw from it, which is why the formula above adds `concurrency_max_threads`. Shrinking `concurrency_max_threads` is always safe. When Graphiti's pool fills up, extra sideloads just run on the request thread on its already-counted connection, so you lose some parallelism and nothing else. Raising it is what needs care, since every sideload thread is one more claim on connections, and the formula has to grow with it. + +The database server has its own ceiling, `max_connections` in Postgres. Every Ruby process brings a full pool, so weigh that limit against your process count, meaning Puma `workers` (`WEB_CONCURRENCY`) times your server count, plus each job worker process, all multiplied by `pool`. + +`bin/rake graphiti:audit` checks the formula against this environment's numbers. + +The analysis behind these numbers is in [#469](https://github.com/graphiti-api/graphiti/issues/469), worth reading in full if you're debugging connection errors. + +## Adapters {#adapters} + +Common resource overrides can be packaged into an Adapter for code re-use, most commonly to use a different client/datastore than ActiveRecord/RelationalDB. + +[Adapters are best explained in the 'Without ActiveRecord' recipe](/topics/without-activerecord). diff --git a/website/versioned_docs/version-2.0/getting-started/first-api.md b/website/versioned_docs/version-2.0/getting-started/first-api.md new file mode 100644 index 00000000..8b8b1570 --- /dev/null +++ b/website/versioned_docs/version-2.0/getting-started/first-api.md @@ -0,0 +1,289 @@ +--- +title: 'Build Your First API' +--- + +# Build Your First API + +By the end of this page you'll have a working Rails API, backed by Graphiti, that supports filtering, sorting, pagination, and nested relationships out of the box. + +We'll use Rails and ActiveRecord here, on familiar ground. For how the pieces fit together, see [Lifecycle of a Request](/concepts/overview). + +You'll need Ruby 3.2+ and Rails 7.1+ installed for this walkthrough. Graphiti itself only requires Ruby 3.2+ and ActiveSupport, so you can [use it without Rails](/getting-started/installation#without-rails). + +## Installation {#installation} + +Let's start with a classic Rails blog. We'll use a [template](http://guides.rubyonrails.org/rails_application_templates.html) to handle some of the boilerplate. Run this command and accept all the defaults for now: + +```bash +$ rails new blog --api -m https://raw.githubusercontent.com/graphiti-api/graphiti_rails_template/master/all.rb +``` + +Feel free to run `git diff` if you're interested in the +particulars. This is mostly installing gems and including modules. + +> Note: if a network issue prevents you from pointing to this URL +> directly, you can download the file and and run this command as `-m +> /path/to/template` + +Alternatively, you can [**add to an existing project**](/getting-started/installation#adding-to-an-existing-app). + +## Defining a Resource {#defining-a-resource} + +A [**Resource**](/concepts/resources) defines how to query and persist your [**Model**](/concepts/backends-and-models). In other +words: a Model is to the database as Resource is to the API. So +first, let's define our Model: + +```bash +$ bundle exec rails generate model Post title:string upvotes:integer active:boolean +$ bundle exec rails db:migrate +``` + +Now we can use the built-in [generator](/concepts/resources#generators) to define our Resource, +corresponding [**Endpoint**](/concepts/endpoints), and +[**Integration Tests**](/topics/testing). + +```bash +$ bundle exec rails g graphiti:resource Post title:string upvotes:integer active:boolean +``` + +You'll see a number of files created. Now run your app!: + +```bash +$ bundle exec rails s +``` + +Verify `http://localhost:3000/api/v1/posts` renders JSON correctly. +Now we need data. + +##### Seeding Data {#seeding-data} + +Edit `db/seeds.rb` to create a few `Post`s: + +```ruby +Post.create!(title: 'My title', upvotes: 10, active: true) +Post.create!(title: 'Another title', upvotes: 20, active: false) +Post.create!(title: 'OMG! A title', upvotes: 30, active: true) +``` + +And run the script: + +```bash +$ bundle exec rails db:seed +``` + +Now load `http://localhost:3000/api/v1/posts`. You should have 3 `Post`s in +your database. + + + +
+ +## Querying {#querying} + +Now that we've defined our Resource and seeded some data, let's see +what query functionality we have. We've listed all `Post`s at `http://localhost:3000/api/v1/posts`. Let's see what we can do: + +| What you want | URL | +| --- | --- | +| Sort by title, ascending | `/api/v1/posts?sort=title` | +| Sort by title, descending | `/api/v1/posts?sort=-title` | +| Paginate, 2 per page | `/api/v1/posts?page[size]=2` | +| Paginate, 2 per page, second page | `/api/v1/posts?page[size]=2&page[number]=2` | +| Sparse fieldset: only `title` | `/api/v1/posts?fields[posts]=title` | +| Filter, simple equality | `/api/v1/posts?filter[title]=my title` | +| Filter, case-insensitive equality | `/api/v1/posts?filter[title][eql]=My title` | +| Filter, prefix | `/api/v1/posts?filter[title][prefix]=my` | +| Filter, suffix | `/api/v1/posts?filter[title][suffix]=title` | +| Filter, contains | `/api/v1/posts?filter[title][match]=itl` | +| Filter, greater than | `/api/v1/posts?filter[upvotes][gt]=20` | +| Filter, greater than or equal to | `/api/v1/posts?filter[upvotes][gte]=20` | +| Filter, less than | `/api/v1/posts?filter[upvotes][lt]=20` | +| Filter, less than or equal to | `/api/v1/posts?filter[upvotes][lte]=20` | + +Filtering on an attribute you haven't made filterable raises `Graphiti::Errors::InvalidAttributeAccess`. Filtering on one that doesn't exist raises `Graphiti::Errors::UnknownAttribute`. All filter logic can be customized, and customizations can be packaged into an **Adapter** for reuse. See [Filter](/concepts/resources#filter). + +### Extra Fields + +Some fields are expensive enough that you only want to compute them when a client asks. Declare those with `extra_attribute`: + +```ruby +# app/resources/post_resource.rb +extra_attribute :description, :string do + @object.active? ? 'Active Post' : 'Inactive Post' +end +``` + +Request it with `/api/v1/posts?extra_fields[posts]=description`. You can also eager load data conditionally when the field is requested. + +### Statistics + +Useful for search grids ("the first 10 active posts, plus the total count of all posts") in a single request. Hit `/api/v1/posts?stats[total]=count` and the result arrives in the `meta` section of the response: + +![meta_total_count](/assets/img/meta_total_count.png) + +Statistics respect your filters, so the count adjusts accordingly. There are several built-in stats and you can [add your own](/concepts/resources#statistics). + +### Error Handling + +Your app always renders a JSONAPI-compliant error response. Raise something in the controller: + +```ruby +# app/controllers/posts_controller.rb +def index + raise 'foo' +end +``` + +and this is what you'd see in production: + +![error_payload](/assets/img/error_payload.png) + +Different errors can be given different response codes, JSON, and side effects. See [Error Handling](/topics/error-handling). + +## Persisting {#persisting} + +Resources can Create, Update, and Delete (and you can persist multiple +Resources in a single request). The best way to observe this behavior is +to take a look at the tests the generator created. One example: + +```ruby +# spec/api/v1/employees/create_spec.rb +subject(:make_request) do + jsonapi_post "/api/v1/employees", payload +end + +describe 'basic create' do + let(:payload) do + { + data: { + type: 'employees', + attributes: { + first_name: 'Jane' + } + } + } + end + + it 'works' do + expect(EmployeeResource).to receive(:build).and_call_original + expect { + make_request + }.to change { Employee.count }.by(1) + expect(response.status).to eq(201) + end +end +``` + +Read more about [Persistence](/concepts/persisting) and +[Testing Persistence](/topics/testing#writes). + +## Adding Relationships {#adding-relationships} + +Let’s start by defining our Model: + +```bash +$ bundle exec rails g model Comment post_id:integer body:text active:boolean +$ bundle exec rails db:migrate +``` + +```ruby +# app/models/post.rb +has_many :comments + +# app/models/comment.rb +belongs_to :post +``` + +...and corresponding Resource object: + +```bash +$ bundle exec rails g graphiti:resource Comment body:string active:boolean created_at:datetime +``` + +Configure the relationship in `PostResource`: + +```ruby +# app/resources/post_resource.rb +has_many :comments +``` + +And allow filtering Comments based on the Post `id`: + +```ruby +# app/resources/comment_resource.rb +attribute :post_id, :integer, only: [:filterable] +``` + +This code: + +* Allows eager-loading the relationship. + * URL: `/api/v1/posts?include=comments` + * SQL: `SELECT * FROM comments WHERE post_id = 123` +* Generates a [**Link**](/concepts/links) for +lazy-loading. +* Will use `CommentResource` for querying logic (so we can [Deep +Query](/concepts/relationships#deep-queries), e.g. +"only return the latest 3 active comments"). +* By default, this will generate the query `CommentResource.all(filter: { post_id: 123 })`, but [relationships can be customized](/concepts/relationships) + +You should now be able to hit `/api/v1/comments` with all the same +functionality as before. We need to seed data. + +#### Seeding Relationships {#seeding-relationships} + +Start by clearing out your database: + +```bash +$ bundle exec rails db:migrate:reset +``` + +Replace your `db/seeds.rb` with this code to persist one `Post` and three `Comment`s: + +```ruby +comment1 = Comment.new(body: 'comment one', active: true) +comment2 = Comment.new(body: 'comment two', active: false) +comment3 = Comment.new(body: 'comment three', active: true) + +Post.create! \ + title: 'My title!', + active: true, + comments: [comment1, comment2, comment3] +``` + +And run it: + +```bash +$ bundle exec rails db:seed +``` + +## Relationship Usage {#relationship-usage} + +Now let's fetch a `Post` and filtered `Comment`s in a single request: + +`/api/v1/posts?include=comments` + +Any logic in `CommentResource` is available to us. Let's sort the comments by `created_at` descending: + +`/api/v1/posts?include=comments&sort=-comments.created_at`. + +Logic from `CommentResource` is accessible at the `/api/v1/comments` endpoint, and reusable when eager-loading Comments at `/api/v1/posts:` + +* `/api/v1/comments?filter[active]=true` +* `/api/v1/posts?include=comments&filter[comments.active]=true` + +This is why Resource objects exist: they provide an interface to +reuse code across multiple Endpoints. + +Just as we can query a graph of Resources in a single +request, we can *persist* a graph of Resources in a single request. See +[Sideposting](/concepts/persisting#sideposting). + +## Exploring with Vandal {#exploring-with-vandal} + +Graphiti ships with Vandal, a UI that introspects your schema for point-and-click data exploration. See the [Vandal Guide](/reference/vandal) to try it against this blog. + +## Next Steps {#whats-next} + +* Continue with the [Tutorial](/tutorial) for a deeper walkthrough of customization and relationships. +* Browse the [Resources guide](/) for the full capability reference. +* Read the [Testing Guide](/topics/testing) to start testing your API. diff --git a/website/versioned_docs/version-2.0/getting-started/installation.md b/website/versioned_docs/version-2.0/getting-started/installation.md new file mode 100644 index 00000000..30f526ab --- /dev/null +++ b/website/versioned_docs/version-2.0/getting-started/installation.md @@ -0,0 +1,186 @@ +--- +title: 'Installation' +--- + +:::info Requirements +Graphiti 2.0 requires Ruby 3.2+ and ActiveSupport 7.1+. Rails is optional, and 7.1+ if you use it. Coming from 1.x? Remove `graphiti-rails`, `graphiti_spec_helpers` and `graphiti_errors` from your Gemfile: they're part of the main gem now, and the [upgrade guide](/upgrading) covers the rest. +::: + +## From Scratch {#from-scratch} + +The easiest way to start from scratch is to use the application +template: + +```bash +$ rails new blog --api -m https://raw.githubusercontent.com/graphiti-api/graphiti/main/templates/rails/all.rb +``` + +Alternatively, download and point to the template locally: + +```bash +$ curl -O https://raw.githubusercontent.com/graphiti-api/graphiti/main/templates/rails/all.rb +$ rails new blog --api -m all.rb +``` + +Run `git diff` to see the changes to a blank Rails app. + +## Adding to an Existing App {#adding-to-an-existing-app} + +This process is straightforward. You can add Graphiti to an existing +Rails app alongside [JBuilder](https://github.com/rails/jbuilder) or [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers). + +Start with gems: + +```ruby +# The only strictly-required gem +gem 'graphiti' + +# For automatic ActiveRecord pagination +gem 'kaminari' + +# Test-specific gems +group :development, :test do + gem 'rspec-rails' + gem 'factory_bot_rails' + gem 'faker' +end + +group :test do + gem 'database_cleaner' +end +``` + +You'll be up-and-running at this point. Verify with a simple standalone +Resource: + +```ruby +# Assuming you already have a Post ActiveRecord Model +class PostResource < Graphiti::Resource + self.adapter = Graphiti::Adapters::ActiveRecord + attribute :title, :string +end + +PostResource.all.data # => [#, #, ...] +``` + +Now we need to integrate with Rails endpoints (to give us things +like [#context](/concepts/resources#context)): + +```ruby +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + include Graphiti::Rails::Controller +end +``` + +And wire-up our error-handling: + +```ruby +# app/controllers/application_controller.rb +# When #show action does not find record, return 404 +register_exception Graphiti::Errors::RecordNotFound, + status: 404 + +rescue_from Exception do |e| + handle_exception(e) +end +``` + +That's it for the basics. You may have issues with generators +conflicting with your existing application structure - but you can +always write files manually or [submit an issue](https://github.com/graphiti-api/graphiti/issues). + +### Responders {#responders} + +Graphiti supports JSONAPI, simple JSON, and XML. `Graphiti::Rails::Controller` carries `ActionController::MimeResponds`, so `respond_to` works even in API-only apps: + +```ruby +def index + posts = PostResource.all(params) + + respond_to do |format| + format.json { render(json: posts) } + format.jsonapi { render(jsonapi: posts) } + format.xml { render(xml: posts) } + end +end +``` + +The [Responders](https://github.com/heartcombo/responders) gem collapses that boilerplate: + +```ruby +def index + posts = PostResource.all(params) + respond_with(posts) +end +``` + +To get this functionality: + +```ruby +# Gemfile +gem 'responders' + +# app/controllers/application_controller.rb +include Graphiti::Rails::Responders +``` + +> Note: Persistence operations only support JSONAPI format, so you'll +> still use `render jsonapi:` and `render jsonapi_errors:` for those. + +### .graphiticfg.yml {#graphiticfg} + +The `.graphiticfg.yml` file lives in the root directory of your +application. It holds configuration we need to reuse across a variety of +contexts (primarily generates and rake tasks). If you use our template to create your application, it's created for you. + +Primarily this is used to hold your "API namespace": + +```yaml +namespace: /my_api/v1 +``` + +If this file doesn't exist you may get unexpected errors - make sure to +create it! + +### Testing {#testing} + +To add our [Integration Tests](/topics/testing): + +```ruby +# Gemfile +group :development, :test do + gem 'factory_bot_rails' + gem 'rspec_rails' + gem 'faker' +end + +group :test do + gem 'database_cleaner' +end +``` + +Bootstrap RSpec if you haven't already: + +```bash +$ bin/rails g rspec:install +``` + +Then add the Graphiti spec helpers and database cleaning to your `RSpec.configure` block. See [RSpec Setup](/topics/testing#rspec) in the Testing guide for the config to paste in. + +### will_paginate {#will-paginate} + +By default, we use [Kaminari](https://github.com/kaminari/kaminari) for +ActiveRecord pagination. If you prefer [will_paginate] (or anything +else): + +```ruby +# app/resources/application_resource.rb +paginate do |scope, current_page, per_page| + scope.paginate(page: current_page, per_page: per_page) +end +``` + +## Without Rails {#without-rails} + +You can use Graphiti in any plain `.rb` file, or serve it from any Rack framework. Both live in the repo's [`examples/`](https://github.com/graphiti-api/graphiti/tree/main/examples) directory: [`plain_ruby`](https://github.com/graphiti-api/graphiti/tree/main/examples/plain_ruby) is Graphiti in a single script, and [`sinatra`](https://github.com/graphiti-api/graphiti/tree/main/examples/sinatra) serves JSON:API endpoints from a Sinatra app, including error rendering via `rescue_registry`. diff --git a/website/versioned_docs/version-2.0/intro.md b/website/versioned_docs/version-2.0/intro.md new file mode 100644 index 00000000..9b4e549e --- /dev/null +++ b/website/versioned_docs/version-2.0/intro.md @@ -0,0 +1,311 @@ +--- +id: intro +title: 'Graphiti' +sidebar_label: 'Overview' +sidebar_position: 0 +slug: / +--- + +# Graphiti + +Graphiti is a serialization (and de-serialization) library for Ruby, with integrations for Rails included. + +It's built on the [JSON:API](https://jsonapi.org) spec, which settles the decisions every API accumulates: response shapes, filtering, sorting, pagination, error formats, and how related data rides along. Your client layer (often a javascript single page app) speaks this protocol in return. It isn't complicated, so client logic can be hand-rolled or you can use one of the [many available libraries](https://jsonapi.org/implementations/#client-libraries) that work with the standard. + +This is an alternative to a library like JBuilder, which builds each JSON response individually. + +Graphiti sits on top of your models and exposes them over a JSON:API-compliant interface. You define Resources instead of controllers and serializers, and get filtering, sorting, pagination, sparse fieldsets, statistics, and nested reads and writes across relationships, all over one endpoint. + +Here is the whole loop. A Resource declares what's exposed: + +```ruby title="app/resources/employee_resource.rb" +class EmployeeResource < ApplicationResource + self.model = Employee # usually inferred from the class name, here for clarity + + attribute :first_name, :string + attribute :last_name, :string + attribute :age, :integer + + has_many :positions +end +``` + +The controller hands it the request params and renders the result: + +```ruby title="app/controllers/employees_controller.rb" +class EmployeesController < ApplicationController + def index + employees = EmployeeResource.all(params) + + respond_to do |format| + format.jsonapi { render(jsonapi: employees) } + format.json { render(json: employees) } + format.xml { render(xml: employees) } + end + end + + def show + employee = EmployeeResource.find(params) + authorize employee.data # data is the Employee model, authorize is Pundit + render(jsonapi: employee) + end +end +``` + +A client asks for employees and their positions in one request: + +```http title="Request" +GET /api/v1/employees?include=positions +``` + +```json title="Response" +{ + "data": [ + { + "id": "1", + "type": "employees", + "attributes": { + "first_name": "Jane", + "last_name": "Doe", + "age": 34 + }, + "relationships": { + "positions": { + "data": [ + { "type": "positions", "id": "1" }, + { "type": "positions", "id": "2" } + ] + } + } + } + ], + "included": [ + { + "id": "1", + "type": "positions", + "attributes": { "title": "Engineer" } + }, + { + "id": "2", + "type": "positions", + "attributes": { "title": "Senior Engineer" } + } + ] +} +``` + +That same Resource also serves `?filter[age][gt]=30`, `?sort=-age`, `?page[size]=10`, `?fields[employees]=first_name`, and `?stats[total]=count`, without writing any of them. + +The same proxy renders all three formats, so `/employees.jsonapi`, `/employees.json` and `/employees.xml` all work off one action. + +`.all` and `.find` return that proxy, so nothing has been queried yet. `.data` is where you reach the model, and also where per-record authorization goes. See [Authorization](/topics/authorization#integrating-with-pundit). + +If repeating that `respond_to` block gets old, the optional [`responders`](https://github.com/heartcombo/responders) integration collapses it to `respond_with(employees)`. See [Installation](/getting-started/installation#responders). + +## The whole Resource API + +Every Resource is a collection of defaults, and you can override any of them. Below is one Resource with those defaults written out the long way, the entire surface area on a single page. You wouldn't write this much by hand. It's here so you can see what's available. + +### ApplicationResource + +Every Resource inherits from an `ApplicationResource`, the same way models inherit from `ApplicationRecord`. This is where cross-cutting configuration lives, so individual Resources stay small. It's also the right place to put helpers like `current_user`, which guards throughout your API can then call. + +```ruby title="app/resources/application_resource.rb" +class ApplicationResource < Graphiti::Resource + # Required when there's no corresponding model + self.abstract_class = true + + # Subclasses override as needed + self.adapter = Graphiti::Adapters::ActiveRecord + + # Flip any of these to lock down every Resource at once, + # e.g. a read-only API + self.attributes_readable_by_default = true + self.attributes_writable_by_default = true + self.attributes_sortable_by_default = true + self.attributes_filterable_by_default = true + + # Used for link generation + self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') + self.endpoint_namespace = '/api/v1' + + def current_user + context.current_user + end +end +``` + +### A Resource + +An individual Resource declares its attributes and relationships, plus anything about it that differs from the defaults: + +```ruby +class EmployeeResource < ApplicationResource + # Both inferred from the class name. Set them only when they differ + self.model = Employee + self.type = :employees # the JSONAPI type + + self.default_sort = [{ name: :desc }] # default nil + self.page_default_size = 10 # default 20 + + attribute :name, :string + attribute :age, :integer + attribute :hired_at, :datetime, writable: false + + has_many :positions +end +``` + +That is a complete, working Resource. Everything below is how you override a piece of it. + +### Attributes + +```ruby +# Each flag defaults to the corresponding class-level setting +attribute :name, :string, + readable: self.attributes_readable_by_default, + writable: self.attributes_writable_by_default, + sortable: self.attributes_sortable_by_default, + filterable: self.attributes_filterable_by_default + +# Alter display +# @object is your model instance +attribute :name, :string do + @object.name.upcase +end +``` + +### Sorting + +```ruby +# Pass a type - sort :name, :string - if no attribute is defined +sort :name do |scope, dir| + scope.order(name: dir) +end +``` + +### Filtering + +```ruby +# Pass a type - filter :name, :string - if no attribute is defined +filter :name do + # All of these operators have not_ equivalents, e.g. not_eq + # Imagine ".where.not" instead of ".where" + + eq do |scope, value| + scope.where("lower(name) IN ?", value.map(&:downcase)) + end + + eql do |scope, value| + scope.where(name: value) + end + + prefix do |scope, value| + value.each do |v| + scope = scope.where('lower(name) LIKE ?', "#{v.downcase}%") + end + scope + end + + suffix do |scope, value| + value.each do |v| + scope = scope.where('lower(name) LIKE ?', "%#{v.downcase}") + end + scope + end + + match do |scope, value| + value.each do |v| + scope = scope.where('lower(name) LIKE ?', "%#{v.downcase}%") + end + scope + end +end + +# Comparison operators, for integer, float, datetime, etc +filter :age, :integer do + eq do |scope, value| + scope.where(age: value) + end + + gt do |scope, value| + value.each { |v| scope = scope.where('age > ?', v) } + scope + end + + gte do |scope, value| + value.each { |v| scope = scope.where('age >= ?', v) } + scope + end + + lt do |scope, value| + value.each { |v| scope = scope.where('age < ?', v) } + scope + end + + lte do |scope, value| + value.each { |v| scope = scope.where('age <= ?', v) } + scope + end +end +``` + +Filters receive an array of values by default, which is why each operator above iterates. Pass `single: true` to accept one value instead. + +### Querying + +```ruby +# Passed to sort, filter, paginate, etc +# Apply global logic here: only return active Employees, +# scope results to the current user, and so on +def base_scope + Employee.all +end + +# Must execute the query and return an array of Model instances +def resolve(scope) + scope.to_a +end +``` + +### Persisting + +Your adapter handles writes for you, so most Resources define nothing here. Reach for [lifecycle hooks](/concepts/persisting#persistence-lifecycle-hooks) when you need to intervene: + +```ruby +before_attributes do |attributes| + # before attributes are assigned to the model +end + +before_save do |model| + # assigned, but not yet persisted +end + +before_commit do |model| + # saved and validated, still inside the transaction +end +``` + +The model you inspect is the model that saves. Attributes are assigned up front, so you can hold the model, check it, and change it before anything is written. The instance you were handed is the one that gets persisted: + +```ruby +employee = EmployeeResource.build(payload) + +employee.data # the model, attributes already assigned, nothing written yet +employee.data.valid? # inspect it, or modify it +employee.save # persists that same instance +``` + +Updates work the same way, reading the persisted record until you apply the payload: + +```ruby +proxy = EmployeeResource.find(payload) +proxy.data.first_name # => "asdf", straight from the database +proxy.assign_attributes(payload) +proxy.data.first_name # => "Jane", assigned but still unsaved +proxy.save(action: :update) +``` + +## Upgrading from 1.x + +The [2.0 upgrade guide](/upgrading) covers the whole migration: the three gems that folded into core, the deprecated spellings that still work but warn, and the two real behavior changes. Controllers now opt in via `Graphiti::Rails::Controller`, and `around_persistence` receives the model rather than an attributes hash. diff --git a/website/versioned_docs/version-2.0/js/authentication.md b/website/versioned_docs/version-2.0/js/authentication.md new file mode 100644 index 00000000..1477e917 --- /dev/null +++ b/website/versioned_docs/version-2.0/js/authentication.md @@ -0,0 +1,63 @@ +--- +title: 'Authentication' +sidebar_position: 7 +--- + +### Authentication + +Spraypaint supports [JSON Web Tokens](https://jwt.io/introduction). These can +be set manually, or automatically fetched from `localStorage`. + +To set manually: + +```typescript +ApplicationRecord.jwt = 'myt0k3n' +``` +> All requests will now send the header:
+> `Authorization: Token token="myt0k3n"`. + +To set via `localStorage`, store the token with a key of `jwt` and it will be set automatically. To customize the `localStorage` key: + +```typescript +ApplicationRecord.jwtStorage = "authtoken" +``` + +...or to opt-out of `localStorage` altogether: + +```typescript +ApplicationRecord.jwtStorage = false +``` + +You can control the format of the header that is sent to the +server: + +```typescript + class ApplicationRecord extends SpraypaintBase { + // ... code ... + static generateAuthHeader(token) { + return `Bearer ${token}` + } + } +``` + +```javascript + var ApplicationRecord = SpraypaintBase.extend({ + // ... code ... + static: { + generateAuthHeader: function(token) { + return "Bearer " + token; + } + } + }); +``` + +Finally, if your server returns a refreshed JWT within the `X-JWT` header, it will be used in all subsequent requests (and `localStorage` +will be updated automatically if you're using it). + +

+ + NEXT: + State Syncing + » + +

diff --git a/website/versioned_docs/version-2.0/js/ddau.md b/website/versioned_docs/version-2.0/js/ddau.md new file mode 100644 index 00000000..bf9b2ebc --- /dev/null +++ b/website/versioned_docs/version-2.0/js/ddau.md @@ -0,0 +1,20 @@ +--- +title: 'Ddau' +sidebar_position: 9 +--- + +### Data Down, Actions Up + +It's a [popular pattern](http://www.samselikoff.com/blog/data-down-actions-up) to pass data **down** to components, avoid modifying state within the component, and instead pass **actions up** to modify state. This can make complex applications easier to track and reason about, and you'll see it in client-side frameworks like React. + +To follow this pattern, use `#dup()` when passing down to your component: + +```bash + +``` + +This will create a new instance of the model with all the same state. +Avoid modifying this instance in your component and instead pass +**actions up**. + +When opting-in to [state-syncing](/js/state-syncing) these instances will sync-up whenever one of these is instances is persisted. You won't have to worry about updating the child component when the parent instance is saved. diff --git a/website/versioned_docs/version-2.0/js/extra-params.md b/website/versioned_docs/version-2.0/js/extra-params.md new file mode 100644 index 00000000..11ebfae7 --- /dev/null +++ b/website/versioned_docs/version-2.0/js/extra-params.md @@ -0,0 +1,41 @@ +--- +title: 'Extra Params' +sidebar_position: 10 +--- + +### Extra Params + +Sometimes you need to submit params that are not standard jsonapi params. One great example would be +`https://yourdomain.com/users?debug=true` which is not a param for the `UserResource` you may have, but +might enable functionality in your controller as needed. + +Invoking it is pretty straightforward, just invoke `extraParams` and pass in params and values you wish +to add to your API call when executed. + + +```typescript +YourRecord.extraParams({ debug: true }) +``` + +One common way to use this globally is to put this into a base class so it can be chained as part of +every resource. + +```typescript + @Model + export class ApplicationRecord extends SpraypaintBase { + static withDebug(): Scope { + return this.extraParams({ debug: true }) as Scope; + } + } + // unfortunately you will need to pass in the + // implementing class' type as a generic + UserRecord.withDebug().all() +``` +```javascript + const ApplicationRecord = SpraypaintBase.extend({ + static: { + withDebug: () => this.extraParams({ debug: true }); + } + }) + UserRecord.withDebug().all() +``` diff --git a/website/versioned_docs/version-2.0/js/index.md b/website/versioned_docs/version-2.0/js/index.md new file mode 100644 index 00000000..3cfc2013 --- /dev/null +++ b/website/versioned_docs/version-2.0/js/index.md @@ -0,0 +1,112 @@ +--- +title: 'Index' +sidebar_position: 1 +--- + +

+ Spraypaint + the isomorphic, framework-agnostic Graphiti ORM +

+ +### Why Spraypaint? + +Contracts like JSONAPI and GraphQL treat the API like a database. When querying a database, we have two options: + + * Type the low-level query language directly (in the database world, this would be hand-typing SQL). + * Use an ORM (like Rails's `ActiveRecord`, Phoenix's `Ecto`, Django's `DjangoORM`, or Node's `Sequelize`). + +While both options have pros and cons, we tend to think ORMs have two overwhelming benefits: ***ease of use*** and ***composable queries***. We'll explore both these concepts in other sections. + +So, we want a javascript ORM for our JSONAPI "database". Because `ActiveRecord` is arguably the most well-known ORM, we've tried to match its interface to make this library accessible to new users. That said, you'll find we've tried to favor *explicitness* over *implicitness* in order to avoid common `ActiveRecord` pitfalls. + + Typescript + Javascript +```typescript +// Spraypaint is like "ActiveRecord in Javascript". It can: +// +// * Deeply nest reads and writes +// * Automatically handle validation errors +// * Replace *ux patterns +// * ...and much more! + +// define models +@Model() +class ApplicationRecord extends SpraypaintBase { + static baseUrl = "http://my-api.com" + static apiNamespace = "/api/v1" +} + +@Model() +class Person extends ApplicationRecord { + static jsonapiType = "people" + + @Attr() firstName: string + @Attr() lastName: string + + get fullName() { + return `${this.firstName} ${this.lastName}` + } +} + +// execute queries +Person + .where({ first_name: 'John' }) + .order({ created_at: 'desc' }) + .per(10).page(2) + .includes({ jobs: 'company' }) + .select({ people: ['first_name', 'last_name'] }) + +// persist data +let person = new Person({ firstName: 'Jane' }) +person.save() +``` + +```javascript +// Spraypaint is like "ActiveRecord in Javascript". It can: +// +// * Deeply nest reads and writes +// * Automatically handle validation errors +// * Replace *ux patterns +// * ...and much more! + +var spnt = require('spraypaint/dist/spraypaint') + +// define models +const ApplicationRecord = spnt.JSORMBase.extend({ + static: { + baseUrl: 'http://my-api.com', + apiNamespace: '/api/v1' + } +}) + +const Person = ApplicationRecord.extend({ + attrs: { + firstName: spnt.attr(), + lastName: spnt.attr() + }, + methods: { + fullName: function() { + return this.firstName + ' ' + this.lastName; + } + } +}) + +// execute queries +Person + .where({ first_name: 'John' }) + .order({ created_at: 'desc' }) + .per(10).page(2) + .includes({ jobs: 'company' }) + .select({ people: ['first_name', 'last_name'] }) + +// persist data +var person = new Person({ firstName: 'Jane' }) +person.save() +``` + +### Where to Go Next + + * [Installation](/js/installation) - install spraypaint and connect it to your API + * [Models](/js/models) - define models, attributes, and relationships + * [Reads](/js/reads) - query your API with a composable, ActiveRecord-like interface + * [Writes](/js/writes) - create, update, and destroy records diff --git a/website/versioned_docs/version-2.0/js/installation.md b/website/versioned_docs/version-2.0/js/installation.md new file mode 100644 index 00000000..f1862448 --- /dev/null +++ b/website/versioned_docs/version-2.0/js/installation.md @@ -0,0 +1,120 @@ +--- +title: 'Installation' +sidebar_position: 2 +--- + +### Installation + +Installation is straightforward. Since we use `fetch` underneath the hood, we recommend installing alongside a `fetch` polyfill. + +If using `yarn`: + +```bash +$ yarn add spraypaint isomorphic-fetch +``` + +If using `npm`: + +```bash +$ npm install spraypaint isomorphic-fetch +``` + +Now import it: + +```typescript +import { + Model, + SpraypaintBase, + Attr, + BelongsTo, + HasMany + // etc +} from "spraypaint" +``` + +```javascript +const { + SpraypaintBase, + attr, + belongsTo, + hasMany + // etc +} = require("spraypaint/dist/spraypaint") +``` + +...or, if you're avoiding JS modules, `spraypaint` will be available as a global in the browser. + +### Typescript + +Spraypaint works with modern TypeScript. Depending on your `tsconfig.json` settings, you may need a `!` after each attribute and relationship declaration: + +```typescript +@Attr first_name!: string +@HasMany() positions!: Position[] +``` + +This is because of [Strict Class Initialization](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-7.html#strict-class-initialization) - `strictPropertyInitialization` expects every declared class field to be assigned in the constructor, which Spraypaint's decorators handle at runtime rather than at construction time. For the purposes of Spraypaint, we don't need this check. Remove the need for `!` (as the rest of these guides do) by setting + +`"strictPropertyInitialization": false` + +in `tsconfig.json`. + +### Connecting to the API + +Just like `ActiveRecord`, our models will inherit from a base class that holds connection information (`ApplicationRecord`, or `ActiveRecord::Base` in Rails < 5): + +```typescript +@Model() +class ApplicationRecord extends SpraypaintBase { + static baseUrl = "http://my-api.com" + static apiNamespace = "/api/v1" +} +``` + +```javascript +const ApplicationRecord = SpraypaintBase.extend({ + static: { + baseUrl: "http://my-api.com", + apiNamespace: "/api/v1" + } +}) +``` + +All URLs follow the following pattern: + + * `baseUrl` + `apiNamespace` + `jsonapiType` + +As you can see above, typically `baseUrl` and `apiNamespace` are set on a top-level `ApplicationRecord` (though any subclass can override). `jsonapiType`, however, is set per-model - see [Models](/js/models) for how to define it. + +> **TIP**: Avoid CORS and use relative paths by setting `baseUrl` to `""` + +> **TIP**: You can always use the `endpoint` option to override this pattern and set the endpoint manually. + +#### Setting Application Name + +It can be helpful to send the name of your client application in request headers. With this information, servers can keep track of which clients are hitting which APIs. + +To do this: + +```typescript +@Model() +class Person extends ApplicationRecord { + static clientApplication = "sales-backend" +} +``` + +```javascript +const Person = ApplicationRecord.extend({ + static: { + clientApplication: "sales-backend" + } +}) +``` + +

+ + NEXT: + Models + » + +

diff --git a/website/versioned_docs/version-2.0/js/middleware.md b/website/versioned_docs/version-2.0/js/middleware.md new file mode 100644 index 00000000..199cfaba --- /dev/null +++ b/website/versioned_docs/version-2.0/js/middleware.md @@ -0,0 +1,72 @@ +--- +title: 'Middleware' +sidebar_position: 6 +--- + +### Middleware + +Middleware is helpful whenever you want to globally intercept request. +This is accomplished by assigning a `MiddlewareStack` to your `ApplicationRecord`. Each stack has `beforeFilters` and `afterFilters` where you can globally modify requests. If you `throw("abort")`, the +promise will be rejected. + +Example: redirecting to the login page every time the server returns `401`: + +```typescript + import { MiddlewareStack } from 'spraypaint' + + let middleware = new MiddlewareStack() + middleware.afterFilters.push((response, json) => { + if (response.status === 401) { + window.location.href = "/login" + throw("abort") + } + }) + + ApplicationRecord.middlewareStack = middleware +``` + +```javascript + var MiddlewareStack = spraypaint.MiddlewareStack; + + var middleware = new MiddlewareStack(); + middleware.afterFilters.push(function(response, json) { + if (response.status === 401) { + window.location.href = "/login"; + throw("abort"); + } + }); + + ApplicationRecord.middlewareStack = middleware; +``` + +Example: adding a custom header before the request is sent: + +```typescript + import { MiddlewareStack } from 'spraypaint' + + let middleware = new MiddlewareStack() + middleware.beforeFilters.push((url, options) => { + options.headers["CUSTOM-HEADER"] = "whatever" + }) + + ApplicationRecord.middlewareStack = middleware +``` + +```javascript + var MiddlewareStack = spraypaint.MiddlewareStack; + + var middleware = new MiddlewareStack(); + middleware.beforeFilters.push(function(url, options) { + options.headers["CUSTOM-HEADER"] = "whatever"; + }); + + ApplicationRecord.middlewareStack = middleware; +``` + +

+ + NEXT: + Authentication + » + +

diff --git a/website/versioned_docs/version-2.0/js/models.md b/website/versioned_docs/version-2.0/js/models.md new file mode 100644 index 00000000..af34e48c --- /dev/null +++ b/website/versioned_docs/version-2.0/js/models.md @@ -0,0 +1,202 @@ +--- +title: 'Models' +sidebar_position: 3 +--- + +### Defining Models + +Once your `ApplicationRecord` base class is [connected to the API](/js/installation#connecting-to-the-api), define a model by giving it a `jsonapiType`: + +```typescript +@Model() +class Person extends ApplicationRecord { + static jsonapiType = "people" +} +``` + +```javascript +const Person = ApplicationRecord.extend({ + static: { + jsonapiType: "people" + } +}) +``` + +With the above configuration, all `Person` endpoints will begin `http://my-api.com/api/v1/people`. + +### Defining Attributes + +`ActiveRecord` automatically sets attributes by introspecting database columns. We could do the same - `swagger.json` is our schema - but tend to agree with those who feel this aspect of `ActiveRecord` is a bit too "magical". In addition, explicitly defining our attributes can be used to track which applications are using which attributes of the API. + +Though this is configurable, by default we expect the API to be `under_scored` and attributes to be `camelCased`. + +```typescript +@Model() +class Person extends ApplicationRecord { + // ... code ... + @Attr() firstName: string + @Attr() lastName: string + @Attr() age: number + + get fullName() : string { + return `${this.firstName} ${this.lastName}` + } +} + +let person = new Person({ firstName: "John" }) +person.firstName // "John" +person.lastName = "Doe" +person.attributes // { firstName: "John", lastName: "Doe" } +person.fullName // "John Doe" +``` + +```javascript +const attr = spraypaint.attr +const Person = ApplicationRecord.extend({ + // ... code ... + attrs: { + firstName: attr(), + lastName: attr(), + age: attr() + }, + methods: { + fullName: function() { + return this.firstName + " " + this.lastName; + } + } +}) + +var person = new Person({ firstName: "John" }) +person.firstName // "John" +person.lastName = "Doe" +person.attributes // { firstName: "John", lastName: "Doe" } +person.fullName() // "John Doe" +``` + +Attributes can be marked read-only, so they are never sent to the server on a write request: + +```typescript +@Attr({ persist: false }) createdAt: string +@Attr({ persist: false }) updatedAt: string +``` + +```javascript +attrs: { + createdAt: attr({ persist: false }), + updatedAt: attr({ persist: false }) +} +``` + +### Defining Relationships + +Just like `ActiveRecord`, there are `HasMany`, `BelongsTo`, and `HasOne` relationships: + +```typescript +@Model() +class Dog extends ApplicationRecord { + // ... code ... + @BelongsTo() person: Person[] +} + +class Person extends ApplicationRecord { + // ... code ... + @HasMany() dogs: Dog[] +} +``` + +```javascript +const hasMany = spraypaint.hasMany +const belongsTo = spraypaint.belongsTo + +const Person = ApplicationRecord.extend({ + // ... code ... + attrs: { + dogs: hasMany() + } +}) + +const Dog = ApplicationRecord.extend({ + // ... code ... + attrs: { + person: belongsTo() + } +}) +``` + +By default, we expect the relationship name to correspond to a pluralized `jsonapiType` on a separate `Model`. If your models don't use this convention, feel free to supply it explicitly: + +```typescript +@Model() +class Dog extends ApplicationRecord { + // ... code ... + @BelongsTo('people') owner: Person[] +} + +// alternatively, specify the class directly + +class Dog extends ApplicationRecord { + // ... code ... + @BelongsTo(Person) owner: Person[] +} +``` + +```javascript +const Dog = ApplicationRecord.extend({ + // ... code ... + attrs: { + owner: belongsTo('people') + } +}) +``` + +Relationships can be: + +* Assigned via constructor +* Assigned directly +* Automatically loaded via `.includes()` (see [reads](/js/reads)) +* Saved in a single request `.save({ with: 'dogs' })` (see +[writes](/js/writes)) + +```typescript +let dog = new Dog({ name: "Fido" }) +let person = new Person({ dogs: [dog] }) +person.dogs[0].name // "Fido" + +let person = new Person() +person.dogs = [dog] +person.dogs[0].name // "Fido" + +// Will auto-create Dog instance +let person = new Person({ dogs: [{ name: "Scooby" }] }) +person.dogs[0].name // "Scooby" + +let person = (await Person.includes('dogs')).data +person.dogs // array of Dog instances from the server +``` + +```javascript +var dog = new Dog({ name: "Fido" }) +var person = new Person({ dogs: [dog] }) +person.dogs[0].name // "Fido" + +let person = new Person() +person.dogs = [dog] +person.dogs[0].name // "Fido" + +// Will auto-create Dog instance +var person = new Person({ dogs: [{ name: "Scooby" }] }) +person.dogs[0].name // "Scooby" + +Person.includes('dogs').then((response) => { + var person = response.data + person.dogs // array of Dog instances from the server +}) +``` + +

+ + NEXT: + Reads + » + +

diff --git a/website/versioned_docs/version-2.0/js/reads.md b/website/versioned_docs/version-2.0/js/reads.md new file mode 100644 index 00000000..6705d74d --- /dev/null +++ b/website/versioned_docs/version-2.0/js/reads.md @@ -0,0 +1,494 @@ +--- +title: 'Reads' +sidebar_position: 4 +--- + +The interface for read operations is a simpler version of the [ActiveRecord Query Interface](http://guides.rubyonrails.org/active_record_querying.html). Instead of generating SQL, we'll be generating JSONAPI requests. + +## Basic Finders + +Execute queries with `.all()`, `find()`, or `.first()`: + +```typescript +let response = await Post.all() +response.data // array of Post instances +``` +```javascript +Post.all().then(function(response) { + response.data // array of Post instances +}); +``` +
+

GET /posts

+
+ +```typescript +let response = await Post.find(123) +response.data // Post instance +``` +```javascript +Post.find(123).then(function(response) { + response.data // Post instance +}); +``` +
+

GET /posts/123

+
+ +```typescript +let response = await Post.first() +response.data // Post instance +``` +```javascript +Post.first().then(function(response) { + response.data // Post instance +}); +``` +
+

GET /posts?page[size]=1

+
+ +## Composable Queries with Scopes + +The beauty of ORMs is their ability to compose queries. We'll be doing this by chaining together `Scope`s (query fragments). All of the methods you see on this page can be chained together - the request will not fire until the chain ends with `all()`, `first()`, or `find`. Example: + +```typescript +let scope = Post.order({ name: "desc" }) + +if (someCheckboxIsChecked) { + scope = scope.where({ important: true }) +} else { + scope = scope.where({ important: false }) +} + +scope.all() // request fires +``` + +```javascript +var scope = Post.order({ name: "desc" }); + +if (someCheckboxIsChecked) { + scope = scope.where({ important: true }); +} else { + scope = scope.where({ important: false }); +} + +scope.all() // request fires +``` +
+

/posts?sort=-name&filter[important]=true

+

/posts?sort=-name&filter[important]=false

+
+ +In practice, you'll probably have some scopes you want to re-use across different contexts. A best practice is to store these scopes as class methods (static methods) in the model: + +```typescript +class Post extends ApplicationRecord { + // ... code ... + static superImportant() { + return this + .where({ ranking_gt: 8 }) + .order({ ranking: 'desc' }) + .stats({ total 'count' }) + } +} + +// get 10 super important posts +let scope = Post.superImportant().per(10) +scope.all() // fire query +``` + +```javascript +const Post = ApplicationRecord.extend({ + // ... code ... + static: { + superImportant() { + return this + .where({ ranking_gt: 8 }) + .order({ ranking: 'desc' }) + .stats({ total 'count' }) + } + } +}) + +// get 10 super important posts +var scope = Post.superImportant().per(10); +scope.all() // fire query +``` +
+

/posts?sort=-ranking&stats[total]=count&page[size]=10&filter[ranking_gt]=8

+
+ +## Metadata + +The [meta information](http://jsonapi.org/format/#document-meta) of the JSONAPI response is available as a POJO on the response: + +```typescript +let response = await Post.all() +response.meta // { stats: { total: { count: 100 } } } +``` +```javascript +await Post.all().then(function(response) { + response.meta // { stats: { total: { count: 100 } } } +}) +``` + +## Promises and Async/Await + +The result of `all()`, `first()` or `find` is a [Promise](https://developers.google.com/web/fundamentals/primers/promises). The promise will resolve to a `Response` object. + +A `Response` object has three keys - `data`, `meta`, and `raw`. `data` - the one you'll be using the most - will be a `Model` instance (or array of `Model`) instances. `meta` will be the [Meta Information](http://jsonapi.org/format/#document-meta) returned by the API (mostly used for statistics in our case). `raw` is only used to introspect the raw response document. + +```typescript +Post.all().then((response) => { + response.data // array of Post instances + response.meta // js object from the server + response.raw // js response document +}) +``` + +```javascript +Post.all().then(function(response) { + response.data // array of Post instances + response.meta // js object from the server + response.raw // js response document +}); +``` +
+

/posts

+
+ +Hopefully you're running in an environment that supports ES7's [Async/Await](https://hackernoon.com/6-reasons-why-javascripts-async-await-blows-promises-away-tutorial-c7ec10518dd9). This makes things even easier: + +```typescript +let { data } = await Post.all() +data // array of Post instances + +// alternatively + +let posts = (await Post.all()).data +posts // array of Post instances +``` +
+

/posts

+
+ +## Filtering + +Use `#where()` to apply filters: + +```typescript +Post.where({ important: true }).all() +``` +
+

/posts?filter[important]=true

+
+ +`#where()` clauses can be chained together. If the same key is seen twice, it will be overridden: + +```typescript +Post + .where({ important: true }) + .where({ ranking: 10 }) + .where({ important: false }) + .all() +``` +
+

/posts?filter[important]=false&filter[ranking]=10

+
+ +`#where()` clauses are based on **server implementation**. The key should be exactly as the server understands it. Here are some common conventions we promote: + +```typescript +// id greater than 5 +Post.where({ id_gt: 5 }).all() + +// id greater than or equal to 5 +Post.where({ id_gte: 5 }).all() + +// id less than 5 +Post.where({ id_lt: 5 }).all() + +// id less or equal to 5 +Post.where({ id_lte: 5 }).all() + +// title starts with "foo" +Post.where({ title: { prefix: "foo" } }).all() + +// OR these two values +Post.where({ status_or: ['draft', 'review'] }) + +// AND these two values (default) +Post.where({ status: ['draft', 'review'] }) +``` + +### Escaping Values + +[Graphiti treats a comma as a delimiter of multiple values](/concepts/resources#escaping-values). To escape the comma and tell Graphiti this is a single value, wrap it in `{{curlies}}`: + +```typescript +Post.where({ title: "{{Hello World, here I am}}" }) +``` + +## Sorting + +Use `#order()` to sort. + +If passed a string, it will default to **ascending**: + +```typescript +Post.order("title").all() +``` +
+

/posts?sort=title

+
+ + +Otherwise, pass an object: + +```typescript +Post.order({ title: "desc" }).all() +``` +
+

/posts?sort=-title

+
+ +For multisort, chain multiple `#order()` clauses: + +```typescript +Post + .order({ title: "desc" }) + .order("ranking") + .all() +``` +
+

/posts?sort=-title,ranking

+
+ +## Pagination + +Use `#per()` to set the limit per page: + +```typescript +Post.per(10).all() +``` +
+

/posts?page[size]=10

+
+ +Use `#page()` to set the current page: + +```typescript +Post.page(5).all() +``` +
+

/posts?page[number]=5

+
+ +When chained together (10 per page, the 5th page): + +```typescript +Post.page(5).per(10).all() +``` +
+

/posts?page[size]=10&page[number]=5

+
+ +## Fieldsets + +### Sparse Fieldsets + +Use `#select()` to limit the fields returned by the server: + +```typescript +Post.select(['title', 'status']).all() +``` +
+

/posts?fields[posts]=title,status

+
+ +When dealing with relationships, it may be easier to pass an object, where the key is the corresponding JSONAPI type. This will be exactly what's sent to the server in `?fields`: + +```typescript +Post.select({ + posts: ['title', 'status'], + comments: ['created_at'] +}).all() +``` +
+

/posts?fields[posts]=title,status&fields[comments]=created_at

+
+ +### Extra Fieldsets + +Use `#selectExtra()` to explicitly request a field that doesn't usually come back (often computationally expensive): + +```typescript +Post.selectExtra(['highlights', 'cumulative_ranking']).all() +``` +
+

/posts?extra_fields[posts]=highlights,cumulative_ranking

+
+ +Just like the `select` example above, feel free to pass an object specifying the fields for each relationship. + +## Includes + +Use `#includes()` to ["sideload"](http://jsonapi.org/format/#fetching-includes) associations: + +```typescript +Post.includes("comments").all() +``` +
+

/posts?include=comments

+
+ +You can also pass an array of associations: + +```typescript +Post.includes(["blog", "comments"]).all() +``` +
+

/posts?include=blog,comments

+
+ +Or an object for nested associations: + +```typescript +Post.includes(["blog", { comments: "author" }]).all() +``` +
+

/posts?include=blog,comments.author

+
+ +## Nested Queries + +We can nest all read operations at any level of the graph. Let's say we wanted to fetch all `Post`s and their `Comment`s...but only return comments that are `active`, sorted by `created_at` descending. We can create a `Comment` scope as normal, then `#merge()` it into our `Post` scope: + +```typescript +let commentScope = Comment + .where({ active: true }) + .order({ created_at: "desc" }) +Post + .includes("comments") + .merge({ comments: commentScope }) + .all() +``` + +```javascript +var commentScope = Comment + .where({ active: true }) + .order({ created_at: "desc" }) +Post + .includes("comments") + .merge({ comments: commentScope }) + .all() +``` +
+

/posts?include=comments&filter[comments][active]=true&sort=-comments.active

+
+ +Because this can get verbose, it's often desirable to store it on the class: + +```typescript +class Comment extends ApplicationRecord { + // ... code ... + static recent() { + return this + .where({ active: true }) + .order({ created_at: "desc" }) + } +} + +Post.merge({ comments: Comment.recent() }).all() +``` + +```javascript +const Comment = ApplicationRecord.extend({ + // ... code ... + static: { + recent: function() { + return this + .where({ active: true }) + .order({ created_at: "desc" }) + } + } +}) + +Post + .includes("comments") + .merge({ comments: Comment.recent() }) + .all() +``` + +Any number of scopes can be merged in. Just remember to `#include()` and `#merge()` relationship names **as the server understands them**: + +```typescript +class Dog extends ApplicationRecord { + @BelongsTo() person: Person +} + +// We've modeled this as Dog > person in javascript +// And Person is jsonapiType "people" +// But the server defined the relationship as "owner" +Dog.includes("owner").merge({ owner: Person.limitedFields() }) +``` + +```javascript +const Dog = ApplicationRecord.extend({ + // ... code ... + methods: { + person: belongsTo() + } +}) + +// We've modeled this as Dog > person in javascript +// And Person is jsonapiType "people" +// But the server defined the relationship as "owner" +Dog.includes("owner").merge({ owner: Person.limitedFields() }) +``` + +## Statistics + +Use `#stats()` to request statistics. Access stats within `meta`: + +```typescript +let { data } = await Post.stats({ total: "count" }).all() +data.meta.stats.total.count // the total count +``` + +```javascript +Post.stats({ total: "count" }).all().then(function(response) { + response.meta.stats.total.count // the total count +}) +``` +
+

/posts?stats[total]=count

+
+ +Stats are always independent of pagination. If you request the total count, you'll get the total count even if you're limiting to 10 per page. This means to get **only** statistics - avoid returning `Post` instances altogether - request `0` results per page: + +```typescript +let { data } = await Post.per(0)stats({ total: "count" }).all() +data.meta.stats.total.count // the total count +``` + +```javascript +Post + .per(0) + .stats({ total: "count" }) + .all().then(function(response) { + response.meta.stats.total.count // the total count + }) +``` +
+

/posts?stats[total]=count&page[size]=0

+
+ +

+ + NEXT: + Writes + » + +

diff --git a/website/versioned_docs/version-2.0/js/state-syncing.md b/website/versioned_docs/version-2.0/js/state-syncing.md new file mode 100644 index 00000000..4316736c --- /dev/null +++ b/website/versioned_docs/version-2.0/js/state-syncing.md @@ -0,0 +1,100 @@ +--- +title: 'State Syncing' +sidebar_position: 8 +--- + +### State Syncing + +You may have encountered state management libraries like +[Redux](https://redux.js.org) or [Vuex](https://vuex.vuejs.org/en/intro.html). These are fantastic libraries, but their usefulness is lessened with Spraypaint. As a full-fledged model layer, Spraypaint manages state for you, automatically. + +If you opt-in to this feature: + +```typescript +ApplicationRecord.sync = true +``` + +Instances will sync up whenever the server tells us about updated state. +Consider the scenario where an instance is initially loaded, then separately polled in the background: + +```typescript + let person = (await Person.find(1)).data + + let poll = () => { + await Person.find(1) + setTimeout(poll, 1000) + } + poll() +``` + +```javascript + Person.find(1).then(function(response) { + var person = response.data; + }); + + var poll = function() { + Person.find(1); + setTimeout(poll, 1000); + } + poll() +``` + +Our `poll()` function **never assigns or updates `person`**. But if the server returns an updated `name` attribute, **`person.name` will be automatically updated**. This is true even if `person.name` +is bound in 17 different nested components. + +Instances can still update their attributes independently - we only sync +when the server returns updated data: + +```typescript + let instanceA = (await Person.find(1)).data + let instanceB = (await Person.find(1)).data + + instanceA.name // "Jane" + instanceB.name // "Jane" + + instanceB.name = "Silvia" + instanceA.name // "Jane" + instanceB.name // "Silvia" + + await instanceB.save() + instanceA.name // "Silvia" + instanceB.name // "Silvia" +``` + +```javascript + var instanceA, instanceB; + Person.find(1).then(function(response) { + instanceA = response.data; + }); + Person.find(1).then(function(response) { + instanceB = response.data; + }); + + instanceA.name // "Jane" + instanceB.name // "Jane" + + instanceB.name = "Silvia" + instanceA.name // "Jane" + instanceB.name // "Silvia" + + instanceB.save().then(function() { + instanceA.name // "Silvia" + instanceB.name // "Silvia" + }); +``` + +#### Gotchas + +Under the hood, instances are listening for updates from a central data +store. This means that you'll want to remove listeners whenever you no +longer need the instance - otherwise it will never be garbage collected +properly. To remove a listener: + +```typescript +instance.unlisten() +``` + +In practice, when developing in a SPA, you'll want to `#unlisten()` +whenever a view is destroyed and model instances no longer need to be referenced. If +you are using VueJS, this is done automatically by adding [spraypaint-vue](https://github.com/graphiti-api/spraypaint-vue) +to your application. diff --git a/website/versioned_docs/version-2.0/js/writes.md b/website/versioned_docs/version-2.0/js/writes.md new file mode 100644 index 00000000..6c4fd585 --- /dev/null +++ b/website/versioned_docs/version-2.0/js/writes.md @@ -0,0 +1,373 @@ +--- +title: 'Writes' +sidebar_position: 5 +--- + +Similar to `ActiveRecord`, you can call `#save()` on a model instance. Spraypaint will [create](http://jsonapi.org/format/#crud-creating) (`POST`) or [update](http://jsonapi.org/format/#crud-updating) (`PATCH`) as needed. + +`#save()` returns a `Promise` that will resolve a `boolean` - `true` when the server returns a 200-ish response code, `false` when the server returns a `422` response code (see [validations](/js/writes#validations)). As always, anything else will reject the promise. + +```typescript + let blog = new Blog({ title: "My Blog" }) + let success = await blog.save() // POST /blogs + console.log(success) // true/false + + blog.title = "Updated Title" + success = await blog.save() // PUT /blogs/:id + console.log(success) // true/false +``` + +```javascript + var blog = new Blog({ title: "My Blog" }); + // POST /blogs + blog.save().then(function(success) { + console.log(success); // true/false + + blog.title = "Updated Title": + // PUT /blogs/:id + blog.save().then(function(success) { + console.log(success) // true/false + }); + }); +``` + +After saving, the instance will automatically pick up any server-assigned attributes: + +```typescript + let post = new Post() + await post.save() + post.id // server-assigned value + post.createdAt // server-assigned value +``` + +```javascript + var post = new Post(); + post.save().then(function(success) { + post.id // server-assigned value + post.createdAt // server-assigned value + }); +``` + +If a `Model` was instantiated with data from the server, `isPersisted` will return `true`. This means that we can assign IDs on the client without any adverse behavior. We can also manually mark objects as persisted for testing purposes: + +```typescript + let blog = new Blog({ id: 123 }) + blog.isPersisted // false + await blog.save() // POST /blogs + blog.isPersisted // true + blog.id // 123 + + // Manually mark an instance as persisted + blog = new Blog({ id: 123 }) + blog.isPersisted = true + await blog.save() // PUT /blogs/123 +``` + +```javascript + var blog = new Blog({ id: 123 }); + blog.isPersisted // false + // POST /blogs + blog.save().then(function(response) { + blog.isPersisted // true + blog.id // 123 + }); + + // Manually mark an instance as persisted + var blog = new Blog({ id: 123 }); + blog.isPersisted = true + blog.save() // PUT /blogs/123 +``` + +Notably, **only dirty (changed) attributes will be sent to the server**. This prevents race conditions and unexpected side-effects. In the following example, `Post` has attributes `title`, `description`, and `createdAt`: + +```typescript + let post = (await Post.first()) + post.title = "updated" + // ONLY title sent to the server + await post.save() + // Title is now synced with the server + post.description = "updated" + // ONLY description sent to the server + await post.save() +``` + +```javascript + Post.first().then(function(response) { + var post = response.data; + post.title = "updated"; + // ONLY title sent to the server + post.save().then(function(response) { + // Title is now synced with the server + post.description = "updated"; + // ONLY description sent to the server + post.save(); + }); + }); +``` + +## Validations + +JSONAPI Suite is already set up to return validation errors with a `422` response code and JSONAPI-compliant [errors payload](http://jsonapi.org/format/#errors). Those errors will be automatically assigned, and removed on subsequent requests: + +```typescript + let success = await post.save() + console.log(success) // false + post.errors.title // { message: "Can't be blank", ... } + post.title = "no longer blank" + success = await post.save() + console.log(success) // true + post.errors // {} +``` + +```javascript + post.save().then(function(success) { + console.log(success) // false + post.errors.title // { message: "Can't be blank", ... } + post.title = "no longer blank" + post.save().then(function(success) { + console.log(success); // true + post.errors // {} + }); + }) +``` + +## Dirty Tracking + +When an attribute has been modified, but has not yet been saved to the server, it is considered "dirty". Use `#isDirty()` to see if any attribute is dirty, use the `#changes()` method to see all dirty attributes. + +```typescript + let post = await Post.first() + post.title // "original" + post.isDirty() // false + post.changes() // {} + + post.title = "changed" + post.isDirty() // true + post.changes() // { title: ["original", "changed"] } + + await post.save() + post.isDirty() // false + post.changes() // {} +``` + +```javascript + Post.first().then(function(response) { + var post = response.data; + + post.title; // "original" + post.isDirty(); // false + post.changes(); // {} + + post.title = "changed"; + post.isDirty(); // true + post.changes(); // { title: ["original", "changed"] } + + post.save().then(function(success) { // true + post.isDirty(); // false + post.changes(); // {} + }); + }); +``` + +> Remember, only dirty attributes are sent to the server when `#save()` +> is called. + +`#isDirty()` *can* take into account relationships - just pass a string, array, or object or relationship names. A relationship is considered dirty if: + +* Any objects in the relationship have dirty attributes +* An object was removed from a `hasMany` relationship +* An object was added to a `hasMany` relationship +* Any object within the relationship was replaced with a different +object. + +```typescript + let post = await Post.first() + post.comments[0].text = "my comment" + post.isDirty("comments") // true + + post = await Post.first() + post.comments.push(new Comment()) + post.isDirty("comments") // true + + post = await Post.first() + post.comments.splice(1, 1) + post.isDirty("comments") // true + + post = await Post.first() + post.blog // an existing Blog instance + post.blog = (await Blog.first()).data + post.isDirty("blog") // true + + // check nested relationships + post.isDirty(["blog", { comments: "author" }]) +``` + +```javascript + Post.first().then(function(response) { + var post = response.data; + post.comments[0].text = "my comment"; + post.isDirty("comments"); // true + }); + + Post.first().then(function(response) { + var post = response.data; + post.comments.push(new Comment()); + post.isDirty("comments"); // true + }); + + Post.first().then(function(response) { + var post = response.data; + post.comments.splice(1, 1); + post.isDirty("comments"); // true + }); + + Post.first().then(function(response) { + var post = response.data; + post.blog; // an existing Blog instance + + Blog.first().then(function(blog) { + post.blog = (await Blog.first()).data + post.isDirty("blog") // true + }); + }); + + // check nested relationships + post.isDirty(["blog", { comments: "author" }]) +``` + +If you need to reset dirty tracking, call `#reset()` + +```typescript + let post = await Post.first() + post.title // "original" + post.title = "changed" + post.isDirty() // true + post.reset() + post.title // "changed" + post.isDirty() // false +``` + +```javascript + Post.first().then(function(post) { + post.title; // "original" + post.title = "changed"; + post.isDirty() // true + post.reset(); + post.title; // "original" + post.isDirty() // false + }); +``` + +## Nested Writes + +You can write a `Model` and all of its relationships in a single request. Keep in mind normal dirty tracking rules still apply - nothing is sent to the server unless it is dirty. + +```typescript + let author = new Author() + let comment = new Comment({ author }) + let post = new Post({ comments: [comment] }) + + // post.save({ with: "comments" }) + // post.save({ with: ["comments", "blog"] }) + post.save({ with: { comments: 'author' }}) +``` + +```javascript + var author = new Author(); + var comment = new Comment({ author: author }); + var post = new Post({ comments: [comment] }); + + // post.save({ with: "comments" }) + // post.save({ with: ["comments", "blog"] }) + post.save({ with: { comments: "author" }}); +``` + +Use `model.isMarkedForDestruction = true` to delete the associated object. Use `model.isMarkedForDisassociation = true` to remove the association without deleting the underlying object: + +```typescript + let post = (await Post.includes("comments").first()).data + post.comments[0].isMarkedForDestruction = true + post.comments[1].isMarkedForDisassociation = true + + // destroys the first comment + // disassociates the second comment + await post.save({ with: "comments" }) +``` + +```javascript + Post.includes("comments").first().then(function(response) { + var post = response.data; + post.comments[0].isMarkedForDestruction = true; + post.comments[1].isMarkedForDisassociation = true; + + // destroys the first comment + // disassociates the second comment + post.save({ with: "comments" }) + }); +``` + +You may want to send *only* the `id` of the related object to the server - ensuring the models are associated without updating attributes by accident. Just add `.id` to the relationship name: + +```typescript + post.save({ with: "comments.id" }) +``` + +```javascript + post.save({ with: "comments.id" }) +``` + +## Deferred Action + +If your update or destroy action takes a long time then the server can respond with status code `202 Accepted` and include background job object in the payload. + +Example response: +```http +HTTP/1.1 202 Accepted +Content-Type: application/vnd.api+json + +{ + "data": { + "type": "background_jobs", + "id": "550e8400-e29b-41d4-a716-446655440000", + "attributes": { + "status": "pending" + } + } +} +``` + +You will need to give the model object a callback called `onDeferredDestroy` or `onDeferredUpdate`. Spraypaint will then call your callback with the deserialized object included in the payload. + +```typescript +let person = new Person({ firstName: 'Jane' }) +person.onDeferredUpdate = (job: any) => { + handleBackgroundJob(job); +} +person.save() + +person.onDeferredDestroy = (job: any) => { + handleBackgroundJob(job); +} +person.destroy() +``` + +```javascript +const person = new Person({ firstName: 'Jane' }); +person.onDeferredUpdate = (job) => { + handleBackgroundJob(job); +}; +person.save(); + +person.onDeferredDestroy = (job) => { + handleBackgroundJob(job); +}; +person.destroy(); +``` + +

+ + NEXT: + Middleware + » + +

diff --git a/website/versioned_docs/version-2.0/reference/vandal.md b/website/versioned_docs/version-2.0/reference/vandal.md new file mode 100644 index 00000000..ffb589f3 --- /dev/null +++ b/website/versioned_docs/version-2.0/reference/vandal.md @@ -0,0 +1,63 @@ +--- +title: 'Vandal' +--- + +# Vandal +Vandal is the Graphiti UI. It's helpful for exploring data, testing and +generating URLs. To take Vandal for a spin, [view our sample app](https://jsonapi-employee-directory.herokuapp.com/vandal) (*initial load may take a second*). + +
+ + + + +## Installation {#installation} + +### Installing via Template {#installing-via-template} + +If you ran our [application template](/getting-started/installation), +you already have Vandal installed. Check your routes to see it mounted. + +### Installing via Gem {#installing-via-gem} + +* Add the `vandal_ui` gem. +* Run `rake vandal:install` +* Mount the engine: + +```ruby +# config/routes.rb +scope path: "/api/v1", defaults: {format: :jsonapi} do + # ... routes ... + mount VandalUi::Engine, at: '/vandal' +end +``` + +That's it! Vandal will dynamically generate a schema at `/vandal/schema.json`, and you can view the UI at `/vandal`. + +### Manual Installation {#manual-installation} + +[Vandal](https://github.com/graphiti-api/vandal) is a VueJS +application. Grab the [dist files](https://github.com/graphiti-api/vandal/tree/master/dist) and put them anywhere you'd like. + +`index.html` has a placeholder, `__SCHEMA_PATH__`. Replace +this with a URL hosting your schema, and you'll be good to go. + +## Usage {#usage} + +First, make sure your schema is being correctly generated. You should +see Vandal make a request something like `/vandal/schema.json` - make +sure that looks correct. If it doesn't, you may need to bounce your +server. + +After selecting an endpoint, use the left rail to configure your +request. Click a relationship once to include it in the response. +If a relationship is included, you can click any row in the table to +view related data. + +Click a relationship twice and you can configure the deep query of +the associated Resource. In other words, if you're fetching Posts and +Comments, click `comments` twice to say things like "only active +comments should be returned". + +When you hit 'submit', the top URL bar will change to reflect your query +and results will show in the center table. diff --git a/website/versioned_docs/version-2.0/reference/why.md b/website/versioned_docs/version-2.0/reference/why.md new file mode 100644 index 00000000..f8743607 --- /dev/null +++ b/website/versioned_docs/version-2.0/reference/why.md @@ -0,0 +1,13 @@ +--- +title: 'Why REST?' +--- + +# Why REST? + +Graphiti builds on REST rather than replacing it, which is worth a short explanation if you're weighing it against GraphQL. + +The complaints that motivated GraphQL are real. REST APIs often make clients do several round trips and still hand back the wrong shape of data. But those are complaints about how REST APIs are usually built, not about REST. Add eager-loading and a schema to REST and the complaints go away, and you keep the parts of REST that are hard to get back once you've left: addressable URLs, HTTP caching, and Links that let the server change how a relationship resolves without breaking clients. + +The other half is conventions. A GraphQL schema is hand-written per type, so filtering and sorting get reinvented on every team. One API spells it `name_contains`, another `name_LIKE`, another exposes no multisort at all. JSON:API already answers those questions, so `?filter[name][prefix]=Ja&sort=-created_at&page[size]=10` means the same thing on every endpoint of every Graphiti API. You define the Resource. The query interface follows from the attribute types. + +That's the whole tradeoff: fewer decisions per endpoint, at the cost of a fixed request and response format. diff --git a/website/versioned_docs/version-2.0/topics/authorization.md b/website/versioned_docs/version-2.0/topics/authorization.md new file mode 100644 index 00000000..35b10e4d --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/authorization.md @@ -0,0 +1,155 @@ +--- +title: 'Authorization' +--- + +Graphiti authorization happens at three independent layers: which records a query can ever see (`base_scope`), which attributes are readable/writable on those records, and which relationships can be sideloaded or sideposted. Each layer is enforced separately, so a guard on one doesn't imply anything about the others. + +## Context + +Guards need to know who's asking. Every Resource has access to `Graphiti.context` via the `#context` method (`lib/graphiti/resource.rb`). In a Rails app, including `Graphiti::Rails::Context` in your controller wraps every action in `Graphiti.with_context(graphiti_context, action_name.to_sym)`, and `graphiti_context` defaults to the controller instance itself (`lib/graphiti/rails/context.rb`): + +```ruby +class ApplicationController < ActionController::Base + include Graphiti::Rails::Context +end +``` + +That means `context` is your controller, and `context.current_user` (or whatever helper method your controller exposes) is available inside any Resource. Outside of Rails, set context manually: + +```ruby +Graphiti.with_context(OpenStruct.new(current_user: user)) do + PostResource.all +end +``` + +## Scoping records + +Override `#base_scope` to limit which records a Resource can ever return, regardless of filters: + +```ruby +class PostResource < ApplicationResource + def base_scope + Post.where(account_id: context.current_user.account_id) + end +end +``` + +This runs before filtering, sorting, and pagination, so it can't be bypassed by query params. It applies to sideloads too: a `has_many`/`belongs_to`/`has_one` on the related Resource inherits that Resource's `base_scope` unless the relationship itself passes an explicit `base_scope:` option (`lib/graphiti/sideload.rb#base_scope`), so a scoped Resource stays scoped no matter which relationship it's reached through. See [Composing with Scopes](/concepts/resources#composing-with-scopes) for how `base_scope` fits into the rest of query building. + +### Knowing which action you're in + +`base_scope` runs for every action, and sometimes you want it to behave differently for a collection than for a single record. Use `current_action`, which is the action name as a symbol. Rails sets it from `action_name` when wrapping the request (`lib/graphiti/rails/context.rb#wrap_graphiti_context`): + +```ruby +def base_scope + return Post.all if current_action == :show + Post.where(account_id: context.current_user.account_id) +end +``` + +Reach for this rather than digging through the query object's internals. `current_action` is public and stable. The params inside `Graphiti::Query` are neither. It is `nil` when nothing set it, which includes a Resource spec that calls `Graphiti.with_context` without a second argument. + +## Integrating with Pundit + +Graphiti has no built-in Pundit integration, but the two compose cleanly: let Pundit's policy scope decide which records exist, and let Graphiti guards decide which fields and relationships are exposed. + +Merge the policy scope in `base_scope`, so it can't be bypassed by query params: + +```ruby +class ApplicationResource < Graphiti::Resource + def base_scope + Pundit.policy_scope!(context.current_user, model) + end + + def current_user + context.current_user + end +end +``` + +Because `context` is the controller in Rails, per-record authorization stays where it always was: in the action, on the model the proxy hands you: + +```ruby +def show + post = PostResource.find(params) + authorize post.data + render jsonapi: post +end +``` + +Note the split: the policy scope answers "which records may appear at all", `authorize` answers "may this user see this specific record", and [attribute guards](#attribute-guards) answer "which fields of it". Reaching for a policy inside an attribute guard works too, since guards can receive the model: + +```ruby +attribute :salary, :integer, readable: :salary_visible? + +def salary_visible?(model) + Pundit.policy(current_user, model).salary? +end +``` + +## Attribute guards + +Pass a symbol, string, or proc to `readable:`/`writable:` on an attribute to gate it per-request. The guard method can optionally accept the model instance and the attribute name as arguments. Arity decides what it receives, and the model is only resolved if a guard actually declares a parameter for it (`lib/graphiti/util/attribute_check.rb`, `lib/graphiti/resource.rb#guard_model`): + +```ruby +class EmployeeResource < ApplicationResource + attribute :salary, :integer, writable: :salary_writable? + + def salary_writable?(model_instance, attribute_name) + context.current_user.admin? || context.current_user == model_instance.manager + end +end +``` + +On create the model is a new unsaved instance. On update it's the persisted record. A failed `writable` guard on a request rejects the write with an `unwritable_attribute` validation error before anything is persisted (`lib/graphiti/request_validators/validator.rb`). A failed `readable` guard omits the attribute from the response (`lib/graphiti/util/serializer_attributes.rb`). + +You can set the same guard for every attribute on a Resource with `attributes_readable_by_default`/`attributes_writable_by_default`, which also accept a symbol (`lib/graphiti/resource/configuration.rb`): + +```ruby +class ApplicationResource < Graphiti::Resource + self.attributes_writable_by_default = :writable_by_default? + + def writable_by_default?(model_instance, attribute_name) + PolicyChecker.new(context.current_user).writable?(model_instance, attribute_name) + end +end +``` + +Full option details, including `only`/`except` shorthand, live under [Limiting Behavior](/concepts/resources#limiting-behavior). + +## Relationship guards + +`has_many`, `belongs_to`, `has_one`, and `many_to_many` accept the same `readable:`/`writable:` guard shape, but unlike attribute guards, relationship guards take no arguments. Base the decision on `context` alone (`lib/graphiti/sideload.rb#evaluate_flag`): + +```ruby +class EmployeeResource < ApplicationResource + has_many :salary_histories, readable: :admin?, writable: :admin? + + def admin? + context.current_user.admin? + end +end +``` + +A failed `readable` guard silently scrubs the relationship from `?include=` before any records are fetched, and omits it from serialized output. A failed `writable` guard rejects a sidepost to that relationship with an `unwritable_relationship` validation error (`lib/graphiti/request_validators/validator.rb`). + +The guard method can live on either side of the relationship: Graphiti first looks for it on the resource declaring the relationship, falling back to the related resource if it isn't defined there (`lib/graphiti/sideload.rb#guard_resource`). That lets you define the guard once on the related resource and cover every relationship that points at it. + +To audit every guarded relationship across your app (useful before deploying a new guard), call `Graphiti.guarded_relationships`, which returns strings like `"EmployeeResource.salary_histories"` for every relationship whose `readable` or `writable` flag is a symbol, string, or proc (`lib/graphiti.rb#guarded_relationships`). + +See [Customizing Relationships](/concepts/relationships#customizing-relationships) for the rest of the relationship option surface. + +## Testing authorization + +Set context in a spec with `Graphiti.with_context`: + +```ruby +let(:ctx) { OpenStruct.new(current_user: double(admin?: true)) } + +it 'exposes salary to admins' do + Graphiti.with_context(ctx) { render } + expect(d[0].salary).to eq(100_000) +end +``` + +See [Context](/topics/testing#context) in the testing guide for more on setting context in Resource and API specs. diff --git a/website/versioned_docs/version-2.0/topics/caching.md b/website/versioned_docs/version-2.0/topics/caching.md new file mode 100644 index 00000000..7b9d4f21 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/caching.md @@ -0,0 +1,55 @@ +--- +title: 'Caching' +--- + +# Caching + +Graphiti can cache the rendered JSON response for a request, keyed off the underlying data and the parts of the query that affect rendering. This is opt-in at two levels: a global switch that enables cache-backed rendering, and a per-resource declaration that says which resources actually participate. + +## Enabling it + +First, tell Graphiti which cache store to use. Any object that responds to `fetch` works, so `Rails.cache` is the usual choice: + +```ruby +Graphiti.cache = Rails.cache +``` + +Then turn on cache-backed rendering globally: + +```ruby +Graphiti.configure do |config| + config.cache_rendering = true +end +``` + +If `cache_rendering` is `true` but `Graphiti.cache` isn't set to something that responds to `fetch`, Graphiti raises `"You must configure a cache store in order to use cache_rendering. Set Graphiti.cache = Rails.cache, for example."` the first time `Graphiti.config.cache_rendering?` is checked. + +`cache_rendering` alone doesn't cache anything, though. Each resource has to opt in with `cache_resource`: + +```ruby +class EmployeeResource < ApplicationResource + cache_resource expires_in: 5.minutes, tag: :cache_tag +end +``` + +`expires_in` defaults to `false` (no expiry) and `tag` defaults to `nil`. Calling `cache_resource` sets a resource-level flag that flows through every `all`/`find` call on that resource, so caching applies to both index and show-style requests. + +## What actually gets cached + +Only the rendered JSON is cached, not the database query. On render, if the resource proxy is cacheable and `Graphiti.config.cache_rendering?` is true, the renderer wraps the render call in `Graphiti.cache.fetch`, keyed by `"graphiti:render/#{proxy.cache_key}"`, versioned by `proxy.updated_at`, and expiring after `proxy.cache_expires_in`. If either condition is false, rendering happens normally with no cache involved. + +## Cache key composition + +`proxy.cache_key` combines three pieces, joined into a single expanded cache key: + +- **Scope cache key**: the underlying object's own `cache_key` (typically an ActiveRecord relation's `cache_key`, so it reflects the resolved records), combined with the `cache_key` of every sideloaded resource proxy. Sideloading `positions` or `department` folds their cache keys into the parent's. +- **Query cache key**: a SHA1 digest over the parts of the query that affect *rendering*: `extra_fields`, `fields`, whether links are requested, whether pagination links are requested, and `format`. Filters, sorts, and pagination page/size are deliberately not part of this digest. Two requests that select the same rendering options produce the same query cache key even if they filter different data. This is why the key is always combined with the scope key, which does vary with the resolved records. +- **Resource cache tag**: if `cache_resource` was given a `tag:`, and the resource responds to that method, its value is appended as a third segment (e.g. `cache_resource tag: :cache_tag` calls `resource.cache_tag` and appends the result). + +## Versioning and expiry + +`proxy.updated_at` is the max `updated_at` across the resolved records (`@object.maximum(:updated_at)`) and every sideloaded proxy's `updated_at`, recursively. If that calculation raises, Graphiti logs the error and falls back to `Time.now`, so a broken `updated_at` calculation degrades to "always fresh" rather than raising into the request. This value is passed as the cache store's `version:` option, so it participates in the effective cache entry the same way `ActiveSupport::Cache::Store#fetch` normally handles versioning. `expires_in` is passed straight through to the cache store as-is from `cache_resource`. + +## Debugging cache behavior + +When the [debugger](/topics/debugging) is enabled and a request's rendering is actually cached (`proxy.cached?` and `cache_rendering?` both true), the debug output includes a cache section showing the cache key's name, whether it's "stable" or "volatile" (based on how often the key changes across requests), and, when the key does change, which cache-key segments were added or removed. This is built on `Graphiti::Util::CacheDebug`, which persists hit/miss counts in `Graphiti.cache` between requests to compute those stats. See [ETags](/topics/etags) for the related per-response version identifier this same infrastructure computes. diff --git a/website/versioned_docs/version-2.0/topics/customizing-sideloads.md b/website/versioned_docs/version-2.0/topics/customizing-sideloads.md new file mode 100644 index 00000000..bded319d --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/customizing-sideloads.md @@ -0,0 +1,156 @@ +--- +title: 'Customizing Sideloads' +--- + +# Customizing Sideloads +> [See the code in our sample app](https://github.com/graphiti-api/employee_directory/commit/e5dbb24b7e5853a9f39aed455a5d318d303df37e) + +This cookbook will help you understand sideloading. It would be great to +live in a world where everything follows default ActiveRecord table +conventions, but in my experience this is rarely the case. From legacy +code to alternate datastores, we need to think in Real World terms. + +Our [Employee Directory](https://github.com/graphiti-api/employee_directory) sample application +has a clean schema - let's screw with it. Let's say `Department` has a column called `watcher_emails`, which is an array of strings. We want to sideload `Department > Watchers`. Though the *relationship* is called `watchers`, these will be `Employee` records. + +Let's start by adding a spec: + +```ruby +# spec/resources/department/reads_spec.rb + +describe 'sideloading' do + describe 'watchers' do + let!(:employee1) { create(:employee) } + let!(:employee2) { create(:employee) } + let!(:employee3) { create(:employee) } + let!(:department) do + create :department, + watcher_emails: [employee1.email, employee3.email] + end + + before do + params[:include] = 'watchers' + end + + it 'sideloads employees via watcher_emails' do + render + sl = d[0].sideload(:watchers) + expect(sl.map(&:id)).to eq([employee1.id, employee3.id]) + expect(sl.map(&:jsonapi_type).uniq).to eq(['employees']) + end + end +end +``` + +Add the relationship: + +```ruby +# app/resources/department_resource.rb +has_many :watchers, resource: EmployeeResource +``` + +Run the test and you'll get this error: + +```error +Graphiti::Errors::AttributeError: + EmployeeResource: Tried to filter on attribute :department_id, but could not find an attribute with that name. +``` + +How would we track down this error? Well, we know Resources connect +together with [Links](/concepts/links). Let's +take a look at the query parameters that would be used to connect these +two Resources: + +```ruby +has_many :watchers, resource: EmployeeResource do + params do |hash, departments| + binding.pry + end +end +``` + +> Note - we're using [pry](https://github.com/pry/pry) to debug here. + +The value of `hash` here is: + +```ruby +{ filter: { department_id: "1" } } +``` + +Which makes sense. If we say `has_many :things`, by default we expect `Thing` to have a `department_id` we can query. + +That's not our case, though. Instead, let's customize those parameters +to fit our use case: + +```ruby +params do |hash, departments| + emails = departments.map(&:watcher_emails).flatten + hash[:filter] = { email: emails } +end +``` + +Instead of querying by `department_id`, we need to query by `email`. And +the value we pass in will be an array of email addresses + +We'd need to add an `email` filter to `EmployeeResource` to make this +work. This gets us ***querying*** correctly, but there's another error: + +```error +undefined method `department_id' for # +``` + +Here's the thing to keep in mind: let's say our request was +`/departments?include=watchers`. We queried all the data, and we now have an array of `Department`s and an array of `Employee`s. Now we need +to specify which employees should be assigned as watchers of which +department. + +Let's write that code manually: + +```ruby +has_many :watchers, resource: EmployeeResource do + # ... code ... + assign do |departments, employees| + departments.each do |d| + d.watchers = employees.select do |e| + e.email.in?(d.watcher_emails) + end + end + end +end +``` + +We're selecting all relevant `Employee`s for a given `Department` by checking the array of `watcher_emails`. + +This code can be tightened up a little with `assign_each` (recommended). +This way we don't have to iterate departments or worry about the +assignment ourselves: + +```ruby +has_many :watchers, resource: EmployeeResource do + # ... code ... + + assign_each do |department, employees| + employees.select { |e| e.email.in?(d.watcher_emails) } + end +end +``` + +We're using `#select` to return an array of relevant `Employee`s. If this was a `belongs_to` or `has_one` relationship, we'd probably want to use `#find` to return a single `Employee`. + +OK there's *one last error*: + +```error +undefined method `watchers=' for # +``` + +This one is simple - the `assign` function will call your Adapter's assignment logic, which by default will be a simple `department.watchers += relevant_employees`. That means we need to add a getter/setter for +this property: + +```ruby +# app/models/department.rb +attr_accessor :watchers +``` + +And we're done! The test should now pass. [Check out the working code +here](https://github.com/graphiti-api/employee_directory/tree/customize_sideloads_cookbook). diff --git a/website/versioned_docs/version-2.0/topics/debugging.md b/website/versioned_docs/version-2.0/topics/debugging.md new file mode 100644 index 00000000..c7179b0b --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/debugging.md @@ -0,0 +1,242 @@ +--- +title: 'Debugging' +--- + +## Debugger {#debugger} + +Graphiti comes with a debugger that shows the queries executed for a +given request. Remember that Resources [have a query interface](/concepts/resources#query-interface) independent of a request or response. And Resources connect similar to ActiveRecord's `includes`: + +```ruby +employees = EmployeeResource.all +PositionResource.all(filter: { employee_id: employees.map(&:id) }) +``` + +> *Remember, this is all [customizable](/concepts/relationships#customizing-relationships)*. + +That means we can log the requests made by individual Resources: + +`/api/v1/employees?include=notes,positions.department.teams` +

+ +

+ +And even copy/paste these queries into a console session to debug: + +```bash +$ bin/rails c +>> TeamResource.all({:filter=>{:department_id=>"1,2,3"}}) +``` + +If you're having trouble with a request, see if you can isolate to a +specific Resource, then test that Resource directly. + +Finally: if an error occurs, we'll note the query that caused it: + +

+ +

+ +### JSON Output {#json-output} + +It can be helpful to have this debug output come back as part of the +JSON response. To enable this: + +```ruby +# app/controllers/application_controller.rb +def allow_graphiti_debug_json? + true + # or, current_user.admin? + # or, Rails.env.development? +end +``` + +And request the debug output: + +`/your/url?debug=true` + +You should now see the debug output in `meta`: + +

+ +

+ +
+ +If there's an error, and you've [enabled raw errors](/topics/error-handling#displaying-raw-errors), you'll also see the query that caused the error in the JSON response: + +
+ +

+ +

+ +
+ +### Configuration {#configuration} + +By default, we'll log to `Rails.logger`, and only enable debugging (logs or JSON) when `Rails.logger.level` is set to `debug`. Here are the +various ways to configure. + +Use `config.debug` to explicitly toggle debugging: + +```ruby +# config/initializers/graphiti.rb +Graphiti.configure do |c| + c.debug = false +end + +# Or use environment variable +# GRAPHITI_DEBUG=false +``` + +Use `config.debug_models` to get additional (but verbose) output: + +

+ +

+ +```ruby +# config/initializers/graphiti.rb +Graphiti.configure do |c| + c.debug_models = true +end + +# Or use environment variable +# GRAPHITI_DEBUG_MODELS=true +``` + +As noted above, `allow_graphiti_debug_json?` must return `true` if you +want JSON output: + +```ruby +# app/controllers/application_controller.rb +def allow_graphiti_debug_json? + true + # or, current_user.admin? + # or, Rails.env.development? +end +``` + +Note you need to explicitly pass `?debug=true` in the request. + +Assign a different logger: + +```ruby +Graphiti.logger = Logger.new(...) + +# Or the built-in STDOUT logger: +Graphiti.logger = Graphiti.stdout_logger +``` + +Manually apply the debugging (when using Rails, this normally happens in +a `around_action`): + +```ruby +Graphiti::Debugger.debug do + EmployeeResource.all +end +``` + +### Rake Tasks {#rake-tasks} + +There are some common debugging scenarios that are possible to do +manually, but their frequency warrants common patterns. For these, we +have rake tasks. + +#### graphiti:request {#graphiti-request} + +> `bin/rake graphiti:request[PATH,DEBUG]` + +Execute a request using `ActionDispatch::Integration::Session` (which +underlies request specs). + +This can be helpful when you don't have, or don't want to spin up, a web +server. Imagine you want to debug something on production, so you shell +into a docker container and edit some files locally. Now you want to +execute a request and see if your changes worked: + +```bash +$ bin/rake graphiti:request[/employees] +``` + +Will execute the request and spit out the JSON response. You may want to +run with the Debugger enabled: + +```bash +$ bin/rake graphiti:request[/employees,true] +``` + +Which add Debugger output as well. + +The `PATH` should not contain the domain unless you want to hit a live +API instead of a test server. + +#### graphiti:audit {#graphiti-audit} + +> `bin/rake graphiti:audit` + +Audits every relationship declared across your resources. It reports anything that will raise at request time, relationships that load an association just to render resource ids, and `belongs_to` relationships that render no ids unless included. A checklist at the end shows what was checked: + +``` +ERROR will raise when the relationship is included: the model has no association method + + EmployeeResource + has_many :positions Employee has no #positions method + + fix: define it, point the relationship at the real association with `as:`, or remove the relationship + +checks + + ✓ all relationships inspectable + ✗ 1 association method missing + ✓ all readable guards defined + ✓ all sideload filters declared + +graphiti: 12 resources, 40 relationships, 1 error. +``` + +The task exits nonzero when there are errors, so it can hold the line in CI. Run it before and after flipping [belongs_to_resource_ids_by_default](/concepts/relationships#belongs-to-resource-ids) to see exactly what the setting changes. + +#### graphiti:benchmark {#graphiti-benchmark} + +> `bin/rake graphiti:benchmark[PATH,NUM_REQUESTS]` + +It can be helpful to run a quick benchmark without hitting a live web +server, to eliminate the vagaries of latency. To do this: + +```bash +$ bin/rake graphiti:benchmark[/employees,100] +``` + +Which will return the average response time. + +#### Authorization headers {#Authorization-headers} + +If you have an Authorization scheme implemented (for example [authenticate_or_request_with_http_token](https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html) in rails) you can supply the `Authorization` http header value with the `AUTHORIZATION_HEADER` environment variable: + +```bash +$ export AUTHORIZATION_HEADER="Token --PRIVATE_API_KEY--" +$ bin/rake graphiti:request[/employees,true] +``` + +This also will work for `Basic` ([request_http_basic_authentication](https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Basic.html$$)) and `Bearer` values + +## Tips {#tips} + +When debugging an application, try to isolate the individual Resource +call and debug the Resource directly (instead of running the entire +request). This helps eliminate variables, and plain ruby code is easier +to work with. If possible, try to remove Graphiti entirely and focus on +your Models and Backends. + +The most common scenario is debugging a query. We suggest overriding +`resolve` and using [pry](https://github.com/pry/pry) (or equivalent): + +```ruby +# Introspect the scope without firing a query +# Call 'super' to fire the query +def resolve(scope) + binding.pry +end +``` diff --git a/website/versioned_docs/version-2.0/topics/error-handling.md b/website/versioned_docs/version-2.0/topics/error-handling.md new file mode 100644 index 00000000..20e39578 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/error-handling.md @@ -0,0 +1,279 @@ +--- +title: 'Error Handling' +--- + +## Overview {#overview} + +Whenever we have an application error, we want to respond with a +[JSONAPI-compliant errors payload](http://jsonapi.org/format/#errors). +This way clients have a predictable response detailing information about +the error. + +```json +{ + "errors": [ + { + "code": "internal_server_error", + "status": "500", + "title": "Internal Server Error" + } + ] +} +``` + +We'll also need a way to customize this payload. For instance, if a +`NotAuthorized` error is raised, the response should have a `403` status +code. For other errors, we may want to render a helpful error message: + +```ruby +class ApplicationController < ActionController::API + register_exception NotAuthorized, status: 403 + register_exception ShipmentDelayed, + detail: ->(e) { "Contact us at 123-456-7899" } + # ... code ... +end +``` + +Exception handling lives in Graphiti's Rails integration. Customizing the behavior based on error class happens in the [RescueRegistry](https://github.com/wagenet/rescue_registry) dependency. + +### Setup {#setup} + +Include the Rails integration in the controllers serving your resources: + +```ruby +class ApplicationController < ActionController::Base + include Graphiti::Rails::Controller +end +``` + +That registers handlers for Graphiti's own exceptions and renders anything else as JSON:API. `register_exception` itself is available on every controller without it. See below. + +#### Displaying Raw Errors {#displaying-raw-errors} + +When raw errors are on, the same payload carries the underlying exception under `meta.__raw_error__`: + +```json +{ + "errors": [ + { + "code": "internal_server_error", + "status": "500", + "title": "Internal Server Error", + "meta": { + "__raw_error__": { + "message": "EmployeesController::SomeError", + "backtrace": [ + "app/controllers/employees_controller.rb:5:in `index'", + "..." + ] + } + } + } + ] +} +``` + + +It can be useful to display the raw error as part of the JSON response - +but you probably don't want to expose your stack trace to customers. +Let's only show raw errors for the `staging` environment: + +```ruby +class ApplicationController < ActionController::API + # ... code ... + + def show_detailed_exceptions? + Rails.env.staging? + end +end +``` + +Another common pattern is to only show raw errors when the user is +privileged to see them: + +```ruby +class ApplicationController < ActionController::API + # ... code ... + + def show_detailed_exceptions? + current_user.admin? + end +end + +``` + +When `#show_detailed_exceptions?` returns `true`, you'll get the raw error class, +message, and backtrace in the JSON response. + +## Usage {#usage} + +### Basic {#basic} + +Let's register an error with a custom response code: + +```ruby +register_exception Errors::NotAuthorized, status: 403 +``` + +Now if we `raise Errors::NotAuthorized`, the response code will be `403`. + +Additional options: + +```ruby +register_exception Errors::NotAuthorized, + status: 403, + title: "You cannot perform this action", + detail: :exception, # render the raw error message + detail: ->(error) { "Invalid Action" } # message via proc +``` + +[See full documentation in the RescueRegistry README](https://github.com/wagenet/rescue_registry). + +All controllers will inherit any registered exceptions from their parent. They can also add their own. In this example, `FooError` will only throw a custom status code when thrown from `FooController`: + +```ruby +class FooController < ApplicationController + register_exception FooError, status: 422 +end +``` + +### Replacing Graphiti's own registrations {#replacing} + +Registering one of Graphiti's own errors again replaces its entry, and the last call wins. Keep the include above your own: + +```ruby +class ApiController < ActionController::API + include Graphiti::Rails::Controller + + register_exception Graphiti::Errors::UnsupportedPageSize, status: 422 +end +``` + +### Titles and details {#copy} + +Title and detail come from a locale key named after the error code: + +```yaml +en: + graphiti: + errors: + internal_server_error: + title: "Something went wrong" + detail: "We've probably received an error report already, but please contact us if the issue persists." + not_found: + title: "Not found" +``` + +`register_exception`'s own `title:` or `detail:` wins, and Graphiti registers its own client errors with `detail: :exception` so each reports the specific problem. With no key, the title is the HTTP status name and there is no detail. + +Validation messages are keyed the same way, by the code the payload reports in `meta.code`: + +```yaml +en: + graphiti: + errors: + format: "%{attribute} %{message}" + messages: + missing: "is missing" + invalid: "must be an object" + invalid_relationship: "is not a valid relationship" + unwritable_relationship: "cannot be written" + unknown_attribute: "is an unknown attribute" + unwritable_attribute: "cannot be written" + type_error: "should be type %{type}" + attribute_mismatch: "does not match the server endpoint" +``` + +`rails g graphiti:locale` writes that file, and `graphiti:install` calls it for you. + +A message goes into `meta.message` bare, and `format` joins it to the attribute for `detail`. Where that word order does not suit, a message can name its own `%{attribute}`. Translations hold up inside concurrent sideloads, since `I18n.locale` travels to the pool threads. + +### Error reporting {#error-reporting} + +Graphiti's client errors are in Rails' `rescue_responses`, so a 400 or 404 renders without being reported to `Rails.error` as an unhandled failure. Rails still logs them, and everything else is reported as before. + +This only changes what `Rails.error` hears about. An error tracker with its own middleware still catches everything, so you filter there too. + +Exceptions you register yourself are not in there, so a 403 of your own still counts as a failure. Name it the same way Rails names its own: + +```ruby +# config/application.rb +config.action_dispatch.rescue_responses["MyApp::Forbidden"] = :forbidden +``` + +To go the other way and hear about one of Graphiti's, drop it in an initializer, which runs after the railtie that installs them: + +```ruby +# config/initializers/graphiti.rb +ActionDispatch::ExceptionWrapper.rescue_responses.delete("Graphiti::Errors::RecordNotFound") +``` + +### Advanced {#advanced} + +The final option `register_exception` accepts is `handler`. Here you can inject your own error handling class that customize `RescueRegistry::ExceptionHandler`. For example: + +```ruby +class MyCustomHandler < Graphiti::Rails::ExceptionHandler + # self.exception accessible within all instance methods + + def status_code + # ...customize... + end + + def error_code + # ...customize... + end + + def title + # ...customize... + end + + def detail + # ...customize... + end + + def meta + # ...customize... + end +end + +register_exception FooError, handler: MyCustomHandler +``` + +If you would like to use the same custom handler for all errors, override `default_exception_handler`: + +```ruby +# app/controllers/application_controller.rb +def self.default_exception_handler + MyCustomHandler +end +``` + +## Testing {#testing} + +This pattern of globally rescuing exceptions makes sense when +running our live application...but during testing, we may want to +raise real errors and bypass this rescue logic. + +This is why we turn off error-handling during tests by default: + +```ruby +# spec/rails_helper.rb +RSpec.configure do |config| + config.include Graphiti::Rails::TestHelpers + # ... code ... + + config.before :each do + handle_request_exceptions(false) + end +end +``` + +If you want to turn this on for an individual test (so you can test +error codes, etc): + +```ruby +before do + handle_request_exceptions(true) +end +``` diff --git a/website/versioned_docs/version-2.0/topics/etags.md b/website/versioned_docs/version-2.0/topics/etags.md new file mode 100644 index 00000000..e64ce556 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/etags.md @@ -0,0 +1,46 @@ +--- +title: 'ETags' +--- + +# ETags + +Every resource proxy can compute a weak ETag for its current result set via `proxy.etag`. It's a plain string. Graphiti doesn't wire up `If-None-Match` handling or send `304 Not Modified` responses itself, so using it for HTTP conditional requests is up to your controller (for example, with Rails' own `fresh_when`/`stale?`). + +## How it's computed + +`etag` is a weak ETag built from the same cache key used for [caching](/topics/caching), but the *versioned* one: + +```ruby +def etag + "W/#{ActiveSupport::Digest.hexdigest(cache_key_with_version.to_s)}" +end +``` + +`cache_key_with_version` combines the scope's versioned cache key (which folds in every sideloaded proxy's versioned cache key and the underlying object's own `cache_key_with_version`), the query's cache key, and the resource cache tag if one is configured. Those are the same three ingredients described in the caching doc, except the scope portion here is version-aware rather than the plain identity-only key. In practice this means the ETag changes whenever the resolved records' `updated_at` values change, or whenever the rendering-relevant query params (fields, extra_fields, links, pagination_links, format) change. + +Because it's derived purely from `cache_key_with_version`, calling `etag` twice on equivalent proxies (same resource, scope, and query) produces the same weak ETag, and it's always prefixed with `W/`. + +## Using it + +Since there's no built-in controller integration, you compute and use it explicitly: + +```ruby +def index + employees = EmployeeResource.all(params) + response.headers["ETag"] = employees.etag + render jsonapi: employees +end +``` + +Or combine it with Rails' conditional-GET support if you want automatic `304` handling: + +```ruby +def index + employees = EmployeeResource.all(params) + fresh_when(etag: employees.etag) +end +``` + +## Relationship to resource-level caching + +`etag` doesn't require `cache_resource` or `Graphiti.config.cache_rendering = true`. It's available on any resource proxy regardless of whether that resource participates in rendering caching. It does, however, share its key ingredients with the cache-rendering machinery: the same `cache_key_with_version` that ETags are hashed from is also what `Graphiti::Util::CacheDebug` tracks (as `current_version[:etag]` / `last_version[:etag]`) when the [debugger](/topics/debugging) reports on cache-key changes for a cached resource. So if you're seeing an ETag change unexpectedly, the debugger's cache section (enabled the same way as for [caching](/topics/caching)) will show you which cache-key segment changed. diff --git a/website/versioned_docs/version-2.0/topics/hopping-relationships.md b/website/versioned_docs/version-2.0/topics/hopping-relationships.md new file mode 100644 index 00000000..f918bd2c --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/hopping-relationships.md @@ -0,0 +1,149 @@ +--- +title: 'Hopping Relationships' +--- + +# Hopping Relationships +> [See the code](https://github.com/graphiti-api/employee_directory/commit/b187127d60ea67ef4c2a326721caeaad21ed7ec9) + +Our [sample application](https://github.com/graphiti-api/employee_directory) +has the setup `Employee > Position > Department`, where one of the positions is the `current_position`. What if we wanted to change this to `Employee > Department`, hiding everything about positions +under-the-hood? + +Let's start by saying an `Employee` has many `Department`s. Here's the +spec: + +```ruby +describe 'sideloading' do + describe 'departments' do + let!(:employee) { create(:employee) } + let!(:position1) do + create :position, + historical_index: 2, + employee: employee, + department: department1 + end + let!(:position2) do + create :position, + historical_index: 1, + employee: employee, + department: department2 + end + let!(:department1) { create(:department) } + let!(:department2) { create(:department) } + + before do + params[:include] = 'departments' + end + + it 'finds the departments for all positions' do + render + sl = d[0].sideload(:departments) + expect(sl.map(&:id)).to eq([department1.id, department2.id]) + expect(sl.map(&:jsonapi_type).uniq).to eq(['departments']) + end + end +end +``` + +Start by defining the association: + +```ruby +has_many :departments +``` + +And you'll get this error: + +```error +Graphiti::Errors::AttributeError: + DepartmentResource: Tried to filter on attribute :employee_id, but could not find an attribute with that name. +``` + +Which makes sense - if this is a `has_many` association, we'd expect DepartmentResource to filter by `employee_id`. Though in our case we +don't have that as a foreign key, we can still implement the +`employee_id` filter: + +```ruby +filter :employee_id, :integer, only: [:eq] do + eq do |scope, value| + scope.joins(:positions).merge(Position.where(employee_id: value)) + end +end +``` + +In order to find `Department`s by an `employee_id`, we need to join the `positions` table which has the `employee_id` column. + +We now get this error: + +```error +NoMethodError: + undefined method `employee_id' for # +``` + +Let's say our URL is `/employees?include=departments`. We've fetched all the `Employee`s and all the `Department`s, now we need to associate each `Department` with its relevant `Employee`. Normally we'd do that by looking at the `employee_id` foreign key on `Department`, but this +scenario has non-standard logic. Let's tell Graphiti how to select +relevant `Department`s for a given `Employee`: + +```ruby +has_many :departments do + assign_each do |employee, departments| + departments.select do |d| + employee_ids = d.positions.map(&:employee_id).flatten + employee.id.in?(employee_ids) + end + end +end +``` + +There's one final step - because we're assigning a department to an +employee, we have to make sure that accessor exists: + +```ruby +# app/models/employee.rb +attr_accessor :department +``` + + +And that's it! Our test now passes. + +There's a little bit of sleight-of-hand above though. Our filter joins +to the `positions` table, and our assignment iterates over departments and calls `department.positions`. **If we don't eager load, we'll cause +an N+!**! + +There are two solutions to this. The first is to simple change `.joins` to `.eager_load`: + +```ruby +scope.eager_load(:positions).merge(Position.where(employee_id: value)) +``` + +This ensures that not only are we joining on the `positions` table, we'll eagler load the `positions` *relationship* and avoid the N+1. + +If you're a stickler, though, you may have a nitpick. For one, if +we're hitting `/departments?filter[employee_id]` directly there is no need to eager load `positions` because we're never associating to an `Employee`. We're paying a performance penalty when we don't have to. + +OK, let's keep our filter `.joins`. We just have to tell Graphiti to switch it to `.eager_load` when sideloading through `EmployeeResource`: + +```ruby +has_many :departments do + # ... code ... + + pre_load do |proxy, employees| + proxy.scope.object = proxy.scope.object.eager_load(:positions) + end +end +``` + +The `pre_load` hook fires after we've built up the scope, but before we resolve it (before actually firing the query). It yields a `proxy` +object that we can modify - here we're modifying the scope to eager load +positions. + +It's up to you if you care about this scenario - you may want to start +with `.eager_load` and only embrace to the extra work of `pre_load` when +you really need it. + +The trick to these customizations is to think in Links. Resources +connect to each other with URLs - what would the query parameters of the +URL be? In this case, `filter?[employee_id]=123`. After that, we just +have to define how to associate relevant objects. Even with complex +associations hopping several levels, the same logic applies. + +See the final code [here](https://github.com/graphiti-api/employee_directory/commit/b187127d60ea67ef4c2a326721caeaad21ed7ec9). diff --git a/website/versioned_docs/version-2.0/topics/json-attributes.md b/website/versioned_docs/version-2.0/topics/json-attributes.md new file mode 100644 index 00000000..2e9501a9 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/json-attributes.md @@ -0,0 +1,77 @@ +--- +title: 'JSON Attributes' +--- + +# JSON Attributes + +Graphiti has two built-in types for structured data: `hash` and `array`. Both are useful for serving JSON/JSONB columns (or any nested data) through a Resource, without you writing custom typecasting. + +For the full type table, see [Types](/concepts/resources#types). This page covers `hash` and `array` specifically. + +## Declaring the attribute {#declaring} + +```ruby +class PostResource < ApplicationResource + attribute :metadata, :hash + attribute :tags, :array +end +``` + +Like any attribute, this is readable, writable, sortable and filterable by default. If your model reads a `metadata` JSONB column and returns a Ruby `Hash`, `attribute :metadata, :hash` will render it as-is. + +## Coercion rules {#coercion} + +Each type is a [Dry::Types](https://dry-rb.org/gems/dry-types) triple of `params` (used for filtering/sorting from query strings), `read`, and `write`. Per `lib/graphiti/types.rb`: + +* `hash` - `read` and `write` are `Dry::Types["strict.hash"]`. Nothing is coerced beyond requiring a real `Hash`. `params` is a custom type that runs `JSON.parse(input) if input.is_a?(String)` before validating with `Dry::Types["params.hash"]`, so a JSON string arriving in a query param gets parsed automatically. +* `array` - `read`, `write`, and `params` are all `Dry::Types["strict.array"]`. There is no `.of(...)` constraint, so elements are not individually coerced. Any array (including an array of hashes) passes through as-is. + +Both types have `kind: "record"` (`hash`) or `kind: "array"` (`array`) rather than `"scalar"`. One consequence: unlike every other base type (`integer`, `string`, `date`, etc.), `hash` and `array` do **not** get an `array_of_*` doppelgänger generated (`lib/graphiti/types.rb` explicitly excludes `:boolean`, `:hash`, and `:array` when building `array_of_*` variants). If you need an array of hashes, just use `attribute :things, :array` - there's no `array_of_hashes` type. + +On coercion failure - reading, writing, or filtering - Graphiti raises `Graphiti::Errors::TypecastFailed` with the attribute name, the offending value, and the underlying error. + +## Filtering on a hash attribute {#filtering} + +Declaring `attribute :metadata, :hash` makes it filterable with the `eq` operator by default (the `hash` type only supports `eq` out of the box, per the default operator map). A request like: + +``` +GET /posts?filter[metadata]={"status":"draft"} +``` + +parses the JSON string into a Ruby `Hash` before your filter block runs: + +```ruby +filter :metadata, :hash do + eq do |scope, value| + # value => [{ "status" => "draft" }] + scope + end +end +``` + +Note the value is wrapped in an array - Graphiti's filter pipeline supports passing multiple comma-separated JSON objects (`filter[metadata]={"a":1},{"b":2}`), so `eq` always receives an array of hashes unless you opt out. + +Pass `single: true` to receive the hash directly instead of an array-wrapped one, and to skip the comma-splitting behavior entirely (useful once your hash values might legitimately contain commas): + +```ruby +filter :metadata, :hash, single: true do + eq do |scope, value| + # value => { "status" => "draft" } + scope + end +end +``` + +A Ruby `Hash` (rather than a JSON string) passed directly as a filter param works the same way. It's validated rather than parsed. + +Array attributes filter similarly: `filter[tags]=ruby,rails` splits on commas into `["ruby", "rails"]`. Wrap a value in `{{curlies}}` to prevent comma-splitting (see [Escaping Values](/concepts/resources#escaping-values)). + +## Writing to a JSON column {#writing} + +There's nothing Graphiti-specific to do here. On a write request, Graphiti coerces the incoming JSON attribute through the `write` type (`strict.hash` or `strict.array` - just a presence/type check) and assigns it to your model via `attributes[:metadata] = value`. Persisting that Ruby `Hash`/`Array` into an actual `jsonb`/`json` column is entirely up to your ORM (ActiveRecord serializes it automatically for `jsonb`/`json` columns) - Graphiti does not serialize to a JSON string itself, so don't do that in your own code either or you'll end up double-encoded. + +## Caveats {#caveats} + +* `hash` and `array` only support the `eq` filter operator by default - there's no built-in `gt`/`lt`/`prefix` for structured data. Add custom operators yourself if you need them. +* Non-`single` hash filters always hand your `eq` block an array, even for a single JSON object - a common source of confusion is forgetting the `value[0]` unwrap. +* There's no schema validation built in - `strict.hash`/`strict.array` just confirm you got a `Hash`/`Array`, not that its keys match anything in particular. For a shape check, register a [custom type](/concepts/resources#custom-types) with `Dry::Types["hash"].schema(...)`. diff --git a/website/versioned_docs/version-2.0/topics/openstruct-models.md b/website/versioned_docs/version-2.0/topics/openstruct-models.md new file mode 100644 index 00000000..ca442ba4 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/openstruct-models.md @@ -0,0 +1,50 @@ +--- +title: 'OpenStruct Models' +--- + +# OpenStruct Models + +[Model Requirements](/concepts/backends-and-models#model-requirements) covers what any Model needs to respond to, and [Usage Without ActiveRecord](/topics/without-activerecord) walks through building a Resource around a PORO. `OpenStruct` satisfies those requirements with zero boilerplate - no `attr_accessor` list, no constructor - which is exactly why Graphiti uses it internally for [remote resources](/topics/remote-resources): `Resource::Remote` and the default `Sideload` model both set `self.model = OpenStruct` (`lib/graphiti/resource/remote.rb`, `lib/graphiti/sideload.rb`), since a remote resource doesn't know its shape ahead of time. That convenience comes with sharp edges if you reach for `OpenStruct` as a model in your own Resources. + +## What Graphiti expects from it {#expectations} + +Reads go through `@object.send(attribute_name)` (`lib/graphiti/util/serializer_attributes.rb`), and writes go through `model.send(:"#{key}=", value)`-style assignment. `OpenStruct` handles both via `method_missing`, so any attribute you construct it with - or assign later - just works, same as a PORO with `attr_accessor`. + +## The gotcha: typos and reserved methods return silently, they don't raise {#gotcha} + +An `attr_accessor`-based PORO raises `NoMethodError` the moment you call an undefined reader. `OpenStruct` does not - if the attribute was never set, `#send` on it just returns `nil`: + +```ruby +require "ostruct" +o = OpenStruct.new(name: "a") +o.send(:naem) # => nil, not NoMethodError +``` + +Since attribute reads happen inside `@object.send(name_ref)`, a typo'd attribute name (in your `attribute` declaration, or a rename you forgot to propagate) will silently serialize as `null` instead of blowing up in your test suite. With a real PORO the same typo raises immediately and is easy to catch. + +Worse, `OpenStruct` only overrides *undefined* methods - if the attribute name collides with something `Object`/`Kernel` already defines, the field is silently swallowed and you get the *original* method's return value instead of your data: + +```ruby +o = OpenStruct.new(hash: 123, count: 5) +o.hash # => some large integer (Object#hash), NOT 123 +o.count # => 5, fine - `count` isn't a reserved method +``` + +`id`, `class`, `object_id`, `hash`, `send`, `freeze`, and `to_s` are all real methods on every Ruby object. Naming an attribute after one of them (a `hash` field to store a checksum is a realistic trap given Graphiti's own `:hash` type) won't error - it'll quietly return the wrong value. `id` itself is safe (`Object#id` was removed from modern Ruby in favor of `#object_id`), but don't assume the rest are. + +## Validations {#validations} + +`OpenStruct` doesn't include `ActiveModel::Validations`, and the [Null adapter's `#save`](/concepts/backends-and-models#model-requirements) only calls `model.valid?` if the model `respond_to?(:valid?)` - so an unvalidated `OpenStruct` model will save "successfully" with no errors payload, not raise. If you want write-request validation, subclass it: + +```ruby +class Employee < OpenStruct + include ActiveModel::Validations + validates :first_name, presence: true +end +``` + +This works exactly as it would on any other class - `OpenStruct` doesn't get in the way of `include`. + +## When it's the right call {#when} + +`OpenStruct` is a reasonable choice for throwaway resources, prototypes, and cases like remote resources where the attribute set is genuinely dynamic. For a Resource you're going to maintain, prefer a real PORO, `ActiveModel::Model`, or `Dry::Struct` (all shown in [Model Implementations](/concepts/backends-and-models#model-implementations)) - you get the same zero-ORM flexibility with a class that fails loudly on a mistake instead of quietly serializing `nil`. diff --git a/website/versioned_docs/version-2.0/topics/remote-resources.md b/website/versioned_docs/version-2.0/topics/remote-resources.md new file mode 100644 index 00000000..d624ec30 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/remote-resources.md @@ -0,0 +1,291 @@ +--- +title: 'Remote Resources' +--- + +## Overview {#overview} + +Resources have a defined query contract, and connect together with [Links](/concepts/links). That contract doesn't care whether the sideloaded Resource lives in the same application, so we can point it at a separate service instead: + +```ruby +has_many :comments, + remote: 'http://blog-api.com/api/v1/comments' +``` + +Splitting an application into services tends to break down at the boundary between them: no consistent query interface, no consistent error handling, no types or backwards-compatibility checks. Graphiti was built to address exactly this - a defined query contract, an errors payload, and a schema with types and backwards-compatibility checks, all organized into RESTful Resources - so cross-service communication is automated rather than hand-rolled per integration. + +> Note: Remote Resources are for **read** operations only. The exception +> is associating to an existing `belongs_to` remote entity. + +> Note: We use [Faraday](https://github.com/lostisland/faraday) to hit +> the remote API. You must add `faraday` to your Gemfile to enable +> remote resources. + +### How it Works {#how-it-works} + +Let's take a simple association: + +```ruby +class PostResource < ApplicationResource + has_many :comments +end +``` + +This would generate a [Link](/concepts/links) for +lazy-loading comments: + +```ruby +{ + related: "http://my-api.com/api/v1/comments?filter[post_id]=123" +} +``` + +Critically, **those same lazy-loading parameters are used when +eager-loading**: + +```ruby +# under the hood +posts = PostResource.all.data +CommentResource.all(filter: { post_id: 123 }) +``` + +OK, and we also know Resources support [any backend](/concepts/backends-and-models), and we can build an [Adapter](/topics/without-activerecord#adapters) if our backend supports common operations like filtering, sorting, and pagination. + +So, that means we can build an Adapter that makes an HTTP request to another Graphiti Resource that lives in a separate API. That adapter is built into Graphiti and comes out-of-the-box: `Graphiti::Adapters::GraphitiAPI` + +```ruby +class CommentResource < ApplicationResource + self.remote = "http://my-api.com/api/v1/comments" + # under-the-hood, this sets: + # self.adapter = Graphiti::Adapters::GraphitiAPI +end +``` + +This Resource works as normal. We can execute queries: + +```ruby +comments = CommentResource.all({ + sort: '-id', + filter: { active: true } +}) + +# The model instances are OpenStructs +comments.data # => [#, #, ...] + +# Those models reflect all the properties returned from the API: +comments.data.map(&:author) # => ["Jane Doe", "John Doe", ...] +``` + +And we can sideload just like we always do: + +```ruby +class PostResource < ApplicationResource + # Nothing to see here! + has_many :comments +end +``` + +We'll still support Deep Querying - let's fetch the Post and its +active comments, ordered by `created_at`: + +`/posts?include=comments&sort=comments.created_at&filter[active]=true` + +Let's say `CommentResource` has an association to `Author`. If `AuthorResource` is defined in the remote API, we can fetch it as normal - no special configuration needed to fetch the `Post`, `Comment`s and `Author`s in a single request. + +But maybe only `CommentResource` is remote, and `Authors` are local. +We need only define the association locally: + +```ruby +class CommentResource < ApplicationResource + self.remote = "http://my-api.com/api/v1/comments" + + belongs_to :author +end +``` + +Let's say we need to tweak the display of a property coming from the +remote API. Again, works just like normal: + +```ruby +class CommentResource < ApplicationResource + self.remote = "http://my-api.com/api/v1/comments" + + attribute :body, :string do + @object.body.truncate(100) + end +end +``` + +You only need to define attributes when overriding this logic - +otherwise we'll take them directly from the API response. This means you +don't have to update two repos and coordinate deploys - as soon as you +add a property to the remote API and deploy it, it will be reflected in +the local API response. + +For the typical use case, we don't even *need* to create this Resource +class. The sideload definition accepts a `remote:` option, which will +create a Remote Resource under-the-hood: + +```ruby +class PostResource < ApplicationResource + has_many :comments, remote: 'http://my-api.com/api/v1/comments' +end + +# Equivalent to: +# +# class PostResource < ApplicationResource +# has_many :comments +# end +# +# class CommentResource < ApplicationResource +# self.remote = 'http://my-api.com/api/v1/comments' +# end +``` + +> NOTE: When sending a request to a remote API, we request page size +> `999` so results don't get accidentally cut off. If you need +> successive requests, please [submit an issue](https://github.com/graphiti-api/graphiti/issues). + +### Customizing {#customizing} + +We use [Faraday](https://github.com/lostisland/faraday) under-the-hood, +which allows for various adapters and middleware. In addition: + +#### Configure Timeout {#configure-timeout} + +```ruby +class CommentResource < ApplicationResource + self.remote = "..." + + # Customize faraday timeout + self.timeout = 10 + self.open_timeout = 20 +end +``` + +#### Configure Request {#configure-request} + +```ruby +class CommentResource < ApplicationResource + self.remote = "..." + + def make_request(url) + # request here is from Faraday: + # + # conn.get do |req| + # yield req + # end + # + super do |request| + request.headers["Custom"] = "Header" + end + end +end +``` + +#### Configure Headers {#configure-headers} + +By default we're going to *forward* the `Authorization` header of the request to the remote API. To override the default headers sent: + +```ruby +# app/resources/comment_resource.rb +def request_headers + { "Some-Foo" => "bar" } +end +``` + +### Error Handling {#error-handling} + +If the remote API has an error, we want to re-raise that same error. But +unless you've enabled [displaying raw errors](/topics/error-handling#displaying-raw-errors), we won't be able to - the only information we have is what's returned from the API. + +You're encouraged to display raw errors when an internal or privileged +user: + +```ruby +rescue_from Exception do |e| + handle_exception(e, show_raw_error: current_user.developer?) +end +``` + +If you do this, we'll be able to re-raise the original error, including +stacktrace. If raw errors are not enabled, we'll raise whatever +information is given. + +Both styles will be wrapped in `Graphiti::Errors::Remote`, so you can +differentiate between a local error and a remote one. + +## Testing {#testing} + +When testing a remote resource, we need to mock the API request and +response. Graphiti gives you a spec helper to do just that - +`include_context "remote api"`: + +```ruby +describe 'comments' do + include_context 'remote api' + + let(:api_response) do + { + data: [{ + id: '1', + type: 'comments', + attributes: { body: 'hello' } + }] + } + end + + it 'does something' do + url = 'http://my-api.com/api/v1/comments?page[size]=999' + mock_api(url, api_response) + # ... test ... + end +end +``` + +This shows all the pieces needed to test remote APIs. We want to test + +* The correct URL is hit +* When given a valid response, the rest of the flow works as expected. + +> NOTE: if the remote relationship is a has_many, the API will need to +> return the foreign key as part of the response. Otherwise, we won't +> know how to associate these children to their parents. + +Here's a slightly longer version, showing that `Post` can sideload `Comment`s: + +```ruby +describe 'sideloading' do + describe 'comments' do + include_context 'remote api' + + let!(:post) { create(:post) } + + let(:api_response) do + { + data: [{ + id: '789', + type: 'comments', + attributes: { body: 'hello' } + }] + } + end + + before do + params[:include] = 'comments' + end + + it 'does something' do + url = "http://my-api.com/api/v1/comments" + url += "?filter[post_id]=#{post_id}" + mock_api(url, api_response) + render + sl = d[0].sideload(:comments) + expect(sl.map(&:id)).to eq(['789']) + expect(sl.map(&:jsonapi_type).uniq) + .to eq(['comments']) + end + end +end +``` + +> Make sure to include `page[size]=999` in the test URL! diff --git a/website/versioned_docs/version-2.0/topics/testing.md b/website/versioned_docs/version-2.0/topics/testing.md new file mode 100644 index 00000000..426f7699 --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/testing.md @@ -0,0 +1,931 @@ +--- +title: 'Testing' +--- + +## Overview {#overview} + +Test first. + +Wait, hear me out! + +[Even if you're not a fan of TDD](http://david.heinemeierhansson.com/2014/tdd-is-dead-long-live-testing.html), Graphiti *integration* tests are the easiest, most pleasant way to develop. In fact, most Graphiti development can happen without even opening a browser. And as a side effect, you get a reliable test suite. + +Let's say we want to filter Employees by `title`, which comes from the `positions` table. Start with a spec: + +```ruby +RSpec.describe EmployeeResource, type: :resource do + describe 'filtering' do + context 'by title' do + # GIVEN some seed data + let!(:employee1) { create(:employee) } + let!(:employee2) { create(:employee) } + let!(:position1) do + create :position, + title: 'foo', + employee: employee1 + end + let!(:position2) do + create :position, + title: 'bar', + employee: employee2 + end + + # WHEN a parameter is set + before do + params[:filter] = { title: 'bar' } + end + + # THEN the query results will be correct + it 'works' do + expect(records.map(&:id)).to eq([employee2.id]) + end + end + end +end +``` + +By developing test-first: + +* We don't need to struggle with seeding local development data or finding the right records for specific scenarios - we can seed randomized data on-the-fly with [factories](https://github.com/thoughtbot/factory_bot). +* There's no need to spin up a server and refresh browser pages, mentally parsing the response payload. +* We get a high-confidence test "for free". +* Because our integration test is separate from implementation, we don't need to worry about [test-induced design damage](http://david.heinemeierhansson.com/2014/test-induced-design-damage.html). + +### API vs Resource {#api-vs-resource} + +There are two types of Graphiti tests: **API tests** and **Resource tests**. + +This is because the same Resource logic can be re-used at multiple endpoints. PostResource can be referenced at `/posts`, `/top_posts`, and `/admin/posts`, but we shouldn't have to test the same filtering and sorting logic over and over. Querying, persistence, and serialization are all Resource responsibilities, tested in Resource tests. + +We still want API tests, though, to test everything outside of the Resource: routing, middleware, cache rules, response codes, etc… + +Typically, you'll write the API test **once** and not have to touch it again. + +### Factories {#factories} + +> Note: Factories are not **required**, but they are considered a best practice used by the Graphiti test generator. Read thoughtbot's [Why Factories?](https://robots.thoughtbot.com/why-factories) for more information. + +We need to seed data into our test database. To do this, we use [Factory Bot](https://github.com/thoughtbot/factory_bot) and [Faker](https://github.com/stympy/faker). + +When you generate a model, a stub factory will be created. It is highly recommended you edit that factory with randomized data: + +```ruby +# BEFORE +FactoryBot.define do + factory :employee do + first_name { 'MyString' } + end +end + +# AFTER +FactoryBot.define do + factory :employee do + first_name { Faker::Name.first_name } + end +end +``` + +This will help catch edge cases and provide more clarity than seeing the same `"MyString"` everywhere. + +It's a best practice that if a factory defines an attribute, there should be a corresponding validation around that attribute. If an attribute is optional, it should not be defaulted in a factory. + +Finally, Rails requires `belongs_to` associations by default. This means that if Employee `belongs_to :department`, then `create(:employee)` will fail. To ensure a relationship is always seeded: + +```ruby +FactoryBot.define do + factory :employee do + department + # OR association :department, factory: :department + end +end + +``` + +### RSpec Setup {#rspec} + +RSpec is not **required**, but considered a first-class citizen used by the Graphiti test generator. + +Add the following to your Gemfile: + +```ruby +# Gemfile +group :development, :test do + gem 'factory_bot_rails' + gem 'rspec_rails' + gem 'faker' +end + +group :test do + gem 'database_cleaner' +end +``` + +Bootstrap RSpec if you haven't already: + +```bash +$ bin/rails g rspec:install +``` + +Then wire up Graphiti's helpers and reset your database between examples: + +```ruby +require 'graphiti/spec_helpers/rspec' + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods + config.include Graphiti::SpecHelpers::RSpec + config.include Graphiti::Rails::TestHelpers, type: :request + + # Clean your DB between test runs + config.before(:suite) do + DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) + end + + config.around(:each) do |example| + begin + DatabaseCleaner.cleaning do + example.run + end + ensure + DatabaseCleaner.clean + end + end +end +``` + +## Test Helpers {#test-helpers} + +Tests are run using [JSONAPI standards](http://jsonapi.org/format/#fetching-includes). But the JSONAPI payload can be a pain to deal with. So, we've supplied helpers. + +These helpers ship with Graphiti, under `Graphiti::SpecHelpers`. + +### #jsonapi_data {#jsonapi-data} + +The `jsonapi_data` method will parse response data and return a normalized object (`Graphiti::SpecHelpers::Node`). Assert against this the same way you assert against JSON: + +```ruby +data = jsonapi_data[0] +expect(data.id).to eq(employee.id) +expect(data.jsonapi_type).to eq('employees') +expect(data.first_name).to eq('Jane') +``` + +* `id` will automatically case to an integer. If you would like to avoid this, use `rawid` instead. +* `jsonapi_type` is a convenience method for `data/type`, to avoid conflicting with an attribute of the same name. +* If the `first_name` key was not present in the response, an error will be raised. + +#### Accessing Sideloads {#accessing-sideloads} + +To grab a relationship: + +```ruby +sideload = jsonapi_data[0].sideload(:comments) +expect(sideload.id).to eq(123) +expect(sideload.jsonapi_type).to eq('comments') +expect(sideload.body).to eq('body') +``` + +The `sideload` method accepts the *name of the relationship*. It returns a normal `jsonapi_data` `Graphiti::SpecHelpers::Node` containing the `include`-ed data. + +#### Accessing Links {#accessing-links} + +To grab a Link: + +```ruby +jsonapi_data[0].link(:comments, :related) +``` + +This accepts the relationship name and the link type. It will return the link URL. + +### #json {#json} + +To see the raw JSON response, use `json`. + +### #json_date and #json_datetime {#date-and-datetime} + +In Graphiti, datetimes are rendered in [ISO 8601 format](https://www.iso.org/iso-8601-date-and-time-format.html). This means that straight date comparisons will fail: + +```ruby +# WRONG +expect(jsonapi_data[0].created_at).to eq(post.created_at) +``` + +Instead, use the `json_datetime` helper to convert to ISO 8601 and compare apples to apples: + +```ruby +# RIGHT +expect(jsonapi_data[0].created_at).to eq(json_datetime(post.created_at)) +``` + +Similarly, there's a `json_date` helper as well. + +### #jsonapi_errors {#jsonapi-errors} + +To parse an [Errors Payload](http://jsonapi.org/format/#errors): + +```ruby +errors = jsonapi_errors + +# Direct access +expect(errors.length).to eq(1) +expect(errors[0].attribute).to eq(:name) +expect(errors[0].status).to eq('422') +expect(errors[0].title).to eq('Validation Error') +expect(errors[0].detail).to eq("Name can't be blank") +expect(errors[0].code).to eq(:blank) +expect(errors[0].message).to eq("can't be blank") + +# By attribute +expect(errors.name.message).to eq("can't be blank") +expect(errors.name.code).to eq(:blank) +# ... etc ... + +# As a hash +expect(errors.to_h).to eq({ + name: "can't be blank" +}) +``` + +### Resource Test Helpers {#resource-test-helpers} + +Resource tests have two helpers, both different ways to execute a query. + +`render` will fire the query and return a JSON response that can be accessed as normal: + +```ruby +it 'works' do + render + expect(jsonapi_data[0].first_name).to eq('Jane') + json # => { data: { type: 'employees', ... } } +end +``` + +`records` will return model instances: + +```ruby +it 'works' do + render + expect(records.map(&:id)).to eq([1, 2, 3]) +end +``` + +### Resource Matchers {#resource-matchers} + +For one-line assertions about a Resource's shape, use the built-in matchers. They're included automatically in `type: :resource` specs and expect a Resource instance as the subject: + +```ruby +RSpec.describe PostResource, type: :resource do + subject { described_class.new } + + it { is_expected.to belong_to_resource(:author) } + it { is_expected.to have_many_resources(:comments) } + it { is_expected.to have_one_resource(:detail) } + it { is_expected.to expose_attribute(:title, :string) } + it { is_expected.to filter_attribute(:title, :string) } +end +``` + +Each matcher accepts `with_options` to assert configuration: + +```ruby +it do + is_expected.to belong_to_resource(:author) + .with_options(foreign_key: :author_id, resource: AuthorResource) +end + +it { is_expected.to expose_attribute(:title, :string).with_options(writable: false) } +``` + +### API Test Helpers {#api-test-helpers} + +When executing an API test request, always use the `jsonapi_` doppelgänger: + +* `jsonapi_get(url, params:)` instead of `get` +* `jsonapi_post(url, payload)` instead of `post` +* `jsonapi_put(url, payload)` instead of `put` +* `jsonapi_patch(url, payload)` instead of `patch` +* `jsonapi_delete(url)` instead of `delete` + +This will set the `CONTENT_TYPE` header to `application/vnd.api+json` and call `to_json` on the payload (when applicable). + +It also allows overriding `jsonapi_headers`. Use this to manipulate headers for a given request: + +```ruby +def jsonapi_headers + super.tap do |headers| + headers['CUSTOM'] = 'foo' + end +end +``` + +### Guard Helpers {#guard-helpers} + +Many teams use [guard](https://github.com/guard/guard) in development to watch their project files and run a smaller set of focused tests as code changes. For those teams leveraging guard and the [guard-rspec plugin](https://github.com/guard/guard-rspec), we offer an additional set of DSL helpers via the [guard-rspec-graphiti plugin](https://github.com/graphiti-api/guard-rspec-graphiti). For more details, check out the [project README](https://github.com/graphiti-api/guard-rspec-graphiti/blob/master/README.md). + +## Resource Tests {#resource-tests} + +There are two test files for each Resource: + +* `spec/resources/post/reads_spec.rb` +* `spec/resources/post/writes_spec.rb` + +### Reads {#reads} + +The basic setup for read operations: + +```ruby +# spec/resources/employee/reads_spec.rb +require 'rails_helper' + +RSpec.describe EmployeeResource, type: :resource do + describe 'serialization' do + # ... code ... + end + + describe 'filtering' do + # ... code ... + end + + describe 'sorting' do + # ... code ... + end + + describe 'sideloading' do + # ... code ... + end +end +``` + +#### Serialization {#serialization} + +```ruby +describe 'serialization' do + let!(:employee) { create(:employee, first_name: 'Jane') } + + it 'works' do + render + data = jsonapi_data[0] + expect(data.id).to eq(employee.id) + expect(data.jsonapi_type).to eq('employees') + expect(data.first_name).to eq('Jane') + end +end +``` + +Best practices: + +* Assert on all attributes, even if there is no logic. This way adding logic will cause a test failure. +* When seeding data, manually assign values. This way you can be assured you aren't accidentally testing `nil == nil` + +If you decide you have a high level of confidence in your factories, you can instead save some keystrokes and assert on randomized data: + +```ruby +expect(data.first_name).to eq(employee.first_name) +``` + +> Note: Our schema validation test will ensure no attributes get removed or change types. + +#### Filtering {#filtering} + +```ruby +describe 'filtering' do + let!(:employee1) { create(:employee) } + let!(:employee2) { create(:employee) } + + context 'by id' do + before do + params[:filter] = { id: { eq: employee2.id } } + end + + it 'works' do + render + expect(jsonapi_data.map(&:id)).to eq([employee2.id]) + end + end +end +``` + +In general, you only need to test filtering when there is custom logic. Our schema validation test will ensure no filters are removed, guarded, changed operators, etc. + +#### Sorting {#sorting} + +```ruby +describe 'sorting' do + describe 'by id' do + let!(:employee1) { create(:employee) } + let!(:employee2) { create(:employee) } + + context 'when ascending' do + before do + params[:sort] = 'id' + end + + it 'works' do + render + expect(jsonapi_data.map(&:id)).to eq([ + employee1.id, + employee2.id + ]) + end + end + + context 'when descending' do + before do + params[:sort] = '-id' + end + + it 'works' do + render + expect(jsonapi_data.map(&:id)).to eq([ + employee2.id, + employee1.id + ]) + end + end + end +end +``` + +In general, you only need to test sorting when there is custom logic. Our schema validation test will ensure no sorts are removed, guarded or limited in direction. + +#### Sideloading {#sideloading} + +```ruby +describe 'sideloading' do + let!(:employee) { create(:employee) } + + describe 'current_position' do + let!(:pos1) do + create(:position, employee: employee, historical_index: 2) + end + let!(:pos2) do + create(:position, employee: employee, historical_index: 1) + end + + before do + params[:include] = 'current_position' + end + + it 'returns position with historical index == 1' do + render + sl = jsonapi_data[0].sideload(:current_position) + expect(sl.jsonapi_type).to eq('positions') + expect(sl.id).to eq(pos2.id) + end + end +end +``` + +There is no need to test each attribute of the sideload - this should be tested in the [Resource Test](#resource-tests) of the sideloaded Resource. + +In general, you only need to test sideloads when there is custom logic. Our schema validation test will ensure no sideloads are removed or associated to a different Resource. + +### Writes {#writes} + +The basic setup for write operations: + +```ruby +# spec/resources/employee/writes_spec.rb +require 'rails_helper' + +RSpec.describe EmployeeResource, type: :resource do + describe 'creating' do + let(:payload) { ... } + # ... code ... + end + + describe 'creating' do + let(:payload) { ... } + # ... code ... + end + + describe 'destroying' do + # ... code ... + end +end +``` + +Here `payload` is a [JSONAPI Resource Object](http://jsonapi.org/format/#crud). + +#### Create {#create} + +```ruby +describe 'creating' do + let(:payload) do + { + data: { + type: 'employees', + attributes: { } + } + } + end) + + let(:instance) do + EmployeeResource.build(payload) + end + + it 'works' do + expect { + expect(instance.save).to eq(true) + }.to change { Employee.count }.by(1) + end +end +``` + +`payload` starts as an empty Employee [Resource Object](http://jsonapi.org/format/#crud), asserting only that saving it creates an Employee. You'll likely want to add attributes here and ensure they are persisted correctly: + +```ruby +let(:payload) do + { + data: { + type: 'employees', + attributes: { first_name: 'Jane', age: 30 } + } + } +end + +# ... code ... + +it 'works' do + expect { + expect(instance.save).to eq(true) + }.to change { Employee.count }.by(1) + employee = Employee.last + expect(employee.first_name).to eq('Jane') + expect(employee.age).to eq(30) +end +``` + +##### Required Belongs To {#required-belongs-to} + +Rails requires `belongs_to` associations by default. This means that if Employee `belongs_to :department`, the above tests will fail (we cannot create the Employee without associating it to Department). + +You have 3 options here: + +* Turn off this validation in test mode. Add `config.active_record.belongs_to_required_by_default = false` to `config/environments/test.rb`. +* Turn off the validation for this specific relationship: `belongs_to :department, optional: true`. +* Associate as part of the request. + +We recommend the third option to preserve real-world end-to-end behavior: + +```ruby +describe 'creating' do + let!(:department) { create(:department) } + + let(:payload) do + { + type: 'employees', + attributes: { ... }, + relationships: { + department: { + data: { + type: 'departments', + id: department.id.to_s + } + } + } + } + end + + # ... code ... +end +``` + +This ensures the Employee is created and associated to the given department. + +#### Update {#update} + +An update spec looks like the create spec, but finds an existing record instead of building a new one, and asserts the changed attribute rather than a changed count: + +```ruby +describe 'updating' do + let!(:employee) { create(:employee) } + + let(:payload) do + { + data: { + id: employee.id.to_s, + type: 'employees', + attributes: { first_name: 'changed!' } + } + } + end + + let(:instance) do + EmployeeResource.find(payload) + end + + it 'works' do + expect { + expect(instance.update).to eq(true) + }.to change { employee.reload.updated_at } + .and change { employee.first_name }.to('changed!') + end +end +``` + +> Note that this test will be pending by default when using the generator, as we require the attributes to be explicitly defined. + +#### Destroy {#destroy} + +Destroy specs drop the payload/instance-building entirely and just find and destroy the record, asserting the count decreases: + +```ruby +describe 'destroying' do + let!(:employee) { create(:employee) } + + let(:instance) do + EmployeeResource.find(id: employee.id) + end + + it 'works' do + expect { + expect(instance.destroy).to eq(true) + }.to change { Employee.count }.by(-1) + end +end +``` + +#### Side Effects {#side-effects} + +```ruby +it 'works' do + # some assertion + email = ActionMailer::Base.deliveries.last + expect(email.subject).to eq('Welcome!') +end +``` + +It's common for write operations to cause side-effects, such as sending an email or updating an audit trail. It's recommended to test these *within the same "it" block* unless the logic gets particularly intense. Though "one expectation per test" works well for unit tests, integration tests can take longer to run and the performance penalty isn't worth it. + +## API Tests {#api-tests} + +There are five test files for each Resource: + +* `spec/api/v1/employees/index_spec.rb` +* `spec/api/v1/employees/show_spec.rb` +* `spec/api/v1/employees/create_spec.rb` +* `spec/api/v1/employees/update_spec.rb` +* `spec/api/v1/employees/destroy_spec.rb` + +### Reads {#api-reads} + +#### #index {#index} + +```ruby +require 'rails_helper' + +RSpec.describe "employees#index", type: :request do + let(:params) { {} } + + subject(:make_request) do + jsonapi_get "/api/v1/employees", params: params + end + + describe 'basic fetch' do + let!(:employee1) { create(:employee) } + let!(:employee2) { create(:employee) } + + it 'works' do + expect(EmployeeResource).to receive(:all).and_call_original + make_request + expect(response.status).to eq(200) + expect(jsonapi_data.map(&:jsonapi_type).uniq) + .to match_array(['employees']) + expect(jsonapi_data.map(&:id)) + .to match_array([employee1.id, employee2.id]) + end + end +end +``` + +#### #show {#show} + +Same shape as `#index`, but requests a single Employee by id and asserts against the singular `d` node instead of an array: + +```ruby +subject(:make_request) do + jsonapi_get "/api/v1/employees/#{employee.id}", params: params +end + +describe 'basic fetch' do + let!(:employee) { create(:employee) } + + it 'works' do + expect(EmployeeResource).to receive(:find).and_call_original + make_request + expect(response.status).to eq(200) + expect(jsonapi_data.jsonapi_type).to eq('employees') + expect(jsonapi_data.id).to eq(employee.id) + end +end +``` + +### Writes {#api-writes} + +#### #create {#api-create} + +```ruby +require 'rails_helper' + +RSpec.describe "employees#create", type: :request do + subject(:make_request) do + jsonapi_post "/api/v1/employees", payload + end + + describe 'basic create' do + let(:payload) do + { + data: { + type: 'employees', + attributes: { + first_name: 'Jane' + } + } + } + end + + it 'works' do + expect(EmployeeResource).to receive(:build).and_call_original + expect { + make_request + }.to change { Employee.count }.by(1) + expect(response.status).to eq(201) + end + end +end +``` + +You probably only want to add attributes required to pass validation, here. We don't assert on attributes of the created record (save this for your Resource test). One easy way to do this is to pass randomized data from your factory: + +```ruby +let(:payload) do + { + data: { + type: 'employees', + attributes: attributes_for(:employee) + } + } +end +``` + +See also: [Dealing with required belongs_to relationships](#required-belongs-to). + +#### #update {#api-update} + +Same as `#create`, but the payload finds an existing employee by `id` and the assertion checks that the record's attributes changed, rather than the count: + +```ruby +subject(:make_request) do + jsonapi_put "/api/v1/employees/#{employee.id}", payload +end + +describe 'basic update' do + let!(:employee) { create(:employee) } + + let(:payload) do + { + data: { + id: employee.id.to_s, + type: 'employees', + attributes: { + first_name: 'changed!' + } + } + } + end + + it 'updates the resource' do + expect(EmployeeResource).to receive(:find).and_call_original + expect { + make_request + }.to change { employee.reload.attributes } + expect(response.status).to eq(200) + end +end +``` + +We don't assert on specific attributes here - save that for your Resource test. Just like `#create`, you may want to use FactoryBot to generate randomized attributes: + +```ruby +let(:payload) do + { + data: { + id: employee.id.to_s, + type: 'employees', + attributes: attributes_for(:employee) + } + } +end +``` + +#### #destroy {#api-destroy} + +```ruby +subject(:make_request) do + jsonapi_delete "/api/v1/employees/#{employee.id}" +end + +describe 'basic destroy' do + let!(:employee) { create(:employee) } + + it 'updates the resource' do + expect(EmployeeResource).to receive(:find).and_call_original + expect { make_request }.to change { Employee.count }.by(-1) + expect { employee.reload } + .to raise_error(ActiveRecord::RecordNotFound) + expect(response.status).to eq(200) + expect(json).to eq('meta' => {}) + end +end +``` + +The response body is asserted to match the [JSONAPI specification for delete responses](http://jsonapi.org/format/#crud-deleting-responses-200): a 200 status with an empty `meta` object. + +## Context {#context} + +Occasionally you'll need to set context for tests. The most common scenario is authorization: + +```ruby +attribute :salary, :integer, readable: :admin? + +def admin? + context.current_user.admin? +end +``` + +When using Rails, `context` is the controller associated to the request. We can manually set context in tests: + +```ruby +let(:user) { double(admin?: true) } +let(:ctx) { double(current_user: user) } + +it 'works' do + Graphiti.with_context ctx do + render + end + expect(jsonapi_data[0].salary).to eq(100_000) +end +``` + +## Schema Validation {#schema-validation} + +Graphiti comes with built-in backwards-compatibility tests. We do this by comparing the current version of the schema with one previously checked-in. + +These tests are added at the bottom of `spec/rails_helper.rb`: + +```ruby +Graphiti::SpecHelpers::RSpec.schema! +``` + +Whenever you run tests, the schema check will *also* run. If we find any backwards-incompatibilities - attributes removed, types changed, default sort direction modified, etc - the schema test will fail with an output detailing all incompatibilities. + +When the schema test succeeds, it will overwrite the existing schema file with the new schema. It will not do this on failure. + +There are times when you want to accept an incompatibility and move on anyway. In this case, use `FORCE_SCHEMA`: + +```bash +$ FORCE_SCHEMA=true bin/rspec +``` + +The same checks run as rake tasks, for a CI step that does not run the suite: + +```bash +$ bin/rake graphiti:schema:check # fails if the file is missing, outdated, or backwards-incompatible +$ bin/rake graphiti:schema:generate # writes it, refusing incompatible changes unless FORCE_SCHEMA=true +``` + +Both use `Graphiti.config.schema_path`. An engine that keeps its own schema file passes a path instead, resolved against the directory the task runs in: + +```bash +$ bin/rake "graphiti:schema:check[spec/support/schema.json]" +``` + +`schema!` takes the same path as a keyword argument, `Graphiti::SpecHelpers::RSpec.schema!(path: "spec/support/schema.json")`. For anything else, `Graphiti::Schema.check` answers `missing?`, `stale?`, `compatible?` and `errors` for a given path without writing. + +## Generators {#generators} + +The [Resource generator](/concepts/resources#generators) will create both Resource and API tests for you. Use these as templates to implement your tests. + +You can also run + +```bash +$ rails generate graphiti:api_test RESOURCE [options] +``` + +For example + +```bash +$ rails generate graphiti:api_test EmployeeResource -a index show +``` + +To generate only the API tests. This can be particularly helpful because API tests are mostly boilerplate that does not need to be manually edited. Pass the `-a` option to limit RESTful actions. + +## Testing Spectrum {#testing-spectrum} + +There's no single right level of test coverage. Teams vary. Our guides favor treating logicless configuration (filters, sorts, sideloads) as covered by Graphiti itself and by schema validation, adding Resource/API tests mainly where there's custom logic - but consider heavier coverage if you're doing a major upgrade or swapping datastores. + +## Double-Testing Units {#double-testing-units} + +A custom filter backed by an ActiveRecord scope can feel like it needs both a model unit test and a near-identical Resource integration test. Use [RSpec shared_context](https://relishapp.com/rspec/rspec-core/docs/example-groups/shared-context) to share the seed data between them, or, if the overhead isn't worth it, mark the scope `# @api private` and skip the unit test until the scope needs to be reused elsewhere. diff --git a/website/versioned_docs/version-2.0/topics/without-activerecord.md b/website/versioned_docs/version-2.0/topics/without-activerecord.md new file mode 100644 index 00000000..4e235bde --- /dev/null +++ b/website/versioned_docs/version-2.0/topics/without-activerecord.md @@ -0,0 +1,324 @@ +--- +title: 'Usage Without ActiveRecord' +--- + +# Usage Without ActiveRecord +Graphiti was built to be used with any ORM or datastore, from PostgreSQL +to elasticsearch to `Net::HTTP`. In fact, Graphiti itself is tested with +Plain Old Ruby Objects (POROs). + +This cookbook will show how to customize a resource around a particular datastore, and how to package those +customizations into a reusable adapter. We'll use an in-memory datastore +and Plain Old Ruby Objects (POROs) here, but the lessons apply to any +datastore. + +For working code, see [this branch of the sample application](https://github.com/graphiti-api/employee_directory/blob/poro/app/resources/post_resource.rb). + +We'll start with this PORO model: + +```ruby +class Post + # Define getters/setters + # e.g. post.title = 'foo' + ATTRS = [:id, :title] + ATTRS.each { |a| attr_accessor(a) } + + # Instantiate with hash of attributes + # e.g. Post.new(title: 'foo') + def initialize(attrs = {}) + attrs.each_pair { |k,v| send(:"#{k}=", v) } + end + + # This part only needed for our particular + # persistence implementation; you may not need it + # e.g. post.attributes # => { title: 'foo' } + def attributes + {}.tap do |attrs| + ATTRS.each do |name| + attrs[name] = send(name) + end + end + end +end +``` + +And this in-memory datastore: + +```ruby +# If we were working with more than just Posts, we'd need a 'type' +# field here as well, to simulate a table name. +DATA = [ + { id: 1, title: 'Graphiti' }, + { id: 2, title: 'is' }, + { id: 3, title: 'super' }, + { id: 4, title: 'dope' } +] +``` + +## Resource Overrides {#resource-overrides} + +If it's your first time with a new ORM or datastore, we recommend +putting the logic in the Resource first. Once things are working *and* +there are multiple uses of the same overrides, package them into an +Adapter. + +```ruby +class PostResource < ApplicationResource + self.adapter = Graphiti::Adapters::Null + + attribute :title, :string + + def base_scope + {} + end + + def resolve(scope) + DATA.map { |d| Post.new(d) } + end +end +``` + +Here we're using the `Null` adapter, which acts as a dumb pass-through. +This can be helpful when you just want to get running for a simple use +case and don't want errors around features you haven't implemented yet. +But it can also be confusing when you expect certain codepaths to +be hit. Mostly just be aware of `Null`'s behavior, or use `Graphiti::Adapters::Abstract` to get helpful errors around what's not +implemented. + +We're also supplying an explicit `base_scope`. This is the beginning +query object we'll modify as params come in. In the case of +ActiveRecord, we might want an `ActiveRecord::Relation` like `Post.all`. For our example, we'll modify a simple ruby hash (keep in +mind the premise of building a hash of options and passing it off to a +client can apply to any datastore). + +Finally, we're [resolving that scope](/concepts/resources#resolve), +returning the full dataset for now. The contract of `#resolve` is to return an array of model instances, hence `DATA.map { |d| Post.new(d) +}`. + +#### Sorting {#sorting} + +```ruby +sort_all do |scope, attribute, direction| + scope[:sort].merge!(attribute: att, direction: dir) +end + +def base_scope + { sort: {} } +end + +def resolve(scope) + if sort = scope[:sort].presence + data = DATA.sort_by { |d| d[sort[:attribute].to_sym] } + data = data.reverse if sort[:direction] == :desc + end + DATA.map { |d| Post.new(d) } +end +``` + +We modified the base scope with a default hash key, `:sort`. When the +user requests sorting, we record this by merging into the hash. We can +then reference that information on the scope when resolving. + +Note the `sort_all` scope block, in fact all scope blocks, must return the scope. + +#### Paginating {#paginating} + +```ruby +paginate do |scope, current_page, per_page| + scope.merge!(current_page: current, per_page: per) +end + +def resolve(scope) + # ... sorting ... + start = (scope[:current_page] - 1) * scope[:per_page] + stop = start + scope[:per_page] + data = data[start...stop] + # ... return models ... +end +``` + +Again: merge into the scope, then reference the scope data when +resolving. + +#### Filtering {#filtering} + +```ruby +filter :title, only: [:eq] do + eq do |scope, value| + scope[:filters][attribute] = value + scope + end +end + +def base_scope(*) + { sort: {}, filters: {} } +end + +def resolve(scope) + # ... sorting ... + scope[:filters].each_pair do |k, v| + data = data.select { |d| d[k.to_sym].in?(v) } + end + # ... pagination ... + # ... return models ... +end +``` + +Same as above examples. Again, we must return the scope object +from the filter function. + +#### Persisting {#persisting} + +All at once: + +```ruby +# Instantiate a model for #create +def build(model_class) + model_class.new +end + +# Used for create/update +def assign_attributes(model, attributes) + attributes.each_pair do |k, v| + model.send(:"#{k}=", v) + end +end + +# Used for create/update +def save(model) + attrs = model.attributes.dup + attrs[:id] ||= DATA.length + 1 + if existing = DATA.find { |d| d[:id].to_s == attrs[:id].to_s } + existing.merge!(attrs) + else + DATA << attrs + end + model +end + +# Used for destroy +def delete(model) + DATA.reject! { |d| d[:id].to_s == model.id.to_s } + model +end +``` + +These are the overrides for persistence operations. You are encouraged +**not** to override `create/update/destroy` directly and instead use +[Persistence Lifecycle Hooks](/concepts/persisting#persistence-lifecycle-hooks). + +## Adapters {#adapters} + +OK so we have all our read and write operations working correctly. But +if we had multiple Resources all using an in-memory datastore, you'd see +this logic repeated all over the place. Let's create an adapter to [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) +up this logic. + +There isn't much more to do than copy/paste what we've already done. +Let's start with our `base_scope`, sorting, and pagination: + +```ruby +class POROAdapter < Graphiti::Adapters::Abstract + def base_scope(*) + { sort: {}, filters: {} } + end + + def paginate(scope, current, per) + scope.merge!(current_page: current, per_page: per) + end + + def order(scope, att, dir) + scope[:sort].merge!(attribute: att, direction: dir) + scope + end + + def resolve(scope) + data = DATA + if sort = scope[:sort].presence + data = data.sort_by { |d| d[sort[:attribute].to_sym] } + data = data.reverse if sort[:direction] == :desc + end + start = (scope[:current_page] - 1) * scope[:per_page] + stop = start + scope[:per_page] + data = data[start...stop] + + data.map { |d| resource.model.new(d) } + end +end +``` + +There's really nothing here we haven't seen before. We're taking the +code we originally wrote, and sticking it into the interface defined by +`Graphiti::Adapters::Abstract`. + +There's a *little* more to do with filtering: + +```ruby +def filter(scope, attribute, value) + scope[:filters][attribute] = value + scope +end +alias :filter_string_eq :filter +alias :filter_integer_eq :filter +alias :filter_date_eq :filter +# ... etc ... +``` + +The logic is the same, but we have a separate method for each filter +operator. This allows us to query differently based on the type - for +instance, ActiveRecord will default to case-insensitive for strings, but +straight equality for integers. If you don't need operator-specific +logic, just `alias` as you see here. + +You may want to limit the default operators we expect to work with a +given type. Let's say your backend allows straight equality for strings, +but doesn't support `prefix`, `suffix`, etc. You can specify this in +your adapter: + +```ruby +def self.default_operators + super.tap do |built_in| + built_in[:string] = [:eq] + end +end + +# or avoid super altogether + +def self.default_operators + { + string: [:eq], + integer: [:eq] + # ... etc ... + } +end +``` + +**That's it for reads**. For writes, I'll post the entire adapter code +below - again, it's just copy/pasting what we already wrote into a +slightly different format. + +```ruby +def destroy(model) + Post::DATA.reject! { |d| d[:id].to_s == model.id.to_s } + model +end + +def save(model) + attrs = model.attributes.dup + attrs[:id] ||= Post::DATA.length + 1 + if existing = Post::DATA.find { |d| d[:id].to_s == attrs[:id].to_s } + existing.merge!(attrs) + else + Post::DATA << attrs + end + model +end + +# For wrapping persistence operations in a DB transactions +# Our in-memory DB doesn't have transactions, so just yield +def transaction(*) + yield +end +``` + +That's really it. [See the working code in Employee Directory here](https://github.com/graphiti-api/employee_directory/blob/poro/app/resources/post_resource.rb). diff --git a/website/versioned_docs/version-2.0/tutorial/index.md b/website/versioned_docs/version-2.0/tutorial/index.md new file mode 100644 index 00000000..04ee1962 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/index.md @@ -0,0 +1,58 @@ +--- +title: 'Tutorial' +--- + +

+ +

+ +# Tutorial +This tutorial serves as a deeper-dive into Graphiti development, +building an Employee Directory application. We purposefully built this +to illustrate common - but non-trivial - scenarios present in many +applications. + +You'll need Ruby 3.2+ and Rails 7.1+ installed. Step 0 starts from an empty directory, so nothing else is assumed. + +A core concept of Graphiti is **Test-First** - the most pleasant way to +develop Graphiti is by starting with an [integration test](/topics/testing). But that can add a lot of noise to a tutorial like this. Though we'll occasionally touch on testing - and the git diffs at the top of each section contain the necessary tests - we won't test first for the purposes of this tutorial. + + +### Server Side: Rails + +[Rails Sample Application](https://github.com/graphiti-api/employee_directory) + +* [Step 0: Bootstrapping](/tutorial/step_0) +* [Step 1: Initial Resource](/tutorial/step_1) +* [Step 2: Has Many](/tutorial/step_2) +* [Step 3: Belongs To](/tutorial/step_3) +* [Step 4: Customizing Queries](/tutorial/step_4) +* [Step 5: Has One](/tutorial/step_5) +* [Step 6: Customizing Writes](/tutorial/step_6) +* [Step 7: Many-to-Many](/tutorial/step_7) +* [Step 8: Polymorphic +Relationships](/tutorial/step_8) +* [Step 9: Polymorphic Resources](/tutorial/step_9) + + + +### Client Side: VueJS (diff-only) + +[VueJS Sample Application](https://github.com/graphiti-api/employee-directory-vue) + + +* [Step 0: Setup](https://github.com/graphiti-api/employee-directory-vue/commit/be690c3038380e17e326935d595a0b83fc8004f9) + * Run after `vue create employee-directory-vue` using [Vue CLI](https://cli.vuejs.org). +* [Step 1: Define Models](https://github.com/graphiti-api/employee-directory-vue/compare/step_0_setup...step_1_models) +* [Step 2: Data Grid](https://github.com/graphiti-api/employee-directory-vue/compare/step_1_models...step_2_data_grid) +* [Step 3: Relationships](https://github.com/graphiti-api/employee-directory-vue/compare/step_2_data_grid...step_3_includes) +* [Step 4: Filtering](https://github.com/graphiti-api/employee-directory-vue/compare/step_3_includes...step_4_filtering) +* [Step 5: Sorting](https://github.com/graphiti-api/employee-directory-vue/compare/step_4_filtering...step_5_sorting) +* [Step 6: Total Count](https://github.com/graphiti-api/employee-directory-vue/compare/step_5_sorting...step_6_stats) +* [Step 7: Pagination](https://github.com/graphiti-api/employee-directory-vue/compare/step_6_stats...step_7_pagination) +* [Step 8: Basic Form Setup](https://github.com/graphiti-api/employee-directory-vue/compare/step_7_pagination...step_8_basic_form_setup) +* [Step 9: Dropdown](https://github.com/graphiti-api/employee-directory-vue/compare/step_8_basic_form_setup...step_9_dropdown) +* [Step 10: Nested Form Submission](https://github.com/graphiti-api/employee-directory-vue/compare/step_9_dropdown...step_10_nested_create) +* [Step 11: Validation Errors](https://github.com/graphiti-api/employee-directory-vue/compare/step_10_nested_create...step_11_validations) +* [Step 12: Nested Destroy](https://github.com/graphiti-api/employee-directory-vue/compare/step_11_validations...step_12_nested_destroy) +* [Step 13: Vue-Specific Glue Code](https://github.com/graphiti-api/employee-directory-vue/compare/step_12_nested_destroy...step_13_vue) diff --git a/website/versioned_docs/version-2.0/tutorial/step_0.md b/website/versioned_docs/version-2.0/tutorial/step_0.md new file mode 100644 index 00000000..82295212 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_0.md @@ -0,0 +1,93 @@ +--- +title: 'Step 0' +--- + +## Step 0: Bootstrapping + +> [View the Code](https://github.com/graphiti-api/employee_directory/commit/e2552ce212c68b41a3eb8161deb822fff3e159d6) + +Let's start by creating a new Rails project. For help with an existing +project, check out [Installation: From +Scratch](/getting-started/installation). + +We'll use the `-m` option to install from a template, which will add a few gems and apply some setup boilerplate. Accept all the default options. + +```bash +$ rails new employee_directory --api -m https://raw.githubusercontent.com/graphiti-api/graphiti_rails_template/master/all.rb +$ cd employee_directory +``` + +> Note: if a network issue prevents you from pointing to this URL directly, you can download the file and and run this command as `-m /path/to/template` + +Feel free to run `git diff` to see what the generator did, otherwise commit the result. You can now head to [Step 1: Basic Resource](/tutorial/step_1), or continue reading to better understand the code. + +#### Digging Deeper 🧐 + +You'll see some boilerplate in `config/routes.rb`: + +```ruby +scope path: "/api/v1", defaults: {format: :jsonapi} do + # your routes go here +end +``` + +This tells Rails that our API routes will be be prefixed - `/api/v1` by default. It also says that if no extension is in the URL (`.json`, `.xml`, etc), default +to the [JSONAPI Specification](http://jsonapi.org). + +Let's look at the above `ApplicationResource`: + +```ruby +class ApplicationResource < Graphiti::Resource + self.abstract_class = true + + # We'll be using ActiveRecord + self.adapter = Graphiti::Adapters::ActiveRecord + + # Links are generated from base_url + endpoint_namespace + self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') + self.endpoint_namespace = '/api/v1' +end +``` + +This should be pretty self-explanatory except for + +```ruby +self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') +``` + +When deriving and validating [Links](/concepts/links), we'll use the `BASE_URL` variable if +present, falling back to the Rails development default of +`http://localhost:3000`. Unlike a Rails URL helper this needs the scheme and port, because it is the whole prefix every link is built on. This means our Links will look like: + +```ruby +"#{ENV['BASE_URL']}/#{Resource.endpoint_namespace}/#{Resource.type}" +``` + +For example: + +```ruby +http://my-website.com/api/v1/employees +``` + +Read more in the [Links Guide](/concepts/links). + +Finally, there's some boilerplate in `ApplicationController`: + +```ruby +class ApplicationController < ActionController::API + include Graphiti::Rails::Controller +end +``` + +This wires Graphiti into the request cycle: the Graphiti context, the debugger, JSON:API error rendering, and `respond_to` (which `ActionController::API` normally strips out). Controllers render with `render jsonapi:`, and to render simple nested JSON like default Rails, we'll only need to add `.json` to the URL. (Prefer writing `respond_with(posts)`? The optional [responders integration](/getting-started/installation#responders) is one gem and one include away.) + +That's it for basic setup! + + +

+ + NEXT - + Step 1: Basic Resource + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_1.md b/website/versioned_docs/version-2.0/tutorial/step_1.md new file mode 100644 index 00000000..8623e226 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_1.md @@ -0,0 +1,199 @@ +--- +title: 'Step 1' +--- + +## Step 1: Basic Resource + +> [View the Diff](https://github.com/graphiti-api/employee_directory/commit/45c1c92e14fb1c3a47b8ed246ceb2cba50e97c72) + +We'll be working with a single database table, `employees`: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idfirst_namelast_nameagecreated_atupdated_at
1HomerSimpson392018-09-042018-09-04
2WaylonSmithers652018-09-042018-09-04
3MontyBurns1232018-09-042018-09-04
+ +### The Rails Stuff 🚂 + +Use the built-in generator to create the database table +and corresponding `ActiveRecord` model: + +```bash +$ bin/rails g model Employee first_name:string last_name:string age:integer +$ bin/rails db:migrate +``` + +Now let's seed some random development data, using [Faker](https://github.com/stympy/faker) (which was installed in [Step 0](/tutorial/step_0)): + +```ruby +# db/seeds.rb +Employee.delete_all # Ensure the DB is cleaned each run + +100.times do + Employee.create! first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + age: rand(20..80) +end +``` + +Run this seed file with + +```bash +$ bin/rails db:seed +``` + +### The Graphiti Stuff 🎨 + +Just like Rails, Graphiti has built-in generators. Let's generate +the corresponding Resource for our `Employee` model: + +```bash +$ bin/rails g graphiti:resource Employee first_name:string last_name:string age:integer created_at:datetime updated_at:datetime +``` + +This generated a few things, but for now let's focus on +`EmployeeResource`: + +```ruby +class EmployeeResource < ApplicationResource + attribute :first_name, :string + attribute :last_name, :string + attribute :age, :integer + attribute :created_at, :datetime, writable: false + attribute :updated_at, :datetime, writable: false +end +``` + +This code defined the [RESTful Resource](https://restful-api-design.readthedocs.io/en/latest/resources.html) we want our API to expose. Let's run our server and see what it does: + +```bash +$ bin/rails s +``` + +Visit `localhost:3000/api/v1/employees`. You should see a [JSONAPI Response](http://jsonapi.org): + +
+ +![jsonapi](/assets/img/legacy/legacy-0378a3bb39.png) + +
+ +If you find the payload a little intimidating, add `.json` to the URL for a more traditional response, or `.xml` for XML. Both are different **renderings** of the same `EmployeeResource`. + +`Resources` are comprised of `Attribute`s: + +```ruby +# app/resources/employee_resource.rb +attribute :first_name, :string +``` + +Each attribute defines behavior for: + +* Reading (display) +* Writing +* Sorting +* Filtering +* Fieldsets + +Let's start with simple display, turning `first_name` into all capital +letters: + +```ruby +# app/resources/employee_resource.rb +attribute :first_name, :string do + # @object is your model instance + @object.first_name.upcase +end +``` + +This is the most important thing to understand about Resources: they are just a collection of defaults, all of which can be overridden. `attribute :first_name` is shorthand for `attribute :first_name do @object.first_name end`. + +We'll go into further Resource customizations over the course of this tutorial. For now, undo the capitalization change above, and verify our out-of-the-box defaults: the same filter, sort, and pagination capabilities you exercised in the [Quickstart](/getting-started/first-api#querying) work here too, just against `employees` instead of `posts`. See the [Overview guide](/concepts/overview) for the full capability reference. + +Write operations are easiest to verify with integration tests, which were created when we generated our Resource: an **API Spec** covering the request/response cycle, and a **Resource Spec** covering the Resource's logic directly. See the [Testing Guide](/topics/testing) for what these look like and how they differ. The example there uses the same `create` payload shape the generator produced for `EmployeeResource`. + +Before we run these specs, we need to edit our [factories](https://github.com/thoughtbot/factory_bot) to ensure +dynamic, randomized data. Let's change this: + +```ruby +# spec/factories/employee.rb + +FactoryBot.define do + factory :employee do + first_name { "MyString" } + last_name { "MyString" } + age { 1 } + end +end +``` + +To + +```ruby +# spec/factories/employee.rb + +FactoryBot.define do + factory :employee do + first_name { Faker::Name.first_name } + last_name { Faker::Name.last_name } + age { rand(20..80) } + end +end +``` + +Now run the generated specs: + +```bash +$ bundle exec rspec +``` + +You'll see 11 tests pass, with 3 pending. One of the pending specs was +autogenerated by rails - you can delete `spec/models/employee_spec.rb` +for now. + +That leaves us with two "update" specs. These are marked pending so you +can manage the data yourself. Follow the comments in these specs to add +attributes and get them passing. + + +

+ + NEXT - + Step 2: Has Many + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_2.md b/website/versioned_docs/version-2.0/tutorial/step_2.md new file mode 100644 index 00000000..0ffe6476 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_2.md @@ -0,0 +1,312 @@ +--- +title: 'Step 2' +--- + +## Step 2: Has Many + +> [View the Code](https://github.com/graphiti-api/employee_directory/compare/step_1_employees...step_2_positions) + +We'll be adding the database table `positions`: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idemployee_idtitleactivehistorical_indexcreated_atupdated_at
1900Engineertrue12018-09-042018-09-04
2900Interntrue22018-09-042018-09-04
3800Managertrue12018-09-042018-09-04
+ +Because this table tracks all historical positions, we have the +`historical_index` column. This tells the order the employee moved through each position, where `1` is most recent. + +### The Rails Stuff 🚂 + +Generate the `Position` model: + +```bash +$ bin/rails g model Position title:string active:boolean historical_index:integer employee:belongs_to +$ bin/rails db:migrate +``` + +Update the `Employee` model with the association, too: + +```ruby +# app/models/employee.rb +has_many :positions +``` + +And update our seed data: + +```ruby +# db/seeds.rb +[Employee, Position].each(&:delete_all) + +100.times do + employee = Employee.create! first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + age: rand(20..80) + + (1..2).each do |i| + employee.positions.create! title: Faker::Job.title, + historical_index: i, + active: i == 1 + end +end +``` + +```bash +$ bin/rails db:seed +``` + +### The Graphiti Stuff 🎨 + +Let's start by running the same command as before to create +`PositionResource`: + +```bash +$ bin/rails g graphiti:resource Position title:string active:boolean +``` + +We'll need to add the association, just like ActiveRecord: + +```ruby +# app/resources/employee_resource.rb +has_many :positions +``` + +...and a corresponding filter: + +```ruby +# app/resources/position_resource.rb +filter :employee_id, :integer +``` + +If you visit `/api/v1/employees`, you'll see a number of HTTP +[Links](https://graphiti.dev/guides/concepts/links) +that allow lazy-loading positions. Or, if you visit +`/api/v1/employees?include=positions`, you'll load the employees and +positions in a single request. We'll dig a bit deeper into this logic +in the section below. + +Before we get there, let's revisit the `historical_index` column. For now, let's +treat this as an implementation detail that the API should not expose - +let's say we want to support sorting on this attribute but nothing else: + +```ruby +attribute :historical_index, :integer, only: [:sortable] +``` + +We're almost done, but if you run your tests you'll see two outstanding +errors. This is because Rails requires `belongs_to` associations by default. We can't save a `Position` without its corresponding `Employee`. + +We can solve this in three ways: + +* Turn this off globally, with [config.active_record.belongs_to_required_by_default](https://edgeguides.rubyonrails.org/configuring.html#configuring-active-record). You may want to do this in test-mode only. +* Turn this off for the specific association: `belongs_to :employee, optional: true`. +* Associate an `Employee` as part of the API request. + +We'll take for the last option. Look at +`spec/resources/position/writes_spec.rb`: + +```ruby +RSpec.describe PositionResource, type: :resource do + describe 'creating' do + let(:payload) do + { + data: { + type: 'positions', + attributes: { } + } + } + end + + let(:instance) do + PositionResource.build(payload) + end + + it 'works' do + expect { + expect(instance.save).to eq(true) + }.to change { Position.count }.by(1) + end + end +end +``` + +When running our tests, let's make sure the `historical_index` column +reflects the order we created the positions. This code recalculates +everything after a record is saved: + +```ruby +# spec/factories/position.rb +FactoryBot.define do + factory :position do + employee + + title { Faker::Job.title } + + after(:create) do |position| + unless position.historical_index + scope = Position + .where(employee_id: position.employee.id) + .order(created_at: :desc) + scope.each_with_index do |p, index| + p.update_attribute(:historical_index, index + 1) + end + end + end + end +end +``` + +Let's associate an `Employee`. Start by seeding the data: + +```ruby +let!(:employee) { create(:employee) } +``` + +And associate via `relationships`: + +```ruby +let(:payload) do + { + data: { + type: 'positions', + attributes: { }, + relationships: { + employee: { + data: { + id: employee.id.to_s, + type: 'employees' + } + } + } + } + } +end +``` + +To ensure the `PositionResource` will process this relationship, the +last step is to add it: + +```ruby +# app/resources/position_resource.rb +belongs_to :employee +``` + +This will associate the `Position` to the `Employee` as part of the +creation process. The test should now pass - make the same change to +`spec/api/v1/positions/create_spec.rb` to get a fully-passing test +suite. + +#### Digging Deeper 🧐 + +Why did we need the `employee_id` filter above? To explain that, let's dive deeper into the logic connecting Resources. + +If you hit `/api/v1/employees`, you'll see a number of +[Links](https://graphiti.dev/guides/concepts/links) in the +response. These are useful for lazy-loading, but the same logic +applies to eager loading. Let's take a look at a Link to see how these +Resources connect together: + +```ruby +{ + ... + relationships: { + positions: { + links: { + related: "http://localhost:3000/api/v1/positions?filter[employee_id]=1" + } + } + } + ... +} +``` + +The salient bit: `/positions?filter[employee_id]=1`. In other words, +fetch all Positions for the given Employee id.That means, whether we're lazy-loading data in separate requests or +eager-loading in a single request, **the same logic fires +under-the-hood**: + +```ruby +PositionResource.all({ + filter: { employee_id: 1 } +}) +``` + +This means we need `filter :employee_id, :integer` to satisfy the query. + +We can customize the logic connecting Resources in a few different +ways. First some simple options: + +```ruby +has_many :positions, foreign_key: :emp_id, primary_key: :eid +``` + +So far so good. The logic, and corresponding Link, both update as you'd +expect (though we'd of course need a corresponding `filter :emp_id, :integer` on `PositionResource`). + +Those options are just simple versions of parameter customization. +You can customize parameters connecting Resources with the `params` block: + +```ruby +has_many :positions do + params do |hash, employees| + hash[:filter] # => { employee_id: employees.map(&:id) } + hash[:filter][:active] = true + hash[:sort] = '-created_at' + end +end +``` + +Customizing these params affects the Link as well as the eager-load +logic. Remember the parameters here should reflect the JSON:API +specification, or anything `PositionResource.all` accepts. + +These are the most common options, but there's a bunch more. Check +out the [Resource Relationships Guide](/concepts/relationships) to dig even deeper. + + + +

+ + NEXT - + Step 3: Belongs To + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_3.md b/website/versioned_docs/version-2.0/tutorial/step_3.md new file mode 100644 index 00000000..8faa5ee6 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_3.md @@ -0,0 +1,142 @@ +--- +title: 'Step 3' +--- + +### Step 3: Belongs To + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_2_positions...step_3_departments) + +We'll be adding the database table `departments`: + + + + + + + + + + + + + + + + + + + + + + +
idname
1Engineering
2Safety
3QA
+ +We'll also be adding a `department_id:integer` foreign key column to the `positions` table. + +### The Rails Stuff 🚂 + +Generate the `Department` model: + +```bash +$ bin/rails g model Department name:string +``` + +To add the foreign key to `positions`: + +```bash +$ bin/rails g migration add_department_id_to_positions +``` + +```ruby +class AddDepartmentIdToPositions < ActiveRecord::Migration[7.1] + def change + add_foreign_key :positions, :departments + end +end +``` + +Update the database: + +```bash +$ bin/rails db:migrate +``` + +Update our seed file: + +```ruby +[Employee, Position, Department].each(&:delete_all) + +engineering = Department.create! name: 'Engineering' +safety = Department.create! name: 'Safety' +qa = Department.create! name: 'QA' +departments = [engineering, safety, qa] + +100.times do + employee = Employee.create! first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + age: rand(20..80) + + (1..2).each do |i| + employee.positions.create! title: Faker::Job.title, + historical_index: i, + active: i == 1, + department: departments.sample + end +end +``` + +Make sure to update `spec/factories/departments.rb` with randomized +data. Then, since this is also a required relationship, update +`spec/factories/positions.rb` to always seed a department when we ask to +create a position: + +```ruby +factory :position do + employee + department + + # ... code ... +end +``` + +### The Graphiti Stuff 🎨 + +You should be used to this by now: + +```bash +bin/rails g graphiti:resource Department name:string +``` + +Add the association: + +```ruby +# app/resources/position_resource.rb +belongs_to :department +``` + +And review the end of [Step 2](/tutorial/step_2) to get all your specs +passing (add the department to the request payload). Practice makes perfect! + +#### Digging Deeper 🧐 + +We didn't need a filter like we did in step two. That's +because the primary key connecting the Resources is `id` by +default. In other words, the Link would be something like: + +```bash +/departments?filter[id]=1 +``` + +Which we get out-of-the-📦 + +But remember, you can customize these relationships just like the +previous `has_many` section. + + +

+ + NEXT - + Step 4: Customizing Queries + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_4.md b/website/versioned_docs/version-2.0/tutorial/step_4.md new file mode 100644 index 00000000..4bd930f2 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_4.md @@ -0,0 +1,135 @@ +--- +title: 'Step 4' +--- + +### Step 4: Customizing Queries + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_3_departments...step_4_customizations) + +So far, we've done fairly straightforward queries. If a user filters on +`first_name`: + +`/api/v1/employees?filter[first_name]=Foo` + +We'll query the equivalent database column: + +```ruby +Employee.where(first_name: 'Foo') +``` + +But what if there's more complex logic? Let's say we want to sort +Employees on their `title` - which comes from the `positions` table. +How would that work? + +### The Rails Stuff 🚂 + +First, we need to get data for an Employee's **current** position. +Let's start by defining what `current` means + +```ruby +# app/models/position.rb +scope :current, -> { where(historical_index: 1) } +``` + +> See the [ActiveRecord Scopes](https://guides.rubyonrails.org/active_record_querying.html#scopes) documentation if you're unfamiliar with this concept. + +Reference this scope in a new association: + +```ruby +has_one :current_position, + -> { current }, + class_name: 'Position' +``` + +Before moving on, let's review what we need to do. The `ActiveRecord` +code for sorting Employees on their current position's title would be: + +```ruby +Employee.joins(:current_position).merge(Position.order(title: :asc)) +``` + +Let's wire this up to Graphiti: + +### The Graphiti Stuff 🎨 + +We're only going to **sort** and **filter** on the `title` attribute - +never display or persist. So start by defining the attribute as such: + +```ruby +attribute :title, :string, only: [:filterable, :sortable] +``` + +Then the `sort` DSL to place our custom query: + +```ruby +# app/resources/employee_resource.rb +sort :title do |scope, direction| + scope.joins(:current_position).merge(Position.order(title: direction)) +end +``` + +That's it! When a request to sort on the title comes in, we'll alter our +scope to join on the `positions` table, and order based on the current position `title`. + +The solution for filtering is similar: + +```ruby +# app/resources/employee_resource.rb +filter :title do + eq do |scope, value| + scope.joins(:current_position).merge(Position.where(title: value)) + end +end +``` + +We can now filter on title: + +`/api/v1/employees?filter[title]=Foo` + +Let's do one more example - how would we order Employees by department +name? We *could* start the same way: + +```ruby +attribute :department_name, :string, only: [:sortable] +``` + +But if we're ***only*** sorting, this is actually redundant. Whenever we +use the `sort` or `filter` DSL, we're creating a sort-only or +filter-only attribute under the hood. So let's define everything in one +shot: + +```ruby +sort :department_name, :string do |scope, value| + scope.joins(current_position: :department) + .merge(Department.order(name: value)) +end +``` + +Remember: you only need to pass the type as the second argument when an +attribute doesn't already exist. And if you ever get an error saying +something is unfilterable or unsortable, check to see if you've already +defined a filter-only or sort-only attribute using these methods. + +#### Digging Deeper 🧐 + +There's a critical part of Graphiti that makes everything easier: start +by imagining it doesn't exist. + +In other words, the meat of the logic above had nothing to do with +Graphiti code - we're "wiring up" independent ActiveRecord +queries. If you're ever confused about query logic, get things working +without Graphiti first. + +We could have changed the above to ActiveRecord scopes like +`.order_by_title(title)`, making the wiring code even simpler. Consider +doing this when the logic is reusable or particlar complex, but be aware +of the tradeoffs of [double-testing units](https://graphiti.dev/guides/concepts/testing#double-testing-units). + + +

+ + NEXT - + Step 5: Has One + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_5.md b/website/versioned_docs/version-2.0/tutorial/step_5.md new file mode 100644 index 00000000..55da867e --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_5.md @@ -0,0 +1,69 @@ +--- +title: 'Step 5' +--- + +### Step 5: Has One + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_4_customizations...step_5_has_one) + +In the last step, we introduced the concept of a "current position" to +the model layer. Let's now expose that relationship to the API. + +### The Rails Stuff 🚂 + +We already defined a `Position.current` scope that we'll re-use - +let's just make a small tweak to support the opposite use case as +well: + +```ruby +scope :current, ->(bool) { + clause = { historical_index: 1 } + bool ? where(clause) : where.not(clause) +} +``` + +### The Graphiti Stuff 🎨 + +You might already have an idea how this might work from the prior step +- we'll use the `params` block to customize the relationship. The `has_one` macro ensures the result is treated as a single object and +not an array. + +```ruby +# app/resources/employee_resource.rb +has_one :current_position, resource: PositionResource do + params do |hash| + hash[:filter][:current] = true + end +end +``` + +Which means we'll have to implement that filter - re-using the +ActiveRecord scope we already defined! + +```ruby +filter :current, :boolean do + eq { |scope, value| scope.current(value) } +end +``` + +#### Digging Deeper 🧐 + +In this example, we're able to return only a single record because we +have a `historical_index` column. If this column didn't exist - maybe we're just ordering on `created_at` and taking the first record - we'd +have a problem. What if we were loading 20 employees and wanted the +`current_position` of each - what SQL would limit the resultset +correctly? + +We call this a [faux has_one](/concepts/relationships#faux-has-one) and there's nothing easily done here. Graphiti will ensure only one record +is returned by the API, but the query will take longer and loading extra +records will eat memory. If there are lots of records in the +association, look into adding a column like `historical_index`. + + +

+ + NEXT - + Step 6: Customizing Writes + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_6.md b/website/versioned_docs/version-2.0/tutorial/step_6.md new file mode 100644 index 00000000..62e29c6c --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_6.md @@ -0,0 +1,82 @@ +--- +title: 'Step 6' +--- + +### Step 6: Customizing Writes + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_5_has_one...step_6_write_customization) + +When we ran the generators (and created a blank Resource class), we got the ability to create, update, and destroy resources for free. You can turn off this behavior with `self.read_only = true`. Or for relationships: `has_many :positions, writable: false`. + +But in RESTful APIs, it's super common for persistence operations to +have side effects - that's how we avoid extraneous verbs and +inconsistent patterns. + +In a prior step, we updated `position` Factory to automatically reorder the `historical_index`: when a new record comes in, all the prior +values need to change. This step will show how to add that behavior to +our API, using hooks that work for a variety of scenarios: sending +emails, checking authorization roles, queuing delayed jobs, and more. + +### The Rails Stuff 🚂 + +Previously, we put the logic that re-ordered the `historical_index` column in the `position` factory. Let's move that to the model so our +tests and API can share the same logic: + +```ruby +# app/models/position.rb +def self.reorder!(employee_id) + scope = Position.where(employee_id: employee_id).order(created_at: :desc) + scope.each_with_index do |p, index| + p.update_attribute(:historical_index, index + 1) + end +end +``` + +```ruby +# spec/factories/positions.rb +# ... code ... +after(:create) do |position| + unless position.historical_index + Position.reorder!(position.employee.id) + end +end +``` + +### The Graphiti Stuff 🎨 + +All Graphiti updates happen within a transaction. We want to insert our +code right before that transaction closes - after the graph of objects +has been persisted and validations have passed. To do that, we'll use +the `before_commit` hook: + +```ruby +before_commit only: [:create, :destroy] do |position| + Position.reorder!(position.employee_id) +end +``` + +Again, the `Position.reorder!` code existed independent of our +API, and was re-used in our factory. + +#### Digging Deeper 🧐 + +Resources come with [Lifecycle Hooks](https://graphiti.dev/guides/concepts/persisting#persistence-lifecycle-hooks), similar to ActiveRecord [Callbacks](https://guides.rubyonrails.org/active_record_callbacks.html). + +Those callbacks have gotten a bad reputation. This is because your Model +can be - is supposed to be - used in a variety of contexts across your +application. Some of those contexts will want a given callback to fire, +others will not, and accomodating the conditionals gets hairy. This is +why many developers move that functionality into [Service Objects](https://engineering.gusto.com/the-rails-callbacks-best-practices-used-at-gusto/). + +But Resource callbacks don't have the same problem - they only fire in +the context of your API, and can be associated to a single endpoint. You +can still use Service Objects if you'd like. Graphiti callbacks wire them up. + + +

+ + NEXT - + Step 7: Many to Many + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_7.md b/website/versioned_docs/version-2.0/tutorial/step_7.md new file mode 100644 index 00000000..15cc8f2e --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_7.md @@ -0,0 +1,205 @@ +--- +title: 'Step 7' +--- + +### Step 7: Many to Many + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_6_write_customization...step_7_many_to_many) + +Let's add a `Team` relationship: a `Team` can have many `Employee`s, an `Employee` can have many `Team`s. Let's also say a `Team` belongs to a `Department`. + + + + + + + + + + + + + + + + + + + + + + + + + + +
iddepartment_idname
11The A Team
21The B Team
32The C Team
+ +To satisfy this many-to-many use case, we'll need a join model, +`TeamMembership`: + + + + + + + + + + + + + + + + + + + + + + + + + + +
idteam_idemployee_id
111
221
332
+ +### The Rails Stuff 🚂 + +```bash +$ bin/rails g model Team name:string department:belongs_to +$ bin/rails g model TeamMembership employee:belongs_to team:belongs_to +$ bin/rails db:migrate +``` + +Graphiti supports `has_many :through`: + +```ruby +# app/models/employee.rb +has_many :team_memberships +has_many :teams, through: :team_memberships +``` + +```ruby +# app/models/department.rb +has_many :teams +``` + +```ruby +class Team < ApplicationRecord + belongs_to :department + has_many :team_memberships + has_many :employees, through: :team_memberships +end +``` + +```ruby +class TeamMembership < ApplicationRecord + belongs_to :team + belongs_to :employee +end +``` + +Finally, we'll need a new seed file to handle these new associations: + +```ruby +[ + Employee, + Position, + Department, + TeamMembership, + Team +].each(&:delete_all) + +departments = [] +def create_department(name) + dept = Department.create! name: name + dept.teams.create!(name: 'Engineering Team B') + dept.teams.create!(name: 'Engineering Team C') + dept +end + +departments << create_department('Engineering') +departments << create_department('Safety') +departments << create_department('QA') + +100.times do + employee = Employee.create! first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + age: rand(20..80) + + (1..2).each do |i| + employee.positions.create! title: Faker::Job.title, + historical_index: i, + active: i == 1, + department: departments.sample + end + + employee.teams << employee.positions[0].department.teams.sample +end +``` + +### The Graphiti Stuff 🎨 + +```bash +$ bin/rails g graphiti:resource Team name:string +``` + +Let's flesh out our `TeamResource`: + +```ruby +# app/resources/team_resource.rb +class TeamResource < ApplicationResource + attribute :department_id, :integer, only: [:filterable] + attribute :name, :string + + belongs_to :department + many_to_many :employees +end +``` + +The trick here is the `many_to_many` relationship. Let's add the reverse +as well: + +```ruby +# app/resources/employee_resource.rb +many_to_many :teams +``` + +And for good measure: + +```ruby +# app/resources/department_resource.rb +has_many :teams +``` + +We can now get all the usual functionality: fetch Employees and their +Teams in a single request (or vice versa). + +#### Digging Deeper 🧐 + +The `many_to_many` relationship is the only one where Graphiti modifies a separate Resource "under the hood". When we said `many_to_many +:employees`, the `EmployeeResource` got a `team_id` filter, and `many_to_many :teams` created an `employee_id` filter on `TeamResource`. + +This is because the logic is more complex than the default use case. We +don't have a simple `WHERE` clause. We need to join tables and look at +the appropriate primary/foreign keys. If the name of your API +association doesn't match the name of your ActiveRecord association, try +`has_many :things, as: :my_activerecord_relationship` to make the +introspection work correctly - or, write your own filter. + +Sometimes you'll have multiple levels of `has_many :through`. In this case, a simple `many_to_many` isn't enough - check out the [Hopping +Relationships](/topics/hopping-relationships) recipe. + +Think hard before reaching for `many_to_many`. Imagine one Team is the "primary" Team for an Employee. We'd add a `primary` boolean column to the `team_memberships` table...but that table isn't exposed to the API! +Consider if there's a hidden domain concept there. + + +

+ + NEXT - + Step 8: Polymorphic Relationships + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_8.md b/website/versioned_docs/version-2.0/tutorial/step_8.md new file mode 100644 index 00000000..03a1f462 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_8.md @@ -0,0 +1,128 @@ +--- +title: 'Step 8' +--- + +### Step 8: Polymorphic Relationships + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_7_many_to_many...step_8_polymorphic_belongs_to) + +Let's introduce the concept of a Note. A Note can belong to a +Department, an Employee, or a Team. For this, we'll need to introduce +the concept of [polymorphism](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idnotable_idnotable_typebody
11EmployeeA Sample Note!
21DepartmentAnother Sample Note!
31TeamA Third Sample Note!
+ +### The Rails Stuff 🚂 + +```bash +$ rails generate model Note notable:references{polymorphic}:index +$ bin/rails db:migrate +``` + +Make sure to add the corresponding model relationships: + +```ruby +# app/models/employee.rb +has_many :notes, as: :notable +# app/models/team.rb +has_many :notes, as: :notable +# app/models/department.rb +has_many :notes, as: :notable + +# app/models/note.rb +belongs_to :notable, polymorphic: true +``` + +Finally, make sure to edit your seed file - check out the [diff](https://github.com/graphiti-api/employee_directory/compare/step_7_many_to_many...step_8_polymorphic_belongs_to) to see the necessary adjustments. + +### The Graphiti Stuff 🎨 + +```bash +$ bin/rails g graphiti:resource Note body:string +``` + +Let's create our `NoteResource`: + +```ruby +class NoteResource < ApplicationResource + attribute :body, :string + + filter :notable_id, :integer + filter :notable_type, :string, allow: %w(Employee Department Team) + + polymorphic_belongs_to :notable do + group_by(:notable_type) do + on(:Employee) + on(:Team) + on(:Department) + end + end +end +``` + +And corresponding associations: + +```ruby +# app/resources/employee_resource.rb +polymorphic_has_many :notes, as: :notable +# app/resources/team_resource.rb +polymorphic_has_many :notes, as: :notable +# app/resources/department_resource.rb +polymorphic_has_many :notes, as: :notable +``` + +#### Digging Deeper 🧐 + +When defining a polymorphic relationship for our API, we're saying "grab +all the parent records, group them by a `type` column, and execute different queries for each type". This way records with `notable_type == +'Employee'` can hit the `employees` table, but records with `notable_type == 'Department'` could in theory load from a different API +altogether. + +Each of the `on` lines defines a new `belongs_to` association. That +means you can customize just like always: + +```ruby +on(:Team).belongs_to :team, resource: SomeCustomTeamResource do + # assign {} + # link {} + # ... etc ... +end +``` + + +

+ + NEXT - + Step 9: Polymorphic Resources + » + +

diff --git a/website/versioned_docs/version-2.0/tutorial/step_9.md b/website/versioned_docs/version-2.0/tutorial/step_9.md new file mode 100644 index 00000000..b1a9b114 --- /dev/null +++ b/website/versioned_docs/version-2.0/tutorial/step_9.md @@ -0,0 +1,171 @@ +--- +title: 'Step 9' +--- + +### Step 9: Polymorphic Resources + +> [View the Diff](https://github.com/graphiti-api/employee_directory/compare/step_8_polymorphic_belongs_to...step_9_polymorphic_resource) + +In the last step, we covered polymorphic relationships: a single +relationship can point to many different Resources. Polymorphic +Resources are the same concept, without an association: a single +Resource can resolve to many different sub-Resources. It's a very similar +to [Single-Table Inheritance in ActiveRecord](https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html). + +To illustrate this, we'll add a `tasks` table and corresponding `Task` superclass. Each record in this table will resolve to one of `Bug`, `Epic`, or `Feature`. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idmilestone_idtypetitle
1nullBugIncorrect Value!
2nullFeatureBuild great stuff!
31EpicBuild TONS of great stuff!
+ +Why not just stick with a single `Task` model? Because each of these types has specific behavior: only `Feature`s have a `points` attribute, and only `Epic`s have a `milestones` relationship. + +### The Rails Stuff 🚂 + +Let's create our `Task` model: + +```bash +$ bin/rails g model Task employee:belongs_to team:belongs_to type:string +title:string +$ bin/rails db:migrate +``` + +And create models to reflect our STI logic: + +```ruby +# app/models/task.rb +class Task < ApplicationRecord + TYPES = %w(Bug Feature Epic) + + belongs_to :team, optional: true + belongs_to :employee, optional: true +end + +# app/models/bug.rb +class Bug < Task +end + +# app/models/feature.rb +class Feature < Task +end + +# Only Epics have Milestones +# app/models/epic.rb +class Epic < Task + has_many :milestones +end + +# app/models/milestone.rb +class Milestone < ApplicationRecord + belongs_to :epic +end +``` + +Add the association: + +```ruby +# app/models/team.rb +has_many :tasks +has_many :bugs +has_many :features +has_many :epics + +# app/models/employee.rb +has_many :tasks +has_many :bugs +has_many :features +has_many :epics +``` + +Finally [view the diff](https://github.com/graphiti-api/employee_directory/compare/step_8_polymorphic_belongs_to...step_9_polymorphic_resource) to edit your `seeds.rb` file. + +### The Graphiti Stuff 🎨 + +Start by creating our Resource as normal: + +```bash +$ bin/rails g graphiti:resource Task title:string +``` + +Now edit to support polymorphism and associations: + +```ruby +class TaskResource < ApplicationResource + self.polymorphic = %w(FeatureResource BugResource EpicResource) + + attribute :employee_id, :integer, only: [:filterable] + attribute :team_id, :integer, only: [:filterable] + attribute :title, :string + + belongs_to :employee + belongs_to :team +end +``` + +The point of this was to show how responses could be specific to type, +so let's customize `Features`: + +```ruby +class FeatureResource < TaskResource + attribute :points, :integer do + rand(20) + end +end +``` + + +Only Epics have milestones, but let's support those as well: + +```bash +$ bin/rails g graphiti:resource Milestone name:string +``` + +```ruby +class MilestoneResource < ApplicationResource + attribute :epic_id, :integer, only: [:filterable] + attribute :name, :string + + # Customize the link to the Tasks endpoint, as we + # didn't create an Epics endpoint + belongs_to :epic do + link do |milestone| + helpers = Rails.application.routes.url_helpers + helpers.task_url(milestone.epic_id) + end + end +end +``` + +#### Digging Deeper 🧐 + +We can now resolve `Tasks`, either as a relationship or through the `/tasks` endpoint directly. When `Task` is type `'Feature'` it will have an extra attribute of `points`. When it's an `Epic`, it will have an additional relationship `Milestone`. + +Graphiti is smart enough to fetch the appropriate relationships. A hit +to `/tasks?include=milestones` will only query for milestones when the resulting `Task` records are `Epic`s. diff --git a/website/versioned_docs/version-2.0/upgrading.md b/website/versioned_docs/version-2.0/upgrading.md new file mode 100644 index 00000000..a6d10423 --- /dev/null +++ b/website/versioned_docs/version-2.0/upgrading.md @@ -0,0 +1,416 @@ +--- +title: 'Upgrading to Graphiti 2.0' +slug: /upgrading +--- + +# Upgrading to Graphiti 2.0 + +Graphiti 2.0 requires **Ruby 3.2+** and **ActiveSupport 7.1+**. Rails is not a dependency, but if you use it, 7.1+. Ruby 3.1 and earlier are past end of life, and Rails 6.1 and 7.0 do not support Ruby 3.2. Apps that cannot move yet should stay on the 1.x branch, which remains open for hotfixes. + +## What you have to change {#what-you-have-to-change} + +Five things, and four of them fail loudly if you skip them. + +**1. Drop three gems.** `graphiti-rails`, `graphiti_spec_helpers` and `graphiti_errors` are now part of `graphiti` itself. + +```diff title="Gemfile" ++ gem "graphiti", "~> 2.0" +- gem "graphiti-rails" +- gem "graphiti_spec_helpers" +- gem "graphiti_errors" +``` + +Graphiti raises at load if one is still installed, because they ship files that collide with Graphiti's own, so leaving them in place means load order decides which copy you get. + +**2. Include the Rails integration in your controllers.** + +```ruby +class ApplicationController < ActionController::Base + include Graphiti::Rails::Controller +end +``` + +If the controller already has `include Graphiti::Rails`, replace it with `include Graphiti::Rails::Controller`. + +
+What the include actually brings, and what a controller without it loses + +Until 2.0, Graphiti added itself to **every** controller in the application: an `around_action` wrapping each request in a Graphiti context, another wrapping it in the debugger, and a catch-all exception handler, on Devise controllers, admin controllers, HTML pages, everything. + +`Graphiti::Rails::Controller` now bundles all of it, and including it is required. Including it in `ApplicationController` matches 1.x behavior. Including it in an API base class scopes it and leaves the rest of the app alone. A controller without it gets no Graphiti context, no debugger, and none of Graphiti's exception handlers, so if a resource action sees an empty `Graphiti.context`, this include is what is missing. It also carries `ActionController::MimeResponds`, so `respond_to` works in `ActionController::API` apps, which under 1.x only came with `Graphiti::Rails::Responders`. + +The class-level DSL travels with it, which is the one failure you see before a request is ever served: + +```ruby +class PostsController < ApplicationController + self.sideload_allowlist = {index: [:comments]} # NoMethodError without the include +end +``` + +`sideload_allowlist` comes from `Graphiti::Context`, so a controller that never includes `Graphiti::Rails::Controller` raises `NoMethodError` while the class body is being loaded. Watch for base classes that were given `Graphiti::Rails::Responders` alone. Responders declares formats and nothing else, and does not carry the context. + +`Graphiti::Rails::Responders` is separate and most apps do not need it. It exists for the [`responders`](https://github.com/heartcombo/responders) gem's `respond_with`, and depends on that gem, which is why it is not part of `Graphiti::Rails::Controller`. + +
+ +**3. Delete any `rescue_from` that called `handle_exception`**, if you have one. + +```ruby +# 1.x, on a controller that included GraphitiErrors +rescue_from Exception do |e| + handle_exception(e) + Sentry.capture_exception(e) unless registered_exception?(e) +end +``` + +Rendering is middleware's job now, so `handle_exception` is gone and there is nothing left to call. Use `RescueRegistry.handles_exception?` if you still want the check. Exceptions reach your tracker's middleware on their own, and Graphiti's 400s and 404s stay out of `Rails.error` because they sit in Rails' `rescue_responses`. + +**4. Update `around_persistence` hooks**, if you have any. + +They now receive the already-assigned model where they used to receive the attributes hash, so a hook doing `attributes[:tenant_id] = current_tenant.id` raises. Move that to `before_attributes`, or set it on the model. + +
+Before and after, and what else moved with it + +Attributes are now assigned to the model once, up front, before the persistence hooks run, which is what lets `build` and `find` hand you the model before anything is written. See the [lifecycle hooks guide](/concepts/persisting#persistence-lifecycle-hooks) for what that enables. + +That changes one hook. + +#### around_persistence receives the model, not the attributes hash + +It now wraps the save of an already-assigned model, and gets that model: + +```ruby +# 1.x +def do_around_persistence(attributes) + attributes[:tenant_id] = current_tenant.id + model = yield + model.log_saved! +end + +# 2.0 +def do_around_persistence(model) + model.tenant_id = current_tenant.id # last chance to touch the model before save, inside the transaction + saved = yield + saved.log_saved! +end +``` + +To migrate, move attribute-hash modifications to `before_attributes` (which still receives the mutable hash, before assignment), or set the value on the model as above. Hooks that only wrap their yield, such as transactions, timing and post-save side effects, need no changes. Graphiti 1.x releases warn at runtime when a hook would be affected. + +`before/around/after_attributes` and `before/around/after_save` are unchanged. Custom `create`/`update` adapter overrides keep their 1.x signatures. + +#### Fine print + +- If you inspect the model before saving, the attributes callbacks run at inspection time (in the controller) outside the save transaction, and before sideposted parents are persisted. On the plain `save` path they run inside the transaction, at the same point as 1.x. If a hook needs the foreign key of a sideposted parent, use `before_save` instead, which always gets the model with those keys set. +- A writable guard asking for the model gets a fresh build/find, never the current request's unsaved changes. +- Sideposted child models are still built and assigned during save, and `data` exposes the pre-assigned root model only. +- The 1.x runtime warning fires when a hook mutates the hash, which is all it can detect. A hook that only reads it (e.g. Rails.logger.info `attributes[:name]`) gets no warning and now reads the model instead. On ActiveRecord `model[:name]` still answers, but hash-only calls like `attributes.key?`, `dig` or `except` raise. + +
+ +**5. Wrap specs that assert on error payloads.** + +```ruby +RSpec.configure do |config| + config.include Graphiti::Rails::TestHelpers, type: :request +end + +it "renders a 404" do + handle_request_exceptions { get "/posts/999" } + + expect(response.status).to eq(404) +end +``` + +Exceptions now propagate untouched in tests rather than rendering, so a spec expecting a 404 body sees the exception raised instead. This is the one that breaks the suite that would otherwise have told you the app was fine. + +You do not have to edit them one by one. An `around` hook restores 1.x behavior for every request spec, and puts the setting back after each example: + +```ruby +config.around(type: :request) { |example| handle_request_exceptions { example.run } } +``` + +
+Why it has to be a request spec + +It has to be a request spec. Exceptions are rendered in Rack middleware, which controller specs bypass, so the same assertion in a controller spec never sees a rendered payload no matter how it is wrapped. + +`handle_request_exceptions` replaces `GraphitiErrors.enable!` and `.disable!`, which toggled rendering globally. Wrapping the request scopes it to the example instead. + +
+ +## Behavior changes to be aware of {#behavior-changes} + +Nothing to do here. These change what a client gets back, or when a callback runs, and nothing warns you about them the way the renames below do. + +
+A `belongs_to` renders resource ids when its foreign key already holds them, where 1.x sent only a link + +A `belongs_to` now renders resource ids in the payload by default, where 1.x sent only a link: + +```json +"employee": { "data": { "type": "employees", "id": "1" }, "links": { "related": "..." } } +``` + +The id comes from the foreign key already on the parent, so this costs no extra queries. `has_many` is unchanged, since answering there means a query per record. + +Not every `belongs_to` qualifies. A remote target or a custom `primary_key` mean the foreign key is not the related id, a polymorphic target means one rendered type cannot cover every record, and a `scope` or `params` block or a `base_scope` mean the key might not survive the filter. Rendering ids for those means loading the association, so they stay opt-in as in 1.x and render nothing until you ask. + +Run [`bin/rake graphiti:audit`](/topics/debugging#graphiti-audit) to see where your API stands: it lists every relationship that renders no ids, and why. + +To go back to the old payload for one relationship: + +```ruby +belongs_to :employee, resource_ids: false +``` + +Or for the whole API, on the resource everything inherits from: + +```ruby +class ApplicationResource < Graphiti::Resource + self.abstract_class = true + + self.belongs_to_resource_ids_by_default = :never +end +``` + +If you carry the `Sideload::BelongsTo` monkey patch from [#167](https://github.com/graphiti-api/graphiti/issues/167), delete it and set nothing. The default now covers the safe cases on its own. To force ids onto the rest the way the patch did, set `self.belongs_to_resource_ids_by_default = :always`, at a query per record for each one. + +The three settings, and when a `belongs_to` cannot use its foreign key, are covered in [Customizing Relationships](/concepts/relationships#belongs-to-resource-ids). + +
+ +
+A relationship with no ids and no link is left out of the payload, where 1.x rendered meta: {included: false} + +A relationship that renders neither resource ids nor a link used to look like this: + +```json +"employee": { "meta": { "included": false } } +``` + +That shape comes from `jsonapi-serializable`, which fills in a relationship object it would otherwise render empty. It is not part of JSON:API and carries nothing a client can act on. Some clients read it as an empty relationship and clear data they already hold. 1.x left these out too when `links_on_demand` was on globally and the request did not ask for links. + +To keep them, on one resource or on the resource everything inherits from: + +```ruby +self.relationship_placeholders = true +``` + +
+ +
+A request with ?include= always gets an included key back + +When nothing comes back with the response, that key now holds an empty array. 1.x left it out entirely, so a client had to handle both a missing key and an empty one. + +
+ +
+`ConflictRequest` renders `code: "conflict"` at 409, where `graphiti-rails` surfaced it as a 500 + +Graphiti 1.x shipped two exception systems, `graphiti_errors` in core and `rescue_registry` in `graphiti-rails`, and both loaded in every Rails app. `rescue_registry` is now the only one, and installs automatically as a dependency. + +Graphiti registers handlers for `InvalidRequest` (400), `ConflictRequest` (409), `RecordNotFound` (404), `RemoteWrite` (400) and `SingularSideload` (400), plus a fallback that renders anything else as JSON:API. Register your own on any controller: + +```ruby +register_exception MyApp::Forbidden, status: 403 +register_exception MyApp::Throttled, status: 429, handler: MyApp::ThrottleHandler +``` + +`register_exception` comes from `rescue_registry`, which adds it to every controller, so you do not need `Graphiti::Rails::Controller` to register your own exceptions or to have them rendered. What the include adds is Graphiti's own registrations above, plus the fallback that renders anything unregistered as JSON:API. + +Only formats in `config.graphiti.handled_exception_formats` (default `[:jsonapi]`) are rendered by Graphiti. Everything else falls through to Rails. + +Registering one of these classes again replaces Graphiti's, and the last call wins. `graphiti_errors` apps often did, `UnsupportedPageSize` at 422 most of all. Put the include at the top of the class and your own registrations below it. + +If you subclassed `GraphitiErrors::ExceptionHandler`, note the interface changed with the gem: it is now `build_payload` / `formatted_response` / `status_code`, not `error_payload` / `status_code(error)`. + +Registering and customizing handlers is covered in [Error Handling](/topics/error-handling). + +**Conflicts now report as conflicts.** `Graphiti::Errors::ConflictRequest`, raised when a `PATCH` payload's id does not match the URL, used to render a 409 whose body said `code: "bad_request"`, `title: "Request Error"`. It now says `code: "conflict"`, `title: "Conflict Error"`. Under `graphiti-rails` this exception had no registered handler at all and surfaced as a 500, so for most apps this payload is new rather than changed. + +
+ +
+A 500 no longer claims your engineers have been notified + +`rescue_registry` gave every 5xx that detail. Graphiti drops it, so a 500 renders `code`, `status` and `title` alone. To say something there, set a locale key rather than subclassing a handler: + +```yaml +en: + graphiti: + errors: + internal_server_error: + title: "Something went wrong" + detail: "We've probably received an error report already, but please contact us if the issue persists." +``` + +`rails g graphiti:locale` writes the file for you. Keyed by error code, so it works for any status. See [Error Handling](/topics/error-handling#copy). + +
+ +
+`Node#respond_to?` answers `true` for any attribute present in the payload + +`Node#respond_to?` is now a proper `respond_to_missing?`, so `node.respond_to?(:first_name)` returns `true` for attributes present in the payload where it used to return `false`. Nothing to do unless a spec asserted on the old `false`. + +The node helpers are covered in [#jsonapi_data](/topics/testing#jsonapi-data). + +
+ +
+Attributes are assigned before the persistence hooks run, so inspecting a model first moves the attributes callbacks outside the save transaction + +If you inspect the model before saving, the attributes callbacks run at inspection time, in your controller and outside the save transaction. On the plain `save` path they run inside the transaction, at the same point as 1.x. + +The hooks and their order are covered in [Persistence Lifecycle Hooks](/concepts/persisting#persistence-lifecycle-hooks). + +
+ +
+`ActiveSupport::CurrentAttributes` now flow into concurrent sideloads + +Since 1.8, `Current` was empty inside a concurrent sideload, so `Current.user` read nothing in production. It now reads what it did in the controller. Workarounds that resolved `Current` values on the request thread can go. See [Concurrency](/concepts/resources#concurrency). + +
+ +## Deprecations you should fix {#deprecations-you-should-fix} + +Every name below still works, warns, and goes away in the next major. They're all pretty easy fixes though, so why not now? + +### Requires you can delete {#deprecated-requires} + +| 1.x | 2.0 | +| --- | --- | +| `require "graphiti-rails"` | remove / no longer needed | +| `require "graphiti_errors"`, `require "graphiti/responders"` | remove / no longer needed | +| `require "graphiti_spec_helpers/rspec"` | `require "graphiti/spec_helpers/rspec"` | + +### Includes and constants {#deprecated-includes} + +| 1.x | 2.0 | +| --- | --- | +| `include Graphiti::Rails` | `include Graphiti::Rails::Controller`| +| `include Graphiti::Responders` | `include Graphiti::Rails::Responders` | +| `jsonapi_context` | `graphiti_context` | +| `Graphiti::Rails::DEPRECATOR` | `Graphiti::DEPRECATOR` (the old name still resolves) | + +### Request context {#deprecated-context} + +| 1.x | 2.0 | +| --- | --- | +| `context_namespace` | `current_action` | +| `Graphiti.context[:namespace]` | `current_action` | + +### Error serializers {#deprecated-error-serializers} + +| 1.x | 2.0 | +| --- | --- | +| `GraphitiErrors::Validation::Serializer` | `Graphiti::ErrorSerializers::Validation` | +| `GraphitiErrors::InvalidRequest::Serializer` | `Graphiti::ErrorSerializers::InvalidRequest` | +| `GraphitiErrors::ConflictRequest::Serializer` | `Graphiti::ErrorSerializers::ConflictRequest` | + +### Spec helpers {#deprecated-spec-helpers} + +| 1.x | 2.0 | +| --- | --- | +| `GraphitiSpecHelpers::RSpec` / `::Sugar` / `::Errors::*` | `Graphiti::SpecHelpers::*` | +| `include Graphiti::SpecHelpers::Sugar` (`d`, `included`, `errors`, `dt`) | call `jsonapi_data`, `jsonapi_included`, `jsonapi_errors`, `json_datetime` directly | +| rspec shared contexts `"resource testing"`, `"remote api"` | `"graphiti resource testing"`, `"graphiti remote api"` | +| `GraphitiContextProxy` | `Graphiti::SpecHelpers::ContextProxy` | + +### Move these off `Graphiti.config` {#deprecated-global-config} + +These are now resource settings. Set them on `ApplicationResource` to keep the old API-wide behavior, or on individual resources to scope them. + +| 1.x | 2.0 | +| --- | --- | +| `Graphiti.config.links_on_demand = true` | `self.relationship_links = :on_demand` | +| `Graphiti.config.pagination_links = true` | `self.page_links = true` | +| `Graphiti.config.pagination_links_on_demand = true` | `self.page_links = :on_demand` | +| `Graphiti.config.typecast_reads = false` | `self.typecast_reads = false` | + +### Link rendering {#deprecated-links} + +| 1.x | 2.0 | +| --- | --- | +| `self.autolink = false` | `self.relationship_links = false` | + +Link rendering is one mode per link now. It takes `true`, `false`, or `:on_demand`, which renders only when the request asks with `?links=true`. `self.relationship_links` sets the resource default and `link:` overrides it per relationship. + +One behavior shift: `link: true` on a resource now always renders, even when the resource is `:on_demand`. Under the old global `links_on_demand` it stayed hidden until `?links=true`, so change those to `link: :on_demand`. + +### Pagination {#deprecated-pagination} + +| 1.x | 2.0 | +| --- | --- | +| `self.default_page_size = 10` | `self.page_default_size = 10` | +| `self.max_page_size = 500` | `self.page_max_size = 500` | +| `self.cursor_paginatable = true` | `self.page_cursors = true` | + +Everything relating to the `page` param shares its prefix: `page_default_size`, `page_max_size`, `page_cursors` and `page_links`. The on-demand param follows, so use `?page_links=true` (`?pagination_links=true` still works). `page_links` takes the same three modes as `relationship_links`, but has no per-relationship level. + +### Filter blanks {#deprecated-filter-blanks} + +| 1.x | 2.0 | +| --- | --- | +| `self.filters_accept_nil_by_default = true` | `self.filter_blanks_treated_as = :null` | +| `self.filters_deny_empty_by_default = true` | `self.filter_blanks_treated_as = :rejected` | +| `filter :name, allow_nil: true` | `filter :name, blanks: :null` | +| `filter :name, deny_empty: true` | `filter :name, blanks: :rejected` | + +`allow_nil:` and `deny_empty:` were two booleans answering one question, and they contradicted each other on `"null"`. The empty check raised before the coercion could run. One `blanks:` option replaces them, taking `:literal`, `:null` or `:rejected`, defaulted by `filter_blanks_treated_as`. + +### Endpoint validation {#deprecated-endpoint-validation} + +| 1.x | 2.0 | +| --- | --- | +| `self.validate_endpoints = false` | `self.validate_requests = false`, `self.validate_links = false` | + +`validate_endpoints` did two unrelated jobs, so it split. `validate_requests` refuses requests to undeclared endpoints, and `validate_links` refuses to render links to unroutable ones. The old name sets both, and turning off link validation no longer disarms the inbound guard. + +### Relationship resource ids {#deprecated-resource-ids} + +| 1.x | 2.0 | +| --- | --- | +| `always_include_resource_ids: true` on a relationship | `resource_ids: true` | + +The resource-wide version of this setting was removed rather than deprecated. See [`always_include_resource_ids_by_default`](#removed-outright) below. + +## Removed outright {#removed-outright} + +| 1.x | 2.0 | +| --- | --- | +| `include GraphitiErrors` | `include Graphiti::Rails::Controller`, and `register_exception` is on every controller either way | +| `GraphitiErrors::ExceptionHandler` | subclass `Graphiti::Rails::ExceptionHandler` | +| `GraphitiErrors.enable!` / `.disable!` | `handle_request_exceptions` | +| `self.always_include_resource_ids_by_default` | `self.belongs_to_resource_ids_by_default`, which takes `:foreign_key`, `:always` or `:never` | +| `Adapters::ActiveRecord#create` / `#update` | override `#save` | + +`always_include_resource_ids_by_default` raises `NoMethodError` at class-definition time. It applied to every relationship type, and only a `belongs_to` can render resource ids without loading an association, so the replacement covers `belongs_to` alone. `= false` becomes `:never`. There is no equivalent of `= true`, because arming every collection API-wide is the behavior it was removed for. Use `:always` for `belongs_to`. + +## Without Rails {#without-rails} + +
+Using the error serializers and exception handling outside Rails + +The serializers move but keep working: `Graphiti::ErrorSerializers::Validation`, `::InvalidRequest` and `::ConflictRequest` load with core and need no Rails. + +`GraphitiErrors::ExceptionHandler`, which turned any exception into a JSON:API errors payload, is replaced by `RescueRegistry::ExceptionHandler`, a runtime dependency now, and usable outside Rails: + +```ruby +require "rack" # or RescueRegistry::ExceptionHandler raises NameError on Rack +require "rescue_registry" + +handler = RescueRegistry::ExceptionHandler.new(exception, status: 404) +handler.build_payload # => {errors: [{code: :not_found, status: "404", ...}]} +handler.formatted_response(:json) # => [404, "{\"errors\":[...]}", :json] +``` + +`register_exception` and the rendering are Rails-only, but rescue_registry ships `RescueRegistry::ShowExceptions`, a Rack middleware for exactly this case. See its README. + +`GraphitiErrors.logger` has no replacement. `Graphiti.logger` is the nearest thing. + +
diff --git a/website/versioned_sidebars/version-2.0-sidebars.json b/website/versioned_sidebars/version-2.0-sidebars.json new file mode 100644 index 00000000..154b725d --- /dev/null +++ b/website/versioned_sidebars/version-2.0-sidebars.json @@ -0,0 +1,84 @@ +{ + "docs": [ + "intro", + "getting-started/installation", + "upgrading", + "concepts/overview", + { + "type": "category", + "label": "API Reference", + "collapsed": false, + "items": [ + "concepts/resources", + "concepts/relationships", + "concepts/persisting", + "concepts/endpoints", + "concepts/links" + ] + }, + { + "type": "category", + "label": "Topics", + "items": [ + "topics/testing", + "topics/error-handling", + "topics/authorization", + "topics/debugging", + "topics/caching", + "topics/etags", + "topics/json-attributes", + "topics/customizing-sideloads", + "concepts/backends-and-models", + "topics/without-activerecord", + "topics/openstruct-models", + "topics/remote-resources", + "topics/hopping-relationships" + ] + }, + { + "type": "category", + "label": "JavaScript Client", + "items": [ + "js/index", + "js/installation", + "js/models", + "js/reads", + "js/writes", + "js/middleware", + "js/authentication", + "js/state-syncing", + "js/ddau", + "js/extra-params" + ] + }, + { + "type": "category", + "label": "Tutorial", + "link": { + "type": "doc", + "id": "tutorial/index" + }, + "items": [ + "getting-started/first-api", + "tutorial/step_0", + "tutorial/step_1", + "tutorial/step_2", + "tutorial/step_3", + "tutorial/step_4", + "tutorial/step_5", + "tutorial/step_6", + "tutorial/step_7", + "tutorial/step_8", + "tutorial/step_9" + ] + }, + { + "type": "category", + "label": "More", + "items": [ + "reference/vandal", + "reference/why" + ] + } + ] +} diff --git a/website/versions.json b/website/versions.json new file mode 100644 index 00000000..59f17b2b --- /dev/null +++ b/website/versions.json @@ -0,0 +1,3 @@ +[ + "2.0" +] From 83d5030ca263c9d2f077495dfc8470fd90412232 Mon Sep 17 00:00:00 2001 From: Jeff Keen Date: Fri, 4 Sep 2026 16:59:21 -0500 Subject: [PATCH 2/5] fix: attribute :id declared on an abstract resource is inherited instead of failing on the missing serializer Closes #151 --- lib/graphiti/resource/dsl.rb | 4 ++++ spec/resource_spec.rb | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/graphiti/resource/dsl.rb b/lib/graphiti/resource/dsl.rb index 48c0bb56..c416f06d 100644 --- a/lib/graphiti/resource/dsl.rb +++ b/lib/graphiti/resource/dsl.rb @@ -176,12 +176,16 @@ def all_attributes end def apply_attributes_to_serializer + return unless serializer + serializer.type(type) Util::SerializerAttributes.new(self, attributes).apply end private :apply_attributes_to_serializer def apply_extra_attributes_to_serializer + return unless serializer + Util::SerializerAttributes.new(self, extra_attributes, true).apply end diff --git a/spec/resource_spec.rb b/spec/resource_spec.rb index a450bc86..052c7d49 100644 --- a/spec/resource_spec.rb +++ b/spec/resource_spec.rb @@ -278,6 +278,25 @@ def self.name expect(klass2.serializer.ancestors[1]).to eq(klass1.serializer) end + it "inherits a custom attribute :id declared on the abstract resource" do + base = Class.new(PORO::ApplicationResource) do + self.abstract_class = true + attribute :id, :string do + "custom-#{@object.id}" + end + end + child = Class.new(base) do + def self.name + "PORO::EmployeeResource" + end + self.model = PORO::Employee + attribute :first_name, :string + end + PORO::DB.data[:employees] = [{id: 1}, {id: 2}] + + expect(JSON.parse(child.all({}).to_jsonapi)["data"].map { |row| row["id"] }).to eq(%w[custom-1 custom-2]) + end + context "when overriding type" do let(:klass1) do Class.new(app_resource) do From 125ef53e89f2a95db603f8439c4ed05455f49f2f Mon Sep 17 00:00:00 2001 From: Jeff Keen Date: Thu, 3 Sep 2026 16:21:05 -0500 Subject: [PATCH 3/5] feat: hide your database ids from clients by specifying an alternate public_id, or an obfuscation transform Clients only ever see the public id. https://graphiti.dev/concepts/resources#public-ids Closes #409 --- docs/concepts/resources.md | 48 + lib/graphiti.rb | 16 + lib/graphiti/adapters/abstract.rb | 8 + lib/graphiti/adapters/active_record.rb | 8 + .../adapters/persistence/associations.rb | 7 +- lib/graphiti/audit.rb | 15 +- lib/graphiti/audit/report.rb | 4 +- lib/graphiti/errors.rb | 36 + lib/graphiti/query.rb | 7 + lib/graphiti/request_validators/validator.rb | 11 +- lib/graphiti/resource.rb | 4 + lib/graphiti/resource/configuration.rb | 96 ++ lib/graphiti/resource/dsl.rb | 43 + lib/graphiti/resource/interface.rb | 39 + lib/graphiti/resource/persistence.rb | 29 + lib/graphiti/resource/sideloading.rb | 2 + lib/graphiti/resource_proxy.rb | 9 +- lib/graphiti/runner.rb | 4 +- lib/graphiti/schema.rb | 4 + lib/graphiti/schema_diff.rb | 5 + lib/graphiti/scope.rb | 8 + lib/graphiti/scoping/default_filter.rb | 2 + lib/graphiti/scoping/filter.rb | 99 +- lib/graphiti/scoping/sort.rb | 2 +- lib/graphiti/sideload.rb | 26 +- lib/graphiti/sideload/belongs_to.rb | 60 +- lib/graphiti/sideload/has_many.rb | 35 +- lib/graphiti/sideload/many_to_many.rb | 6 +- lib/graphiti/stats/payload.rb | 23 +- lib/graphiti/util/internal_param.rb | 16 + lib/graphiti/util/link.rb | 13 +- lib/graphiti/util/persistence.rb | 2 +- lib/graphiti/util/public_id_block.rb | 15 + lib/graphiti/util/public_id_map.rb | 34 + lib/graphiti/util/public_id_sources.rb | 27 + lib/graphiti/util/serializer_relationships.rb | 8 +- spec/audit_spec.rb | 45 + spec/fixtures/legacy.rb | 21 + spec/fixtures/poro.rb | 2 +- spec/integration/rails/public_id_spec.rb | 469 +++++++ spec/public_id_spec.rb | 1090 +++++++++++++++++ spec/schema_diff_spec.rb | 54 + spec/schema_spec.rb | 19 + spec/spec_helper.rb | 4 + spec/stats/payload_spec.rb | 2 +- 45 files changed, 2422 insertions(+), 55 deletions(-) create mode 100644 lib/graphiti/util/internal_param.rb create mode 100644 lib/graphiti/util/public_id_block.rb create mode 100644 lib/graphiti/util/public_id_map.rb create mode 100644 lib/graphiti/util/public_id_sources.rb create mode 100644 spec/integration/rails/public_id_spec.rb create mode 100644 spec/public_id_spec.rb diff --git a/docs/concepts/resources.md b/docs/concepts/resources.md index dd81043b..e08185f3 100644 --- a/docs/concepts/resources.md +++ b/docs/concepts/resources.md @@ -101,6 +101,54 @@ attribute :name, :string do end ``` +### Public Ids {#public-ids} + +By default the JSON:API `id` is the model's primary key. To hide your real database ids from clients, specify a public_id column: + +```ruby +class PostResource < ApplicationResource + public_id :slug +end +``` +Now `id` is the slug everywhere a client interacts with it, including filters, sorts, stats, groups, and links. Graphiti internals like foreign keys, sideloads and joins still run on the primary key, which clients never see. + +If you don't have a dedicated column and just want to obfuscate your database ids: you provide a block to encode and decode them on the fly, using something like [Sqids](https://sqids.org): + +```ruby +class PostResource < ApplicationResource + public_id do + encode { |primary_key| Sqids.new.encode([primary_key]) } + decode { |public_id| Sqids.new.decode(public_id).first } + end +end +``` + +If someone sends an id that doesn't decode, or that decodes to something which wouldn't encode back to the same string, Graphiti treats it as not found. Your `decode` block doesn't need to validate anything. + +Declare `public_id` on an abstract resource, like `ApplicationResource`, and every subclass inherits it. + +**Types.** The public id takes its type from the ActiveRecord column, and is a string anywhere that can't be read. Pass a type to override: `public_id :code, :integer`. + +**Foreign keys.** If you expose a foreign key as a readable attribute, say `attribute :author_id, :integer`, it would print the author's real database id. So Graphiti raises when `AuthorResource` has a public id. Mark the attribute `readable: false` or `only: [:filterable]` instead. Clients can still write to it, filter on it and group by it, using the author's public id. + +If you write your own filter block, you get the value exactly as the client sent it. Ask for `primary_keys:` when you want it decoded for you: + +```ruby +filter :author_id, :string do + eq do |scope, value, primary_keys:| + scope.where(author_id: primary_keys).or(scope.where(coauthor_id: primary_keys)) + end +end +``` + +A filter that isn't named after the foreign key can decode by hand with `AuthorResource.decode_public_ids(value)`, or `decode_public_id` for one. + +**Links.** Relationships link by public id in both directions. For a `belongs_to` that means one extra query per relationship to translate the foreign keys in the response, or no query at all with the encode and decode blocks. The translation goes through the target resource's `base_scope`, so a record the current user can't see simply renders no linkage. + +When Graphiti can't build a link without exposing a real id, it leaves the link out. That happens for a `belongs_to` whose `primary_key` isn't something the target can filter on, and for a `has_many` whose child filter has a custom block that doesn't ask for `primary_keys:`. Run `bin/rake graphiti:audit` to see which relationships are affected, and give any of them a `link` block if you'd rather build the URL yourself. If two parent resources both link into the same child filter, one of them needs an `inverse_filter`. + +**Schema.** `schema.json` records the public id column, or `true` when you're encoding, and the schema check flags any change to it. Every id a client has stored would stop working. + ### Types {#types} | Type | Notes | diff --git a/lib/graphiti.rb b/lib/graphiti.rb index 09bcfd57..6a13c904 100644 --- a/lib/graphiti.rb +++ b/lib/graphiti.rb @@ -92,6 +92,18 @@ def self.resources @resources ||= [] end + def self.public_id_sources + @public_id_sources ||= Util::PublicIdSources.new + end + + def self.public_ids_declared? + !!@public_ids_declared + end + + def self.public_ids_declared=(value) + @public_ids_declared = value + end + # Every guarded relationship, e.g. ["EmployeeResource.positions"] def self.guarded_relationships ::Rails.application.eager_load! if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application @@ -226,6 +238,10 @@ def self.cache require "graphiti/util/simple_errors" require "graphiti/util/transaction_hooks_recorder" require "graphiti/util/attribute_check" +require "graphiti/util/internal_param" +require "graphiti/util/public_id_block" +require "graphiti/util/public_id_map" +require "graphiti/util/public_id_sources" require "graphiti/util/serializer_attributes" require "graphiti/util/serializer_relationships" require "graphiti/util/class" diff --git a/lib/graphiti/adapters/abstract.rb b/lib/graphiti/adapters/abstract.rb index 165682c5..bfe4269e 100644 --- a/lib/graphiti/adapters/abstract.rb +++ b/lib/graphiti/adapters/abstract.rb @@ -68,6 +68,14 @@ def filter_string_not_eql(scope, attribute, value) raise Errors::AdapterNotImplemented.new(self, attribute, :filter_string_not_eql) end + def filter_public_id_eq(scope, attribute, value) + filter_string_eq(scope, attribute, value) + end + + def filter_public_id_not_eq(scope, attribute, value) + filter_string_not_eq(scope, attribute, value) + end + def filter_string_prefix(scope, attribute, value) raise Errors::AdapterNotImplemented.new(self, attribute, :filter_string_prefix) end diff --git a/lib/graphiti/adapters/active_record.rb b/lib/graphiti/adapters/active_record.rb index 7887349d..c4e7b9ce 100644 --- a/lib/graphiti/adapters/active_record.rb +++ b/lib/graphiti/adapters/active_record.rb @@ -61,6 +61,14 @@ def filter_string_not_eql(scope, attribute, value) filter_string_eql(scope, attribute, value.presence, is_not: true) end + def filter_public_id_eq(scope, attribute, value) + filter_string_eql(scope, attribute, value) + end + + def filter_public_id_not_eq(scope, attribute, value) + filter_string_not_eql(scope, attribute, value) + end + # Arel has different match escaping behavior before rails 5. # Since rails 4.x does not expose methods to escape LIKE statements # anyway, we just don't support proper LIKE escaping in those versions. diff --git a/lib/graphiti/adapters/persistence/associations.rb b/lib/graphiti/adapters/persistence/associations.rb index 3be459ff..3d06ccee 100644 --- a/lib/graphiti/adapters/persistence/associations.rb +++ b/lib/graphiti/adapters/persistence/associations.rb @@ -63,11 +63,16 @@ def update_foreign_key(parent_object, attrs, x) if x[:sideload].polymorphic_has_one? || x[:sideload].polymorphic_has_many? attrs[:"#{x[:sideload].polymorphic_as}_type"] = polymorphic_type_value(parent_object) end - attrs[x[:foreign_key]] = parent_object.send(x[:primary_key]) + attrs[x[:foreign_key]] = internal_foreign_key(parent_object.send(x[:primary_key])) update_foreign_type(attrs, x) if x[:is_polymorphic] end end + # Resource#assign decodes public ids out of foreign keys, and must leave a key Graphiti resolved itself alone. + def internal_foreign_key(value) + Graphiti.public_ids_declared? ? Graphiti::Util::InternalParam.new(value) : value + end + def polymorphic_type_value(parent_object) parent_object.class.name end diff --git a/lib/graphiti/audit.rb b/lib/graphiti/audit.rb index 8f8dd378..640c8031 100644 --- a/lib/graphiti/audit.rb +++ b/lib/graphiti/audit.rb @@ -79,7 +79,8 @@ def row_for(resource_class, name, sideload, model) findings: [ missing_association_method(sideload, model), missing_guard_method(resource_class, sideload), - missing_sideload_filter(sideload) + missing_sideload_filter(sideload), + link_hidden(sideload) ].compact ) rescue => error @@ -195,6 +196,18 @@ def missing_sideload_filter(sideload) ) end + def link_hidden(sideload) + return if sideload.link_proc || sideload.link_hides_primary_key? + return unless sideload.requested_link_mode + + Finding.new( + severity: :warning, + check: :link_hidden, + message: "#{target_name(sideload)} publishes a public id the relationship cannot translate to", + remedy: "declare the filter the relationship links through on the related resource, have its block take `primary_keys:`, or give the relationship a `link` block" + ) + end + def builds_its_own_query?(sideload) sideload.class.params_proc || sideload.class.scope_proc end diff --git a/lib/graphiti/audit/report.rb b/lib/graphiti/audit/report.rb index 0de48ca4..59cc3b6e 100644 --- a/lib/graphiti/audit/report.rb +++ b/lib/graphiti/audit/report.rb @@ -7,7 +7,8 @@ class Report broken_relationship: "raised while being inspected", missing_association_method: "will raise when the relationship is included: the model has no association method", missing_guard_method: "will raise whenever the resource renders: the readable guard is not defined", - missing_sideload_filter: "will raise when the relationship is included: the related resource is missing the filter" + missing_sideload_filter: "will raise when the relationship is included: the related resource is missing the filter", + link_hidden: "renders no related link: it would expose a primary key the related resource hides" }.freeze CHECKLIST = { @@ -15,6 +16,7 @@ class Report missing_association_method: ["all association methods defined", "association method", "missing"], missing_guard_method: ["all readable guards defined", "readable guard", "missing"], missing_sideload_filter: ["all sideload filters declared", "sideload filter", "missing"], + link_hidden: ["all relationship links renderable", "relationship link", "hidden"], loads_on_every_render: ["all id-rendering loads preloaded", "relationship", "loading ids without preloading"] }.freeze diff --git a/lib/graphiti/errors.rb b/lib/graphiti/errors.rb index 6d54cc4e..1551b232 100644 --- a/lib/graphiti/errors.rb +++ b/lib/graphiti/errors.rb @@ -740,6 +740,42 @@ def message end end + class ConflictingPublicIdSource < Base + def initialize(resource_class, filter_name, registered, claimant) + @resource_class = resource_class + @filter_name = filter_name + @registered = registered + @claimant = claimant + end + + def message + "#{@resource_class.name}: filter #{@filter_name.inspect} already resolves public ids through #{@registered.name}, so #{@claimant.name} cannot claim it too. Give one of the relationships a different `inverse_filter`." + end + end + + class InvalidPublicId < Base + def initialize(resource_class, reason) + @resource_class = resource_class + @reason = reason + end + + def message + "#{@resource_class.name}: public_id #{@reason}." + end + end + + class PublicIdLeak < Base + def initialize(resource_class, attribute_name, source_resource_class) + @resource_class = resource_class + @attribute_name = attribute_name + @source_resource_class = source_resource_class + end + + def message + "#{@resource_class.name}: attribute #{@attribute_name.inspect} is readable, but it holds the primary key #{@source_resource_class.name} hides behind its public id. Declare it `only: [:filterable]` or drop it." + end + end + class UnselectedForeignKey < Base def initialize(resource_class, sideload, model) @resource_class = resource_class diff --git a/lib/graphiti/query.rb b/lib/graphiti/query.rb index 40f8cc95..dfefa40d 100644 --- a/lib/graphiti/query.rb +++ b/lib/graphiti/query.rb @@ -26,6 +26,7 @@ def initialize(resource, params, *positional, association_name: nil, nested_incl @action = parse_action(action) @entity_map = Concurrent::Map.new if parents.empty? @association_owners = Concurrent::Map.new if parents.empty? + @public_id_maps = Concurrent::Map.new if parents.empty? end def association? @@ -45,6 +46,12 @@ def association_owners @association_owners end + def public_id_maps + return root.public_id_maps unless root == self + + @public_id_maps + end + # Deduplication exists for a row two include paths both resolve, so a # resource class sitting at one node has nothing to collide with. def repeated_resource_classes diff --git a/lib/graphiti/request_validators/validator.rb b/lib/graphiti/request_validators/validator.rb index 3026cd25..66d1597e 100644 --- a/lib/graphiti/request_validators/validator.rb +++ b/lib/graphiti/request_validators/validator.rb @@ -108,7 +108,7 @@ def typecast_attributes(resource, attributes, action, payload_path) resource.class.config[:attributes][:id][:writable] == false begin - attributes[key] = resource.typecast(key, value, :writable) + attributes[key] = typecast_attribute(resource, key, value) rescue Graphiti::Errors::UnknownAttribute @errors.add(fully_qualified_key(key, payload_path), :unknown_attribute) rescue Graphiti::Errors::InvalidAttributeAccess @@ -120,6 +120,15 @@ def typecast_attributes(resource, attributes, action, payload_path) end end + # A writable foreign key arrives as the public id of the record it names, and is decoded when assigned. + def typecast_attribute(resource, key, value) + source = (key == :id) ? nil : resource.class.public_id_source_for(key) + return resource.typecast(key, value, :writable) unless source + + resource.get_attr!(key, :writable, request: true) + source.new.typecast(:id, value, :filterable) + end + def normalized_params(raw_params) normalized = raw_params if normalized.respond_to?(:to_unsafe_h) diff --git a/lib/graphiti/resource.rb b/lib/graphiti/resource.rb index c882ad55..b9bded98 100644 --- a/lib/graphiti/resource.rb +++ b/lib/graphiti/resource.rb @@ -150,6 +150,10 @@ def persist_with_relationships(meta, attributes, relationships, caller_model = n persistence.run end + def model_attribute_for(name) + self.class.model_attribute_for(name) + end + def stat(attribute, calculation) stats_dsl = stats[attribute] || stats[attribute.to_sym] raise Errors::StatNotFound.new(attribute, calculation) unless stats_dsl diff --git a/lib/graphiti/resource/configuration.rb b/lib/graphiti/resource/configuration.rb index bab99308..955ed8b4 100644 --- a/lib/graphiti/resource/configuration.rb +++ b/lib/graphiti/resource/configuration.rb @@ -129,6 +129,12 @@ def remote=(val) } end + def model=(val) + super + apply_public_id if publishes_public_id? + config[:sideloads].each_value { |sideload| sideload.register_public_id_source if eagerly_apply_sideload?(sideload) } + end + def model klass = super unless klass || abstract_class? @@ -241,6 +247,96 @@ def cursor_paginatable? !!page_cursors end + def publishes_public_id? + !!(config[:public_id] || config[:public_id_encode]) + end + + def public_id_attribute?(name) + !!config[:public_id] && [:id, :_public_id].include?(name.to_sym) + end + + def model_declared? + !!model + rescue Errors::ModelNotFound + false + end + + def public_id_for(model) + if (encode = config[:public_id_encode]) + encode.call(model.send(model_primary_key)) + else + model.send(config[:public_id]) + end + end + + def model_attribute_for(name) + return name unless publishes_public_id? + + case name + when :id then config[:public_id] || model_primary_key + when :_primary_key then model_primary_key + when :_public_id then config[:public_id] + else name + end + end + + def model_primary_key + if model.respond_to?(:primary_key) + model.primary_key.to_sym + else + :id + end + end + + def inferred_public_id_type + column = config[:public_id] + return :string unless column && model_loaded? && model.respond_to?(:type_for_attribute) + + case model.type_for_attribute(column.to_s).type + when :integer then :integer + when :uuid then :uuid + else :string + end + end + + def model_loaded? + model + true + rescue Errors::ModelNotFound + false + end + + def guard_public_id_leak!(attribute_name) + return if attribute_name.to_sym == :id + + attribute = attributes[attribute_name.to_sym] + return unless attribute && attribute[:readable] && !attribute[:proc] + + source = public_id_source_for(attribute_name) + raise Errors::PublicIdLeak.new(self, attribute_name, source) if source + end + + # A custom block owns its value, so the generated link can only carry a public id if the block asked to decode it. + def filter_accepts_public_ids?(filter_name) + filter = filters[filter_name.to_sym] + return false unless filter + + filter[:operators][:eq].nil? || Array(filter[:operators_taking_primary_keys]).include?(:eq) + end + + def public_id_source_for(filter_name) + return unless Graphiti.public_ids_declared? + + name = filter_name.to_sym + return self if name == :id && config[:public_id_decode] + + source = Graphiti.public_id_sources[self, name] + return source if source + + sideload = sideloads.values.find { |candidate| candidate.type == :belongs_to && candidate.foreign_key == name } + sideload.resource_class if sideload&.primary_key_filter == :_primary_key + end + def get_attr!(name, flag, opts = {}) opts[:raise_error] = true get_attr(name, flag, opts) diff --git a/lib/graphiti/resource/dsl.rb b/lib/graphiti/resource/dsl.rb index c416f06d..ee424313 100644 --- a/lib/graphiti/resource/dsl.rb +++ b/lib/graphiti/resource/dsl.rb @@ -39,7 +39,9 @@ def filter(name, *args, &blk) dependencies: opts[:dependent], required: required, schema: schema, + internal: !!opts[:internal], operators: operators.to_hash, + operators_taking_primary_keys: operators_taking_primary_keys(operators.to_hash), blanks: blanks_for(name, opts) } elsif (type = args[0]) @@ -131,7 +133,9 @@ def attribute(name, type, options = {}, &blk) attribute_option(options, :schema, true) options[:type] = type options[:proc] = blk + options[:proc] ||= public_id_proc if name == :id && publishes_public_id? config[:attributes][name] = options + guard_public_id_leak!(name) apply_attributes_to_serializer options[:sortable] ? sort(name) : config[:sorts].delete(name) @@ -142,6 +146,44 @@ def attribute(name, type, options = {}, &blk) end end + def public_id(name = nil, type = nil, &blk) + raise Errors::InvalidPublicId.new(self, "takes a column name or a block, not both") if name && blk + raise Errors::InvalidPublicId.new(self, "needs a column name or a block") unless name || blk + + if blk + block = Util::PublicIdBlock.new + block.instance_eval(&blk) + raise Errors::InvalidPublicId.new(self, "block must define both encode and decode") unless block.encoder && block.decoder + config[:public_id_encode] = block.encoder + config[:public_id_decode] = block.decoder + end + config[:public_id] = name + config[:public_id_type] = type + Graphiti.public_ids_declared = true + apply_public_id + end + + def operators_taking_primary_keys(operators) + operators.select { |_, block| block && takes_primary_keys?(block) }.keys + end + + def takes_primary_keys?(block) + block.parameters.any? { |kind, name| kind == :keyrest || (name == :primary_keys && %i[key keyreq].include?(kind)) } + end + + # The hidden filter only ever sees keys Graphiti read off models, so its type is never used to cast anything. + def public_id_proc + proc { @resource.class.public_id_for(@object) } + end + + def apply_public_id + attribute :id, config[:public_id_type] || inferred_public_id_type, &public_id_proc + attribute :_primary_key, :integer_id, only: [:filterable], schema: false + filter :_primary_key, schema: false, internal: true + attribute :_public_id, config[:public_id_type] || inferred_public_id_type, only: [:filterable], schema: false + filter :_public_id, schema: false, internal: true + end + def extra_attribute(name, type, options = {}, &blk) raise Errors::TypeNotFound.new(self, name, type) unless Types[type] defaults = { @@ -175,6 +217,7 @@ def all_attributes attributes.merge(extra_attributes) end + # An abstract resource has no serializer until a concrete subclass is given one. def apply_attributes_to_serializer return unless serializer diff --git a/lib/graphiti/resource/interface.rb b/lib/graphiti/resource/interface.rb index efb580ad..2a61990e 100644 --- a/lib/graphiti/resource/interface.rb +++ b/lib/graphiti/resource/interface.rb @@ -27,6 +27,40 @@ def find(params = {}, base_scope = nil) _find(params, base_scope) end + # @api private + def public_ids_by(filter_name, keys) + encode = config[:public_id_encode] + return keys.to_h { |key| [key, encode.call(key)] } if encode && filter_name == :_primary_key + + attribute = model_attribute_for(filter_name) + value = (filter_name == :_primary_key) ? Util::InternalParam.new(keys) : keys.join(",") + translate_ids(filter_name => value).each_with_object({}) do |record, map| + map[record.send(attribute)] = public_id_for(record) + end + end + + # @api private + # Sqids decodes any string in its alphabet, so "42" yields some unrelated record. + # A decode only counts if encoding the result gives back the string that was sent. + def decode_encoded_id(public_id) + primary_key = config[:public_id_decode].call(public_id) + return if primary_key.nil? + + primary_key if config[:public_id_encode].call(primary_key) == public_id + end + + def decode_public_id(public_id) + decode_public_ids([public_id]).first + end + + def decode_public_ids(public_ids) + if config[:public_id_decode] + return public_ids.map { |public_id| decode_encoded_id(public_id) }.compact + end + + translate_ids(_public_id: Util::InternalParam.new(public_ids)).map { |record| record.send(model_primary_key) } + end + # @api private def _find(params = {}, base_scope = nil) guard_nil_id!(params[:data]) @@ -79,6 +113,11 @@ def caching_options {cache: @cache_resource, cache_expires_in: @cache_expires_in, cache_tag: @cache_tag} end + def translate_ids(filter) + opts = {default_paginate: false, bypass_required_filters: true, bypass_default_filters: true, translating_public_ids: true} + _all({filter: filter}, opts, nil).data + end + def validate_request!(params) return if Graphiti.context[:graphql] || !validate_requests? diff --git a/lib/graphiti/resource/persistence.rb b/lib/graphiti/resource/persistence.rb index 48d0d22f..290657db 100644 --- a/lib/graphiti/resource/persistence.rb +++ b/lib/graphiti/resource/persistence.rb @@ -78,8 +78,16 @@ def assign(assign_params, meta = nil, action_name = nil, model_instance: nil) if action_name == :update id = assign_params[:id] assign_params = assign_params.except(:id) + elsif assign_params.key?(:id) && (public_id = self.class.config[:public_id]) + assign_params = assign_params.except(:id).merge(public_id => assign_params[:id]) + elsif assign_params.key?(:id) && self.class.config[:public_id_decode] + primary_key = self.class.decode_public_id(assign_params[:id]) + raise Errors::ConflictRequest, unresolvable_id_errors(assign_params) if primary_key.nil? + assign_params = assign_params.except(:id).merge(self.class.model_primary_key => primary_key) end + assign_params = decode_foreign_keys(assign_params) + run_callbacks :attributes, action_name, assign_params, meta do |params| model_instance ||= if action_name == :update self.class._find(id: id).data @@ -123,6 +131,27 @@ def create(create_params, meta = nil) model_instance end + def decode_foreign_keys(assign_params) + return assign_params unless Graphiti.public_ids_declared? + + assign_params.each_with_object({}) do |(name, value), decoded| + source = (name == :id || value.nil?) ? nil : self.class.public_id_source_for(name) + decoded[name] = if value.is_a?(Util::InternalParam) + value.value + elsif source + source.decode_public_id(value.to_s) or raise Errors::RecordNotFound.new(source.type, value, name) + else + value + end + end + end + + def unresolvable_id_errors(assign_params) + Util::SimpleErrors.new(assign_params).tap do |errors| + errors.add("data.id", :unresolvable, message: "does not name a record of this resource") + end + end + def update(update_params, meta = nil) model_instance = assigned_model || assign(update_params, meta, :update) diff --git a/lib/graphiti/resource/sideloading.rb b/lib/graphiti/resource/sideloading.rb index 47e11357..5808b9ec 100644 --- a/lib/graphiti/resource/sideloading.rb +++ b/lib/graphiti/resource/sideloading.rb @@ -23,10 +23,12 @@ def allow_sideload(name, opts = {}, &blk) end def apply_sideload_to_serializer(name) + config[:sideloads][name].register_public_id_source Util::SerializerRelationships.new(self, config[:sideloads].slice(name)).apply end def apply_sideloads_to_serializer + config[:sideloads].each_value(&:register_public_id_source) Util::SerializerRelationships.new(self, config[:sideloads]).apply end diff --git a/lib/graphiti/resource_proxy.rb b/lib/graphiti/resource_proxy.rb index 13d1d46e..08c072ef 100644 --- a/lib/graphiti/resource_proxy.rb +++ b/lib/graphiti/resource_proxy.rb @@ -125,14 +125,17 @@ def stats scope = @scope.unpaginated_object if resource.adapter.can_group? if (group = @query.hash[:stats].delete(:group_by)) - resource.get_attr!(group[0], :readable, request: true) - scope = resource.adapter.group(scope, group[0]) + # A foreign key whose values render as public ids exposes nothing, so it may group even when unreadable. + flag = resource.class.public_id_source_for(group[0]) ? :filterable : :readable + resource.get_attr!(group[0], flag, request: true) + scope = resource.adapter.group(scope, resource.model_attribute_for(group[0])) end end payload = Stats::Payload.new @resource, @query, scope, - data + data, + group_by: group&.first payload.generate else {} diff --git a/lib/graphiti/runner.rb b/lib/graphiti/runner.rb index 8bad38ea..56c38fe2 100644 --- a/lib/graphiti/runner.rb +++ b/lib/graphiti/runner.rb @@ -70,7 +70,9 @@ def proxy(base = nil, opts = {}) :sideload, :parent, :params, - :bypass_required_filters + :bypass_required_filters, + :bypass_default_filters, + :translating_public_ids ) scope = jsonapi_scope(base, scope_opts) diff --git a/lib/graphiti/schema.rb b/lib/graphiti/schema.rb index a8b89150..828f56ef 100644 --- a/lib/graphiti/schema.rb +++ b/lib/graphiti/schema.rb @@ -103,6 +103,10 @@ def generate_resources stats: stats(r) } + if r.publishes_public_id? + config[:public_id] = r.config[:public_id]&.to_s || true + end + if r.grouped_filters.any? config[:filter_group] = r.grouped_filters end diff --git a/lib/graphiti/schema_diff.rb b/lib/graphiti/schema_diff.rb index fa12df0a..c0bb3405 100644 --- a/lib/graphiti/schema_diff.rb +++ b/lib/graphiti/schema_diff.rb @@ -46,6 +46,11 @@ def compare_resource(old_resource, new_resource) if old_resource[:type] != new_resource[:type] @errors << "#{old_resource[:name]} changed type from #{old_resource[:type].inspect} to #{new_resource[:type].inspect}." end + + # Every id a client holds, and every url built from one, stops resolving. + if old_resource[:public_id] != new_resource[:public_id] + @errors << "#{old_resource[:name]} changed public_id from #{old_resource[:public_id].inspect} to #{new_resource[:public_id].inspect}." + end yield end diff --git a/lib/graphiti/scope.rb b/lib/graphiti/scope.rb index b104252a..5b602d45 100644 --- a/lib/graphiti/scope.rb +++ b/lib/graphiti/scope.rb @@ -153,6 +153,7 @@ def sync_resolve(&blk) def sync_resolve_sideloads(results) return if results == [] + collect_foreign_keys(results) reset_captured_sideload_proxies each_applicable_sideload do |name, sideload, sideload_query| Graphiti.config.before_sideload&.call(Graphiti.context) @@ -221,6 +222,12 @@ def overlapping_sideloads? false end + def collect_foreign_keys(results) + return unless Graphiti.public_ids_declared? && !@opts[:translating_public_ids] + + @resource.class.sideloads.each_value { |sideload| sideload.collect_foreign_keys(results, @query) } + end + def each_applicable_sideload @query.sideloads.each_pair do |name, sideload_query| sideload = @resource.class.sideload(name) @@ -246,6 +253,7 @@ def capture_sideload_proxy(name, proxy) def future_resolve_sideloads(results) return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == [] + collect_foreign_keys(results) reset_captured_sideload_proxies sideload_promises = [] each_applicable_sideload do |name, sideload, sideload_query| diff --git a/lib/graphiti/scoping/default_filter.rb b/lib/graphiti/scoping/default_filter.rb index 7208f3d9..608b4861 100644 --- a/lib/graphiti/scoping/default_filter.rb +++ b/lib/graphiti/scoping/default_filter.rb @@ -36,6 +36,8 @@ class Scoping::DefaultFilter < Scoping::Base # # @return scope the scope object we are chaining/modifying def apply + return @scope if @opts[:bypass_default_filters] + resource.default_filters.each_pair do |name, opts| next if overridden?(name) @scope = resource.instance_exec(@scope, resource.context, &opts[:filter]) diff --git a/lib/graphiti/scoping/filter.rb b/lib/graphiti/scoping/filter.rb index bb7bead1..e4df0426 100644 --- a/lib/graphiti/scoping/filter.rb +++ b/lib/graphiti/scoping/filter.rb @@ -19,8 +19,8 @@ def apply resource, missing_dependent_filters end - each_filter do |filter, operator, value| - @scope = filter_scope(filter, operator, value) + each_filter do |filter, operator, value, primary_keys, decoded| + @scope = filter_scope(filter, operator, value, primary_keys, decoded) end resource.after_filtering(@scope) @@ -28,18 +28,24 @@ def apply private - def filter_scope(filter, operator, value) + def filter_scope(filter, operator, value, primary_keys, decoded) if (custom_scope = filter.values[0][:operators][operator]) - @resource.instance_exec(@scope, value, resource.context, &custom_scope) + if takes_primary_keys?(filter, operator) + @resource.instance_exec(@scope, value, resource.context, primary_keys: primary_keys, &custom_scope) + else + @resource.instance_exec(@scope, value, resource.context, &custom_scope) + end else - filter_via_adapter(filter, operator, value) + filter_via_adapter(filter, operator, value, decoded) end end - def filter_via_adapter(filter, operator, value) - type_name = Types.name_for(filter.values.first[:type]) + # A foreign key filter holds primary keys once its public ids are decoded, whatever type the attribute renders as. + def filter_via_adapter(filter, operator, value, decoded = false) + type_name = Types.name_for(decoded ? :integer_id : filter.values.first[:type]) + type_name = :public_id if type_name == :string && [:eq, :not_eq].include?(operator) && resource.class.public_id_attribute?(filter.keys.first) method = :"filter_#{type_name}_#{operator}" - attribute = filter.keys.first + attribute = resource.model_attribute_for(filter.keys.first) if resource.adapter.respond_to?(method) resource.adapter.send(method, @scope, attribute, value) @@ -53,27 +59,80 @@ def each_filter filter_param.each_pair do |param_name, param_value| filter = find_filter!(param_name) + internal = param_value.is_a?(Util::InternalParam) + if internal + param_value = param_value.value + elsif filter.values[0][:internal] + raise Errors::InvalidAttributeAccess.new(resource, param_name, :filterable, request: true) + else + source = resource.class.public_id_source_for(filter.keys[0]) + end + normalize_param(filter, param_value).each do |operator, value| operator = operator.to_s.gsub("!", "not_").to_sym validate_operator(filter, operator) + custom = filter.values[0][:operators][operator] + decoded = !!source && !custom + value = decode_public_ids(source, value) if decoded - type = Types[filter.values[0][:type]] - unless type[:canonical_name] == :hash || !value.is_a?(String) - value = parse_string_value(filter.values[0], value) - end - - check_blank_filters!(resource, filter, value) - value = parse_string_null(filter.values[0], value) - validate_singular(resource, filter, value) - value = coerce_types(filter.values[0], param_name.to_sym, value) - validate_allowlist(resource, filter, value) - validate_denylist(resource, filter, value) + decoding = source && takes_primary_keys?(filter, operator) + value = validated_and_cast(filter, param_name, value, decoding ? source : nil) unless internal value = value[0] if filter.values[0][:single] - yield filter, operator, value + primary_keys = decoded_for_block(filter, operator, source, value) + yield filter, operator, value, primary_keys, decoded end end end + # A block asking for primary_keys: was sent public ids, which cast like the source's id rather than the key. + def validated_and_cast(filter, param_name, value, public_id_source) + type = Types[filter.values[0][:type]] + unless type[:canonical_name] == :hash || !value.is_a?(String) + value = parse_string_value(filter.values[0], value) + end + + check_blank_filters!(resource, filter, value) + value = parse_string_null(filter.values[0], value) + validate_singular(resource, filter, value) + value = public_id_source ? cast_as_public_ids(public_id_source, value) : coerce_types(filter.values[0], param_name.to_sym, value) + validate_allowlist(resource, filter, value) + validate_denylist(resource, filter, value) + value + end + + def cast_as_public_ids(source, value) + source_instance = source.new + Array(value).map { |public_id| source_instance.typecast(:id, public_id, :filterable) } + end + + def takes_primary_keys?(filter, operator) + Array(filter.values[0][:operators_taking_primary_keys]).include?(operator) + end + + def decoded_for_block(filter, operator, source, value) + return unless takes_primary_keys?(filter, operator) + source ||= resource.class if filter.keys[0] == :id && resource.class.publishes_public_id? + return value unless source + + keys = decode_public_ids(source, value) + filter.values[0][:single] ? keys.first : keys + end + + def decode_public_ids(source_resource_class, value) + case value + when Hash + value.transform_values { |operand| decode_public_ids(source_resource_class, operand) } + when Array + lookup_primary_keys(source_resource_class, value) + else + lookup_primary_keys(source_resource_class, value.to_s.split(",")) + end + end + + def lookup_primary_keys(source_resource_class, public_ids) + source_resource_class.decode_public_ids(public_ids) + end + def coerce_types(filter, name, value) type_name = filter[:type] is_array = type_name.to_s.starts_with?("array_of") || diff --git a/lib/graphiti/scoping/sort.rb b/lib/graphiti/scoping/sort.rb index 7eb98364..e6722ea9 100644 --- a/lib/graphiti/scoping/sort.rb +++ b/lib/graphiti/scoping/sort.rb @@ -31,7 +31,7 @@ def apply_standard_scope @scope = if sort[:proc] resource.instance_exec(@scope, direction, &sort[:proc]) else - resource.adapter.order(@scope, attribute, direction) + resource.adapter.order(@scope, resource.model_attribute_for(attribute), direction) end end end diff --git a/lib/graphiti/sideload.rb b/lib/graphiti/sideload.rb index ab3d9d6b..800287ea 100644 --- a/lib/graphiti/sideload.rb +++ b/lib/graphiti/sideload.rb @@ -198,8 +198,18 @@ def default_render_resource_ids? # A custom link block means the author wants the link, so a false default does not silence it. def link_mode - return @link unless @link.nil? + mode = requested_link_mode + return false unless mode + return false unless link_proc || link_hides_primary_key? + mode + end + + def requested_link_mode + @link.nil? ? default_link_mode : @link + end + + def default_link_mode default = @parent_resource_class.relationship_links (link_proc && default == false) ? true : default end @@ -208,6 +218,20 @@ def link? link_mode != false end + def link_hides_primary_key? + true + end + + def collect_foreign_keys(parents, query) + end + + def register_public_id_source + end + + def rendered_id_for(foreign_key, query) + foreign_key + end + def link_filter(parents) base_filter(parents) end diff --git a/lib/graphiti/sideload/belongs_to.rb b/lib/graphiti/sideload/belongs_to.rb index 5332f7e5..8bae1245 100644 --- a/lib/graphiti/sideload/belongs_to.rb +++ b/lib/graphiti/sideload/belongs_to.rb @@ -17,7 +17,7 @@ def renderable_at_all? def resource_ids_blocker return :unreadable unless renderable_at_all? - return :custom_primary_key unless foreign_key_is_related_id? + return :custom_primary_key unless foreign_key_is_related_id? || resolves_public_ids? return :polymorphic_child if polymorphic_child? return :scope_block if self.class.scope_proc return :params_block if self.class.params_proc @@ -28,12 +28,6 @@ def resource_ids_blocker nil end - # base_filter matches the foreign key against primary_key, so a custom - # primary_key means the key holds that column's value, not the related id. - def foreign_key_is_related_id? - primary_key == :id - end - def load_params(parents, query) query.hash.tap do |hash| hash[:filter] ||= {} @@ -43,7 +37,49 @@ def load_params(parents, query) def base_filter(parents) parent_ids = ids_for_parents(parents) - {primary_key => parent_ids.join(",")} + return {primary_key_filter => Graphiti::Util::InternalParam.new(parent_ids)} if primary_key_filter == :_primary_key + + {primary_key_filter => parent_ids.join(",")} + end + + def resolves_public_ids? + @resolves_public_ids ||= target_publishes_id? && resource_class.filters.key?(primary_key_filter) + end + + def link_hides_primary_key? + !target_publishes_id? || resolves_public_ids? + end + + def register_public_id_source + return unless target_publishes_id? && resource_class_loaded? && parent_resource_class.model_declared? + + parent_resource_class.guard_public_id_leak!(foreign_key) + end + + def rendered_id_for(foreign_key, query) + return foreign_key unless target_publishes_id? + return unless resolves_public_ids? + + public_id_map(query)[foreign_key] + end + + # The serializer reports a foreign key the record cannot answer with a better error. + def collect_foreign_keys(parents, query) + return unless resource_class_loaded? && resolves_public_ids? + return unless parents.first.respond_to?(foreign_key) + + public_id_map(query).add(ids_for_parents(parents)) + end + + def primary_key_filter + return primary_key unless target_publishes_id? + return :_primary_key if primary_key == :id || primary_key.to_s == resource_class.model_primary_key.to_s + + primary_key + end + + def foreign_key_is_related_id? + primary_key_filter == :id end def ids_for_parents(parents) @@ -75,6 +111,14 @@ def infer_foreign_key private + def target_publishes_id? + @target_publishes_id ||= Graphiti.public_ids_declared? && resource_class.publishes_public_id? + end + + def public_id_map(query) + query.public_id_maps.compute_if_absent(self) { Graphiti::Util::PublicIdMap.new(resource_class, primary_key_filter) } + end + def child_map(children) children.index_by(&primary_key) end diff --git a/lib/graphiti/sideload/has_many.rb b/lib/graphiti/sideload/has_many.rb index 1b485bc7..031975cc 100644 --- a/lib/graphiti/sideload/has_many.rb +++ b/lib/graphiti/sideload/has_many.rb @@ -21,11 +21,30 @@ def load_params(parents, query) end def base_filter(parents) - {foreign_key => parent_filter(parents)} + {foreign_key => internal_parent_filter(parents, foreign_key)} end def link_filter(parents) - {inverse_filter => parent_filter(parents)} + {inverse_filter => public_parent_filter(parents)} + end + + def link_hides_primary_key? + return true unless links_by_public_id? + + resource_class.public_id_source_for(inverse_filter) == parent_resource_class && + resource_class.filter_accepts_public_ids?(inverse_filter) + end + + def links_by_public_id? + @links_by_public_id ||= Graphiti.public_ids_declared? && primary_key == :id && !!parent_resource_class&.publishes_public_id? + end + + def register_public_id_source + return unless links_by_public_id? && resource_class_loaded? + return if parent_resource_class.abstract_class? || !parent_resource_class.model_loaded? + + Graphiti.public_id_sources.register(resource_class, inverse_filter, parent_resource_class) + resource_class.guard_public_id_leak!(inverse_filter) end private @@ -34,6 +53,18 @@ def parent_filter(parents) ids_for_parents(parents).join(",") end + def internal_parent_filter(parents, filter_name) + return parent_filter(parents) unless resource_class.public_id_source_for(filter_name) + + Graphiti::Util::InternalParam.new(ids_for_parents(parents)) + end + + def public_parent_filter(parents) + return parent_filter(parents) unless links_by_public_id? + + parents.map { |parent| parent_resource_class.public_id_for(parent) }.compact.uniq.join(",") + end + def child_map(children) children.group_by(&foreign_key) end diff --git a/lib/graphiti/sideload/many_to_many.rb b/lib/graphiti/sideload/many_to_many.rb index cf7dcea5..2ab5ca5f 100644 --- a/lib/graphiti/sideload/many_to_many.rb +++ b/lib/graphiti/sideload/many_to_many.rb @@ -16,7 +16,7 @@ def inverse_filter end def base_filter(parents) - {true_foreign_key => parent_filter(parents)} + {true_foreign_key => internal_parent_filter(parents, true_foreign_key)} end def infer_foreign_key @@ -39,8 +39,8 @@ def apply_belongs_to_many_filter # Do not recreate if filter already exists unless resource_class.config[:filters].has_key?(inverse_filter.to_sym) resource_class.filter inverse_filter, fk_type do - eq do |scope, value| - self_ref.belongs_to_many_filter(scope, value) + eq do |scope, value, primary_keys:| + self_ref.belongs_to_many_filter(scope, primary_keys) end end end diff --git a/lib/graphiti/stats/payload.rb b/lib/graphiti/stats/payload.rb index d098d277..956727bb 100644 --- a/lib/graphiti/stats/payload.rb +++ b/lib/graphiti/stats/payload.rb @@ -14,11 +14,12 @@ module Stats # meta: { stats: { total: { count: 100 } } } # } class Payload - def initialize(resource, query, scope, data) + def initialize(resource, query, scope, data, group_by: nil) @resource = resource @query = query @scope = scope @data = data + @group_by = group_by end # Generate the payload for +{ meta: { stats: { ... } } }+ @@ -38,14 +39,30 @@ def generate end def calculate_stat(name, function) - args = [@scope, name] + args = [@scope, @resource.model_attribute_for(name)] args << @resource.context if function.arity >= 3 args << @data if function.arity == 4 - function.call(*args) + translate_group_keys(function.call(*args)) end private + def translate_group_keys(result) + return result unless result.is_a?(Hash) && @group_by + + source = @resource.class.public_id_source_for(@group_by) + return result unless source + + public_ids = source.public_ids_by(:_primary_key, result.keys.compact) + result.each_with_object({}) do |(primary_key, value), translated| + if primary_key.nil? + translated[nil] = value + elsif public_ids.key?(primary_key) + translated[public_ids[primary_key]] = value + end + end + end + def each_calculation(name, calculations) calculations.each do |calc| function = @resource.stat(name, calc) diff --git a/lib/graphiti/util/internal_param.rb b/lib/graphiti/util/internal_param.rb new file mode 100644 index 00000000..c3180848 --- /dev/null +++ b/lib/graphiti/util/internal_param.rb @@ -0,0 +1,16 @@ +module Graphiti + module Util + # Request params can only ever be strings, arrays and hashes, so a value wrapped in this class must have come from Graphiti itself. + class InternalParam + attr_reader :value + + def initialize(value) + @value = value + end + + def blank? + value.blank? + end + end + end +end diff --git a/lib/graphiti/util/link.rb b/lib/graphiti/util/link.rb index c2f53e0c..34dc8a53 100644 --- a/lib/graphiti/util/link.rb +++ b/lib/graphiti/util/link.rb @@ -1,9 +1,10 @@ module Graphiti module Util class Link - def initialize(sideload, model) + def initialize(sideload, model, query = nil) @sideload = sideload @model = model + @query = query @linkable = true if @sideload.type == :polymorphic_belongs_to @@ -30,12 +31,18 @@ def linkable? return false if @polymorphic_sideload_not_found if @sideload.type == :belongs_to - !@model.send(@sideload.foreign_key).nil? + !related_id.nil? else @linkable end end + def related_id + return @related_id if defined?(@related_id) + + @related_id = @sideload.rendered_id_for(@model.send(@sideload.foreign_key), @query) + end + def raw_url if @sideload.link_proc @sideload.link_proc.call(@model) @@ -76,7 +83,7 @@ def path @path ||= path = @sideload.resource.endpoint[:url].to_s if @sideload.type == :belongs_to && !@sideload.remote? - path = "#{path}/#{@model.send(@sideload.foreign_key)}" + path = "#{path}/#{related_id}" end path end diff --git a/lib/graphiti/util/persistence.rb b/lib/graphiti/util/persistence.rb index a2d72f52..676d1c56 100644 --- a/lib/graphiti/util/persistence.rb +++ b/lib/graphiti/util/persistence.rb @@ -102,7 +102,7 @@ def iterate(only: [], except: []) def apply_derived_attributes(attributes, payload_attributes) return unless @assigned_model - derived = attributes.reject { |key, value| payload_attributes[key] == value } + derived = @resource.decode_foreign_keys(attributes).reject { |key, value| payload_attributes[key] == value } return if derived.empty? @resource.assign_attributes(@assigned_model, derived, metadata) diff --git a/lib/graphiti/util/public_id_block.rb b/lib/graphiti/util/public_id_block.rb new file mode 100644 index 00000000..d505fed4 --- /dev/null +++ b/lib/graphiti/util/public_id_block.rb @@ -0,0 +1,15 @@ +module Graphiti + module Util + class PublicIdBlock + attr_reader :encoder, :decoder + + def encode(&blk) + @encoder = blk + end + + def decode(&blk) + @decoder = blk + end + end + end +end diff --git a/lib/graphiti/util/public_id_map.rb b/lib/graphiti/util/public_id_map.rb new file mode 100644 index 00000000..21a11025 --- /dev/null +++ b/lib/graphiti/util/public_id_map.rb @@ -0,0 +1,34 @@ +module Graphiti + module Util + class PublicIdMap + def initialize(resource_class, filter_name) + @resource_class = resource_class + @filter_name = filter_name + @pending = Concurrent::Map.new + @resolved = {} + end + + def add(primary_keys) + primary_keys.each { |key| @pending[key] = true unless @resolved.key?(key) } + end + + def [](primary_key) + return if primary_key.nil? + + resolve(@pending.keys) unless @pending.empty? + resolve([primary_key]) unless @resolved.key?(primary_key) + @resolved[primary_key] + end + + private + + def resolve(primary_keys) + found = @resource_class.public_ids_by(@filter_name, primary_keys) + primary_keys.each do |key| + @pending.delete(key) + @resolved[key] = found[key] + end + end + end + end +end diff --git a/lib/graphiti/util/public_id_sources.rb b/lib/graphiti/util/public_id_sources.rb new file mode 100644 index 00000000..9a5845e5 --- /dev/null +++ b/lib/graphiti/util/public_id_sources.rb @@ -0,0 +1,27 @@ +module Graphiti + module Util + class PublicIdSources + def initialize + @sources = {} + end + + def register(child_resource_class, filter_name, parent_resource_class) + key = [child_resource_class.name, filter_name.to_sym] + registered = @sources[key] + if registered && registered.name != parent_resource_class.name + raise Errors::ConflictingPublicIdSource.new(child_resource_class, filter_name, registered, parent_resource_class) + end + @sources[key] = parent_resource_class + end + + def [](child_resource_class, filter_name) + klass = child_resource_class + while klass + source = @sources[[klass.name, filter_name.to_sym]] + return source if source + klass = klass.superclass + end + end + end + end +end diff --git a/lib/graphiti/util/serializer_relationships.rb b/lib/graphiti/util/serializer_relationships.rb index 92b4c5a4..89bd1b74 100644 --- a/lib/graphiti/util/serializer_relationships.rb +++ b/lib/graphiti/util/serializer_relationships.rb @@ -83,10 +83,12 @@ def block .new(resource_class_ref, sideload_ref, @object) end - unless foreign_key.nil? + related_id = sideload_ref.rendered_id_for(foreign_key, @proxy.query) + + unless related_id.nil? { type: sideload_ref.resource.type, - id: foreign_key.to_s + id: related_id.to_s } end end @@ -98,7 +100,7 @@ def block self_ref.send(:validate_link!) unless self_ref.send(:eagerly_validate_links?) link(:related) do - ::Graphiti::Util::Link.new(sideload_ref, @object).generate + ::Graphiti::Util::Link.new(sideload_ref, @object, @proxy.query).generate end end end diff --git a/spec/audit_spec.rb b/spec/audit_spec.rb index f616f48d..7fab2acf 100644 --- a/spec/audit_spec.rb +++ b/spec/audit_spec.rb @@ -9,6 +9,51 @@ def finding(resource_class, check) audit(resource_class).find { |f| f.check == check } end + describe "a link hidden behind a public id" do + let(:employee_resource) do + Class.new(PORO::EmployeeResource) do + def self.name + "PORO::EmployeeResource" + end + + public_id :public_id + end + end + + let(:resource) do + employee_resource_class = employee_resource + Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + belongs_to :employee, resource: employee_resource_class, primary_key: :nickname, link: true + end + end + + it "warns that the relationship renders no link" do + found = finding(resource, :link_hidden) + + expect(found.severity).to eq(:warning) + expect(found.message).to eq("PORO::EmployeeResource publishes a public id the relationship cannot translate to") + end + + it "stays quiet once the relationship has a link block" do + employee_resource_class = employee_resource + resource = Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + belongs_to :employee, resource: employee_resource_class, primary_key: :nickname do + link { |position| "/employees/#{position.employee_id}" } + end + end + + expect(finding(resource, :link_hidden)).to be_nil + end + end + describe "missing association method" do let(:resource) do Class.new(PORO::TeamResource) do diff --git a/spec/fixtures/legacy.rb b/spec/fixtures/legacy.rb index 9bd14d62..0660fc07 100644 --- a/spec/fixtures/legacy.rb +++ b/spec/fixtures/legacy.rb @@ -2,12 +2,14 @@ create_table :authors do |t| t.boolean :active, default: true t.string :first_name + t.string :public_id t.string :last_name t.integer :age t.float :float_age t.float :decimal_age t.string :dwelling_type t.integer :state_id + t.string :region_code t.integer :dwelling_id t.integer :organization_id t.date :created_at_date @@ -78,9 +80,17 @@ create_table :states do |t| t.string :name + t.string :public_id t.timestamps end + # A model whose primary key is neither :id nor an integer, so public_id + # has something other than the default to remap around. + create_table :legacy_regions, primary_key: :code, id: :string do |t| + t.string :name + t.string :public_id + end + create_table :taggings do |t| t.integer :tag_id t.integer :taggable_id @@ -116,9 +126,16 @@ class State < ApplicationRecord has_many :books end + class Region < ApplicationRecord + # employee_directory.rb owns the plain :regions table in this database. + self.table_name = "legacy_regions" + self.primary_key = "code" + end + class Author < ApplicationRecord belongs_to :dwelling, polymorphic: true belongs_to :state + belongs_to :region, foreign_key: :region_code, primary_key: :code, optional: true belongs_to :organization has_many :books has_many :author_hobbies @@ -287,6 +304,10 @@ class ShopResource < ApplicationResource end end + class RegionResource < ApplicationResource + attribute :name, :string + end + class StateResource < ApplicationResource attribute :name, :string attribute :abbreviation, :string do diff --git a/spec/fixtures/poro.rb b/spec/fixtures/poro.rb index 5ba71f6b..fe003db9 100644 --- a/spec/fixtures/poro.rb +++ b/spec/fixtures/poro.rb @@ -112,7 +112,7 @@ def apply_pagination(records, params) class Base include ActiveModel::Validations - attr_accessor :id + attr_accessor :id, :public_id def self.create(attrs = {}) record = new(attrs) diff --git a/spec/integration/rails/public_id_spec.rb b/spec/integration/rails/public_id_spec.rb new file mode 100644 index 00000000..58b473de --- /dev/null +++ b/spec/integration/rails/public_id_spec.rb @@ -0,0 +1,469 @@ +if ENV["APPRAISAL_INITIALIZED"] + RSpec.describe "public_id with ActiveRecord" do + before(:all) do + DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) + end + + around do |example| + DatabaseCleaner.cleaning do + example.run + end + end + + let(:state_resource) do + Class.new(Legacy::StateResource) do + def self.name + "Legacy::StateResource" + end + + public_id :public_id + end + end + + let(:author_resource) do + state_resource_class = state_resource + Class.new(Legacy::AuthorResource) do + def self.name + "Legacy::AuthorResource" + end + + belongs_to :state, resource: state_resource_class + end + end + + let!(:state1) { Legacy::State.create!(name: "Maine", public_id: "st-abc") } + let!(:state2) { Legacy::State.create!(name: "Ohio", public_id: "st-def") } + let!(:author) { Legacy::Author.create!(first_name: "Stephen", state: state1) } + + it "finds by public id" do + proxy = state_resource.find(id: "st-def") + expect(proxy.data.id).to eq(state2.id) + end + + it "renders the public id as the jsonapi id" do + json = JSON.parse(state_resource.find(id: "st-abc").to_jsonapi) + expect(json["data"]["id"]).to eq("st-abc") + end + + it "sorts by the public id attribute" do + proxy = state_resource.all(sort: "-id") + expect(proxy.data.map(&:public_id)).to eq(%w[st-def st-abc]) + end + + it "rejects client filtering on _primary_key" do + expect { + state_resource.all(filter: {_primary_key: state1.id}).to_a + }.to raise_error(Graphiti::Errors::InvalidAttributeAccess) + end + + it "loads belongs_to includes via the real primary key and renders public ids" do + json = JSON.parse(author_resource.all(include: "state").to_jsonapi) + expect(json["data"][0]["relationships"]["state"]["data"]["id"]).to eq("st-abc") + expect(json["included"].map { |i| i["id"] }).to eq(["st-abc"]) + end + + it "filters by public id" do + proxy = state_resource.all(filter: {id: "st-def"}) + + expect(proxy.data.map(&:id)).to eq([state2.id]) + end + + it "assigns a client-supplied id to the public attribute, not the primary key" do + payload = { + data: { + type: "states", + id: "st-new", + attributes: {name: "Vermont"} + } + } + + created = nil + Graphiti.with_context({}, :create) do + proxy = state_resource.build(payload) + expect(proxy.save).to eq(true) + created = proxy.data + end + + expect(created.public_id).to eq("st-new") + expect(created.id).to be_a(Integer) + expect(Legacy::State.find(created.id).public_id).to eq("st-new") + end + + it "renders the public id as linkage when the relationship opts in" do + state_resource_class = state_resource + resource = Class.new(Legacy::AuthorResource) do + def self.name + "Legacy::AuthorResource" + end + end + resource.belongs_to :state, + resource: state_resource_class, + always_include_resource_ids: true + + json = JSON.parse(resource.all({}).to_jsonapi) + + expect(json["data"][0]["relationships"]["state"]["data"]["id"]).to eq("st-abc") + end + + it "renders the public id as linkage without loading the association" do + json = JSON.parse(author_resource.all({}).to_jsonapi) + + expect(json["data"][0]["relationships"]["state"]["data"]) + .to eq({"type" => "states", "id" => "st-abc"}) + expect(json).to_not have_key("included") + end + + it "resolves every rendered foreign key in one query" do + Legacy::Author.create!(first_name: "Peter", state: state2) + queries = [] + subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + queries << payload[:sql] if payload[:name] == "Legacy::State Load" + end + + author_resource.all({}).to_jsonapi + + ActiveSupport::Notifications.unsubscribe(subscription) + expect(queries.size).to eq(1) + end + + context "has_many related links" do + let(:author_resource) do + Class.new(Legacy::AuthorResource) do + def self.name + "Legacy::AuthorResource" + end + + public_id :public_id + end + end + + let(:book_resource) do + author_resource_class = author_resource + Class.new(Legacy::BookResource) do + def self.name + "Legacy::BookResource" + end + + belongs_to :author, resource: author_resource_class + end + end + + before do + author_resource.has_many :books, resource: book_resource, link: true + end + + let!(:author) { Legacy::Author.create!(first_name: "Stephen", public_id: "auth-abc") } + let!(:other_author) { Legacy::Author.create!(first_name: "Peter", public_id: "auth-def") } + let!(:book) { Legacy::Book.create!(title: "The Shining", author: author) } + let!(:other_book) { Legacy::Book.create!(title: "Damage", author: other_author) } + + around do |example| + previous = Graphiti.config.context_for_endpoint + Graphiti.config.context_for_endpoint = ->(path, action) { double("context") } + example.run + Graphiti.config.context_for_endpoint = previous + end + + it "names the author by public id" do + json = JSON.parse(author_resource.all(filter: {id: "auth-abc"}).to_jsonapi) + + expect(json["data"][0]["relationships"]["books"]["links"]["related"]) + .to eq("/legacy/books?filter[author_id]=auth-abc") + end + + it "returns that author's books when a client follows the link" do + books = book_resource.all(filter: {author_id: "auth-abc"}) + + expect(books.data.map(&:title)).to eq(["The Shining"]) + end + + it "translates a public id inside an operator hash" do + books = book_resource.all(filter: {author_id: {not_eq: "auth-abc"}}) + + expect(books.data.map(&:title)).to eq(["Damage"]) + end + + it "still sideloads through the real foreign key" do + json = JSON.parse(author_resource.all(include: "books").to_jsonapi) + + expect(json["included"].map { |node| node["attributes"]["title"] }) + .to match_array(["The Shining", "Damage"]) + end + + it "creates a book from a public id posted as a writable foreign key" do + book_resource.attribute :author_id, :integer, readable: false + proxy = Graphiti.with_context({}, :create) do + book_resource.build(data: {type: "books", attributes: {title: "Carrie", author_id: "auth-abc"}}).tap(&:save) + end + + expect(proxy.data.reload.author).to eq(author) + end + + it "translates a public id through the generated many_to_many filter" do + hobby_resource = Class.new(Legacy::HobbyResource) do + def self.name + "Legacy::HobbyResource" + end + end + author_resource.many_to_many :hobbies, resource: hobby_resource + hobby = Legacy::Hobby.create!(name: "Writing") + Legacy::AuthorHobby.create!(author: author, hobby: hobby) + Legacy::AuthorHobby.create!(author: other_author, hobby: Legacy::Hobby.create!(name: "Sailing")) + + expect(hobby_resource.all(filter: {author_id: "auth-abc"}).data).to eq([hobby]) + end + + it "keys a stat grouped by the foreign key on public ids" do + book_resource.stat author_id: [:count] + + proxy = book_resource.all(stats: {group_by: :author_id, author_id: :count}) + + expect(proxy.stats[:author_id][:count]).to eq("auth-abc" => 1, "auth-def" => 1) + end + end + + context "declared with a block" do + let(:author_resource) do + Class.new(Legacy::AuthorResource) do + def self.name + "Legacy::AuthorResource" + end + + public_id do + encode { |primary_key| "auth-#{primary_key}" } + decode { |public_id| public_id[/\Aauth-(\d+)\z/, 1]&.to_i } + end + end + end + + let(:book_resource) do + author_resource_class = author_resource + Class.new(Legacy::BookResource) do + def self.name + "Legacy::BookResource" + end + + belongs_to :author, resource: author_resource_class, resource_ids: true + stat author_id: [:count] + end + end + + let!(:author) { Legacy::Author.create!(first_name: "Stephen") } + let!(:book) { Legacy::Book.create!(title: "The Shining", author: author) } + + it "renders linkage by encoded id without querying the author" do + queries = [] + callback = ->(*, payload) { queries << payload[:sql] if payload[:sql].include?("authors") } + json = ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + JSON.parse(book_resource.all.to_jsonapi) + end + + expect(json["data"][0]["relationships"]["author"]["data"]).to eq("type" => "authors", "id" => "auth-#{author.id}") + expect(queries).to be_empty + end + + it "loads the include through the primary key and renders the encoded id" do + json = JSON.parse(book_resource.all(include: "author").to_jsonapi) + + expect(json["included"][0]["id"]).to eq("auth-#{author.id}") + end + + it "keys a stat grouped by the foreign key on encoded ids" do + proxy = book_resource.all(stats: {group_by: :author_id, author_id: :count}) + + expect(proxy.stats[:author_id][:count]).to eq("auth-#{author.id}" => 1) + end + end + + context "with a stat on the id" do + let(:state_resource) do + Class.new(Legacy::StateResource) do + def self.name + "Legacy::StateResource" + end + + public_id :public_id + stat id: [:count] + end + end + + it "counts through the public attribute" do + proxy = state_resource.all(stats: {id: :count}) + + expect(proxy.stats[:id][:count]).to eq(2) + end + + it "groups by the public id, matching what the records render" do + proxy = state_resource.all(stats: {group_by: :id, id: :count}) + + expect(proxy.stats[:id][:count].keys).to match_array(%w[st-abc st-def]) + end + end + + context "on a model whose primary key is not :id" do + let(:region_resource) do + Class.new(Legacy::RegionResource) do + def self.name + "Legacy::RegionResource" + end + + public_id :public_id + end + end + + let(:author_resource) do + region_resource_class = region_resource + Class.new(Legacy::AuthorResource) do + def self.name + "Legacy::AuthorResource" + end + + belongs_to :region, + resource: region_resource_class, + foreign_key: :region_code + end + end + + let!(:region) do + Legacy::Region.create!(code: "rg-1", name: "Northeast", public_id: "reg-abc") + end + let!(:author) do + Legacy::Author.create!(first_name: "Stephen", region_code: "rg-1") + end + + it "renders the public id, not the primary key" do + json = JSON.parse(region_resource.all({}).to_jsonapi) + + expect(json["data"][0]["id"]).to eq("reg-abc") + end + + it "loads a belongs_to through the model's own primary key" do + json = JSON.parse(author_resource.all(include: "region").to_jsonapi) + + expect(json["included"].map { |i| i["id"] }).to eq(["reg-abc"]) + end + + it "still rejects client filtering on _primary_key" do + expect { + region_resource.all(filter: {_primary_key: "rg-1"}).to_a + }.to raise_error(Graphiti::Errors::InvalidAttributeAccess) + end + + context "with an explicit primary_key on the belongs_to" do + around do |example| + previous = Graphiti.config.context_for_endpoint + Graphiti.config.context_for_endpoint = ->(path, action) { double("context") } + example.run + Graphiti.config.context_for_endpoint = previous + end + + def author_resource_keyed_on(primary_key) + region_resource_class = region_resource + Class.new(Legacy::AuthorResource) do + def self.name + "Legacy::AuthorResource" + end + + belongs_to :region, + resource: region_resource_class, + foreign_key: :region_code, + primary_key: primary_key, + link: true + end + end + + it "links by public id when the key is the model's own primary key" do + json = JSON.parse(author_resource_keyed_on(:code).all({}).to_jsonapi) + + relationship = json["data"][0]["relationships"]["region"] + expect(relationship["links"]["related"]).to eq("/legacy/regions/reg-abc") + expect(relationship["data"]).to eq({"type" => "regions", "id" => "reg-abc"}) + end + + it "links by public id when the key is another column the target filters on" do + author.update!(region_code: "Northeast") + json = JSON.parse(author_resource_keyed_on(:name).all({}).to_jsonapi) + + relationship = json["data"][0]["relationships"]["region"] + expect(relationship["links"]["related"]).to eq("/legacy/regions/reg-abc") + expect(relationship["data"]).to eq({"type" => "regions", "id" => "reg-abc"}) + end + + it "renders neither a link nor linkage when the target has no filter on the key" do + region_resource.filters.delete(:name) + json = JSON.parse(author_resource_keyed_on(:name).all({}).to_jsonapi) + + expect(json["data"][0]["relationships"]).to_not have_key("region") + expect(json.to_s).to_not include("rg-1") + end + end + end + + it "updates by public id" do + payload = { + data: { + type: "states", + id: "st-abc", + attributes: {name: "Vermont"} + } + } + Graphiti.with_context({}, :update) do + proxy = state_resource.find(payload) + expect(proxy.update_attributes).to eq(true) + end + expect(state1.reload.name).to eq("Vermont") + end + + it "compares public ids exactly" do + expect(state_resource.all(filter: {id: "ST-ABC"}).data).to be_empty + expect(state_resource.all(filter: {id: "st-abc"}).data.map(&:id)).to eq([state1.id]) + end + + it "excludes by public id with not_eq" do + expect(state_resource.all(filter: {id: {not_eq: "st-abc"}}).data.map(&:id)).to eq([state2.id]) + end + + context "a polymorphic belongs_to whose target publishes public ids" do + let(:office_resource) do + Class.new(OfficeResource) do + def self.name + "OfficeResource" + end + + public_id do + encode { |primary_key| "off-#{primary_key}" } + decode { |public_id| public_id.delete_prefix("off-").to_i } + end + end + end + + let(:employee_resource) do + Class.new(EmployeeResource) do + def self.name + "EmployeeResource" + end + + polymorphic_belongs_to :workspace do + group_by(:workspace_type) do + on(:Office) + on(:HomeOffice) + end + end + end + end + + let!(:office) { Office.create!(address: "1 Main") } + let!(:employee) { Employee.create!(first_name: "Ann", workspace: office) } + + before { stub_const("OfficeResource", office_resource) } + + it "renders the workspace linkage and include by public id" do + json = JSON.parse(employee_resource.all(include: "workspace", filter: {id: employee.id}).to_jsonapi) + + expect(json["data"][0]["relationships"]["workspace"]["data"]).to eq("type" => "offices", "id" => "off-#{office.id}") + expect(json["included"][0]["id"]).to eq("off-#{office.id}") + end + end + end +end diff --git a/spec/public_id_spec.rb b/spec/public_id_spec.rb new file mode 100644 index 00000000..8de1ac36 --- /dev/null +++ b/spec/public_id_spec.rb @@ -0,0 +1,1090 @@ +require "spec_helper" + +RSpec.describe "public_id" do + include_context "resource testing" + let(:resource) do + Class.new(PORO::EmployeeResource) do + def self.name + "PORO::EmployeeResource" + end + + public_id :public_id + end + end + let(:base_scope) { {type: :employees} } + + let!(:employee1) do + PORO::Employee.create(first_name: "Jane", public_id: "emp-abc") + end + let!(:employee2) do + PORO::Employee.create(first_name: "John", public_id: "emp-def") + end + + describe "every id the payload exposes" do + let!(:position) do + PORO::Position.create(employee_id: employee1.id, title: "Engineer") + end + + let(:position_resource) do + employee_resource = resource + Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + belongs_to :employee, resource: employee_resource + end + end + + around do |e| + previous = Graphiti.config.context_for_endpoint + Graphiti.config.context_for_endpoint = ->(path, action) { double("context") } + e.run + Graphiti.config.context_for_endpoint = previous + end + + it "renders the public id, never the primary key" do + resource.has_many :positions + params[:include] = "positions" + render + + employee = jsonapi_data.find { |node| node.id == "emp-abc" } + expect(employee).to be_present + expect(jsonapi_data.map(&:id)).to_not include(employee1.id.to_s) + end + + it "generates a related link naming the parent by public id" do + resource.has_many :positions, resource: position_resource, link: true + render + + expect(json["data"][0]["relationships"]["positions"]["links"]["related"]) + .to eq("/poro/positions?filter[employee_id]=emp-abc") + end + + it "resolves the public id when a client follows that link" do + positions = position_resource.all(filter: {employee_id: "emp-abc"}) + + expect(positions.data.map(&:id)).to eq([position.id]) + end + + it "matches nothing when the link filter is given a primary key" do + positions = position_resource.all(filter: {employee_id: employee1.id}) + + expect(positions.data).to be_empty + end + + it "still sideloads through the real foreign key" do + resource.has_many :positions, resource: position_resource + params[:include] = "positions" + render + + expect(jsonapi_included("positions").map(&:rawid)).to eq([position.id.to_s]) + end + + it "translates for a subclass of the child too" do + subclass = Class.new(position_resource) do + def self.name + "PORO::PositionResource" + end + end + + positions = subclass.all(filter: {employee_id: "emp-abc"}) + + expect(positions.data.map(&:id)).to eq([position.id]) + end + + it "lets a redeclaration by a resource of the same name take over the entry" do + other_resource = Class.new(PORO::EmployeeResource) do + def self.name + "PORO::EmployeeResource" + end + + public_id :public_id + end + other_resource.has_many :positions, resource: position_resource, link: true + resource.has_many :positions, resource: position_resource, link: true + + expect(other_resource.sideload(:positions).link?).to eq(false) + expect(resource.sideload(:positions).link?).to eq(true) + end + + it "raises when a differently named parent claims the same child filter" do + other_resource = Class.new(PORO::EmployeeResource) do + def self.name + "PORO::ManagerResource" + end + + self.model = PORO::Employee + public_id :public_id + end + resource.has_many :positions, resource: position_resource + + expect { + other_resource.has_many :positions, resource: position_resource + }.to raise_error(Graphiti::Errors::ConflictingPublicIdSource, /employee_id.*PORO::EmployeeResource.*PORO::ManagerResource/) + end + + it "waits for the parent's model before claiming the child filter" do + other_resource = Class.new(PORO::ApplicationResource) do + def self.name + "PORO::ManagerResource" + end + + public_id :public_id + end + resource.has_many :positions, resource: position_resource + other_resource.has_many :positions, resource: position_resource + + expect { + other_resource.model = PORO::Employee + }.to raise_error(Graphiti::Errors::ConflictingPublicIdSource, /employee_id.*PORO::EmployeeResource.*PORO::ManagerResource/) + end + + it "links an unpaired has_many, whose child declares no belongs_to back" do + resource.has_many :positions, link: true + render + + expect(json["data"][0]["relationships"]["positions"]["links"]["related"]) + .to eq("/poro/positions?filter[employee_id]=emp-abc") + expect(PORO::PositionResource.all(filter: {employee_id: "emp-abc"}).data.map(&:id)).to eq([position.id]) + end + + it "resolves for a subclass of an unpaired child" do + resource.has_many :positions + subclass = Class.new(PORO::PositionResource) do + def self.name + "PORO::SeniorPositionResource" + end + + self.model = PORO::Position + end + + positions = subclass.all(filter: {employee_id: "emp-abc"}) + + expect(positions.data.map(&:id)).to eq([position.id]) + end + + it "types a generated many_to_many filter like the public id, which is what clients send it" do + team_resource = Class.new(PORO::TeamResource) do + def self.name + "PORO::TeamResource" + end + end + resource.many_to_many :teams, + resource: team_resource, + foreign_key: {employee_teams: :owner_id} + + expect(team_resource.filters[:owner_id][:type]).to eq(:string) + expect(team_resource.public_id_source_for(:owner_id)).to eq(resource) + end + + it "still honours a link block, which can use the public id" do + resource.has_many :positions do + link { |employee| "/employees/#{employee.public_id}/positions" } + end + render + + expect(json["data"][0]["relationships"]["positions"]["links"]["related"]) + .to eq("/employees/emp-abc/positions") + end + + it "leaves a belongs_to linkable, since that link carries the target's id" do + classification = PORO::Classification.create(description: "Engineering") + employee1.update_attributes(classification_id: classification.id) + resource.belongs_to :classification, link: true + render + + relationship = json["data"][0]["relationships"]["classification"] + expect(relationship["links"]["related"]).to include(classification.id.to_s) + end + + it "leaves relationships keyed on something else linkable" do + resource.has_many :positions, primary_key: :first_name, foreign_key: :title, link: true + render + + relationship = json["data"][0]["relationships"]["positions"] + expect(relationship["links"]["related"]).to include("filter[title]=Jane") + end + + describe "a custom block on the child's foreign key filter" do + let(:seen) { [] } + + it "receives the value as the client sent it" do + seen_ref = seen + position_resource.filter :employee_id, :string do + eq { |scope, value| + seen_ref << value + scope + } + end + + position_resource.all(filter: {employee_id: "emp-abc"}).data + + expect(seen).to eq([["emp-abc"]]) + end + + it "gets the decoded value when it takes primary_keys:" do + seen_ref = seen + position_resource.filter :employee_id, :string do + eq { |scope, value, primary_keys:| + seen_ref << [value, primary_keys] + scope + } + end + + position_resource.all(filter: {employee_id: "emp-abc,emp-def"}).data + + expect(seen).to eq([[%w[emp-abc emp-def], [employee1.id, employee2.id]]]) + end + + it "gets the value itself as primary_keys: when the parent has no public id" do + seen_ref = seen + plain_positions = Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + filter :employee_id, :integer do + eq { |scope, value, primary_keys:| + seen_ref << [value, primary_keys] + scope + } + end + end + expect(PORO::EmployeeResource).to_not receive(:translate_ids) + + plain_positions.all(filter: {employee_id: employee1.id}).data + + expect(seen).to eq([[[employee1.id], [employee1.id]]]) + end + + it "still receives the real key from a sideload, and as primary_keys: too" do + seen_ref = seen + position_resource.filter :employee_id, :integer do + eq { |scope, value, primary_keys:| + seen_ref << [value, primary_keys] + scope + } + end + resource.has_many :positions, resource: position_resource + params[:include] = "positions" + render + + expect(seen).to eq([[[employee1.id, employee2.id], [employee1.id, employee2.id]]]) + end + + it "suppresses the parent's link when the block does not take primary_keys:, and the audit says so" do + position_resource.filter :employee_id, :string do + eq { |scope, value| scope } + end + resource.has_many :positions, resource: position_resource, link: true + render + + expect(json["data"][0]["relationships"]).to_not have_key("positions") + finding = Graphiti::Audit.findings([resource]).find { |candidate| candidate.check == :link_hidden } + expect(finding.remedy).to include("take `primary_keys:`") + end + + it "keeps the parent's link when the block takes primary_keys:" do + position_resource.filter :employee_id, :string do + eq { |scope, value, primary_keys:| scope } + end + resource.has_many :positions, resource: position_resource, link: true + render + + expect(json["data"][0]["relationships"]["positions"]["links"]["related"]) + .to eq("/poro/positions?filter[employee_id]=emp-abc") + end + + it "leaves the generated many_to_many filter taking primary keys" do + team_resource = Class.new(PORO::TeamResource) do + def self.name + "PORO::TeamResource" + end + end + resource.many_to_many :teams, resource: team_resource, foreign_key: {employee_teams: :owner_id} + + expect(team_resource.filter_accepts_public_ids?(:owner_id)).to eq(true) + end + end + + it "translates without gathering foreign keys for the target's own relationships" do + resource.sideloads.each_value { |sideload| expect(sideload).to_not receive(:collect_foreign_keys) } + + positions = position_resource.all(filter: {employee_id: "emp-abc"}) + + expect(positions.data.map(&:id)).to eq([position.id]) + end + + describe "writing the parent's key" do + around do |example| + Graphiti.with_context({}, :create) { example.run } + end + + before do + position_resource.attribute :employee_id, :integer, readable: false + end + + it "decodes a public id posted as a foreign key attribute" do + proxy = position_resource.build(data: {type: "positions", attributes: {title: "Lead", employee_id: "emp-def"}}) + + expect(proxy.save).to eq(true) + expect(proxy.data.employee_id).to eq(employee2.id) + end + + it "rejects a foreign key that names no record" do + proxy = position_resource.build(data: {type: "positions", attributes: {title: "Lead", employee_id: employee2.id}}) + + expect { proxy.save }.to raise_error(Graphiti::Errors::RecordNotFound, /'employees' with id '#{employee2.id}'.*employee_id/) + end + + it "decodes a foreign key typed as the target's id, not the key's" do + position_resource.attribute :employee_id, :integer, readable: false + proxy = position_resource.build(data: {type: "positions", attributes: {title: "Lead", employee_id: "emp-def"}}) + + expect(proxy.save).to eq(true) + expect(proxy.data.employee_id).to eq(employee2.id) + end + + it "resolves a relationship reference by public id" do + payload = { + data: { + type: "positions", + attributes: {title: "Lead"}, + relationships: {employee: {data: {type: "employees", id: "emp-def"}}} + } + } + proxy = position_resource.build(payload) + + expect(proxy.save).to eq(true) + expect(proxy.data.employee_id).to eq(employee2.id) + end + + it "destroys a parent referenced by public id" do + position = PORO::Position.create(title: "Lead", employee_id: employee2.id) + payload = { + data: { + type: "positions", + id: position.id.to_s, + relationships: {employee: {data: {type: "employees", id: "emp-def", method: "destroy"}}} + } + } + proxy = Graphiti.with_context({}, :update) { position_resource.find(payload) } + + expect(proxy.update_attributes).to eq(true) + expect(proxy.data.employee_id).to be_nil + expect(PORO::DB.data[:employees].map { |employee| employee[:public_id] }).not_to include("emp-def") + end + end + + describe "a readable attribute holding the parent's primary key" do + it "raises when the has_many is declared after it" do + unpaired_resource = Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + attribute :employee_id, :integer + end + + expect { + resource.has_many :positions, resource: unpaired_resource + }.to raise_error(Graphiti::Errors::PublicIdLeak, /PORO::PositionResource: attribute :employee_id.*PORO::EmployeeResource/) + end + + it "raises when it is declared after the has_many" do + resource.has_many :positions, resource: position_resource + + expect { + position_resource.attribute :employee_id, :integer + }.to raise_error(Graphiti::Errors::PublicIdLeak) + end + + it "raises when only the child's belongs_to names the parent" do + expect { + position_resource.attribute :employee_id, :integer + }.to raise_error(Graphiti::Errors::PublicIdLeak) + end + + it "raises when the belongs_to is declared after it" do + employee_resource = resource + + expect { + Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + attribute :employee_id, :integer + belongs_to :employee, resource: employee_resource + end + }.to raise_error(Graphiti::Errors::PublicIdLeak) + end + + it "waits for the model when the belongs_to is declared before it" do + employee_resource = resource + anonymous = Class.new(PORO::ApplicationResource) do + attribute :employee_id, :integer + belongs_to :employee, resource: employee_resource + end + + expect { anonymous.model = PORO::Position }.to raise_error(Graphiti::Errors::PublicIdLeak) + end + + it "allows the key as a filter-only attribute" do + position_resource.attribute :employee_id, :integer, only: [:filterable] + + expect { + resource.has_many :positions, resource: position_resource + }.to_not raise_error + end + + it "queries a string-rendered key by its decoded integer, not as a string" do + recording = Class.new(PORO::Adapter) do + def self.calls + @calls ||= [] + end + + def filter_integer_eq(scope, attribute, value) + self.class.calls << [attribute, value] + filter(scope, attribute, value) + end + end + position_resource.adapter = recording + position_resource.attribute :employee_id, :string do + "emp-#{@object.employee_id}" + end + resource.has_many :positions, resource: position_resource + + position_resource.all(filter: {employee_id: "emp-def"}).data + + expect(recording.calls).to eq([[:employee_id, [employee2.id.to_s]]]) + end + + it "allows a readable key with a block, since the block decides what it renders" do + position_resource.attribute :employee_id, :string do + "emp-#{@object.employee_id}" + end + + expect { + resource.has_many :positions, resource: position_resource + }.to_not raise_error + end + end + end + + describe "declared on an abstract resource" do + let(:public_base) do + Class.new(PORO::ApplicationResource) do + def self.name + "PublicApplicationResource" + end + + self.abstract_class = true + public_id :public_id + end + end + + let(:resource) do + Class.new(public_base) do + def self.name + "PORO::EmployeeResource" + end + + self.model = PORO::Employee + self.type = :employees + end + end + + it "carries the remap down to every subclass" do + render + + expect(jsonapi_data.map(&:rawid)).to eq(%w[emp-abc emp-def]) + expect(resource.config[:public_id]).to eq(:public_id) + expect(resource.config[:attributes][:id][:type]).to eq(:string) + end + + it "filters and sorts through it too" do + expect(resource.all(filter: {id: "emp-def"}).data.map(&:id)) + .to eq([employee2.id]) + expect(resource.all(sort: "-id").data.map(&:id)) + .to eq([employee2.id, employee1.id]) + end + end + + describe "the public id type" do + def model_with_column(column_type) + Class.new do + define_singleton_method(:primary_key) { "id" } + define_singleton_method(:type_for_attribute) { |_name| Struct.new(:type).new(column_type) } + end + end + + it "is a string when the model cannot answer" do + expect(resource.attributes[:id][:type]).to eq(:string) + end + + it "reads the column type off the model" do + model = model_with_column(:integer) + resource = Class.new(PORO::ApplicationResource) do + self.model = model + public_id :code + end + + expect(resource.attributes[:id][:type]).to eq(:integer) + end + + it "lets an explicit type win" do + model = model_with_column(:integer) + resource = Class.new(PORO::ApplicationResource) do + self.model = model + public_id :code, :string + end + + expect(resource.attributes[:id][:type]).to eq(:string) + end + + it "reads the column once a model assigned after the declaration is known" do + resource = Class.new(PORO::ApplicationResource) { public_id :code } + resource.model = model_with_column(:uuid) + + expect(resource.attributes[:id][:type]).to eq(:uuid) + end + + it "never casts what reaches the hidden primary key filter" do + params[:filter] = {_primary_key: Graphiti::Util::InternalParam.new(["not-an-integer"])} + expect(records).to eq([]) + end + end + + describe "serialization" do + it "renders the public id as the jsonapi id" do + render + expect(jsonapi_data.map(&:rawid)).to eq(%w[emp-abc emp-def]) + end + + it "survives a later attribute :id declaration" do + resource.attribute :id, :string + render + expect(jsonapi_data.map(&:rawid)).to eq(%w[emp-abc emp-def]) + end + end + + describe "filtering on id" do + it "queries the public id attribute" do + params[:filter] = {id: "emp-def"} + expect(records.map(&:first_name)).to eq(["John"]) + end + + it "goes through the adapter's public id comparison, which is exact where the adapter can be" do + exact = Class.new(PORO::Adapter) do + def self.calls + @calls ||= [] + end + + def filter_public_id_eq(scope, attribute, value) + self.class.calls << [attribute, value] + filter_string_eq(scope, attribute, value) + end + end + resource.adapter = exact + params[:filter] = {id: "emp-def"} + + expect(records.map(&:first_name)).to eq(["John"]) + expect(exact.calls).to eq([[:public_id, ["emp-def"]]]) + end + + it "hands a custom id filter the primary keys when it asks" do + resource.filter :id, :string do + eq { |scope, value, primary_keys:| scope.merge(conditions: {id: primary_keys.first}) } + end + params[:filter] = {id: "emp-def"} + expect(records.map(&:first_name)).to eq(["John"]) + end + + it "lets a custom id filter decode through the resource without recursing" do + klass = resource + resource.filter :id, :string do + eq { |scope, value| scope.merge(conditions: {id: klass.decode_public_id(value.first)}) } + end + params[:filter] = {id: "emp-def"} + expect(records.map(&:first_name)).to eq(["John"]) + end + + it "accepts several public ids" do + params[:filter] = {id: "emp-abc,emp-def"} + expect(records.map(&:first_name)).to match_array(%w[Jane John]) + end + end + + describe "sorting on id" do + it "sorts by the public id attribute" do + params[:sort] = "-id" + expect(records.map(&:public_id)).to eq(%w[emp-def emp-abc]) + end + end + + describe "the hidden :_primary_key filter" do + it "rejects values from request params" do + params[:filter] = {_primary_key: employee2.id} + expect { records }.to raise_error(Graphiti::Errors::InvalidAttributeAccess) + end + + it "accepts values wrapped by graphiti itself" do + params[:filter] = {_primary_key: Graphiti::Util::InternalParam.new([employee2.id])} + expect(records.map(&:first_name)).to eq(["John"]) + end + + it "is excluded from the schema" do + previous = Graphiti.config.context_for_endpoint + Graphiti.config.context_for_endpoint = ->(path, action) { + double("context", sideload_allowlist: nil) + } + schema = Graphiti::Schema.generate([resource]) + employee_schema = schema[:resources].find { |r| r[:name] == "PORO::EmployeeResource" } + expect(employee_schema[:filters]).to_not have_key(:_primary_key) + expect(employee_schema[:attributes]).to_not have_key(:_primary_key) + ensure + Graphiti.config.context_for_endpoint = previous + end + end + + describe "persistence" do + around do |example| + Graphiti.with_context({}, :create) do + example.run + end + end + + it "keeps a linked parent on a model that was assigned before save" do + employees = resource + positions = Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + belongs_to :employee, resource: employees + end + proxy = positions.build( + data: { + type: "positions", + attributes: {title: "Engineer"}, + relationships: {employee: {data: {type: "employees", id: "emp-def"}}} + } + ) + proxy.data + + expect(proxy.save).to eq(true) + expect(proxy.data.employee_id).to eq(employee2.id) + end + + describe "updating by public id" do + let(:payload) do + { + data: { + type: "employees", + id: "emp-def", + attributes: {first_name: "Johnny"} + } + } + end + + it "finds the record via the public id" do + proxy = resource.find(payload) + expect(proxy.update_attributes).to eq(true) + expect(PORO::Employee.find(employee2.id).first_name).to eq("Johnny") + end + end + + describe "destroying by public id" do + it "finds the record via the public id" do + proxy = resource.find(id: "emp-def") + expect(proxy.destroy).to eq(true) + expect(PORO::Employee.find(employee2.id)).to be_nil + end + end + + describe "creating with a client-supplied id" do + let(:payload) do + { + data: { + type: "employees", + id: "emp-xyz", + attributes: {first_name: "Jake"} + } + } + end + + it "assigns the public id attribute, not the primary key" do + proxy = resource.build(payload) + expect(proxy.save).to eq(true) + expect(proxy.data.public_id).to eq("emp-xyz") + expect(proxy.data.id).to_not eq("emp-xyz") + end + end + end + + describe "belongs_to sideload of a remapped resource" do + let(:department_resource) do + Class.new(PORO::DepartmentResource) do + def self.name + "PORO::DepartmentResource" + end + + public_id :public_id + end + end + + let(:resource) do + department_resource_class = department_resource + Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + belongs_to :department, resource: department_resource_class, link: true + end + end + let(:base_scope) { {type: :positions} } + + let!(:department) do + PORO::Department.create(name: "Engineering", public_id: "dep-abc") + end + let!(:position) do + PORO::Position.create(title: "Developer", department_id: department.id) + end + + it "loads via the real primary key and renders the public id" do + params[:include] = "department" + render + expect(jsonapi_included("departments").map(&:rawid)).to eq(["dep-abc"]) + expect(jsonapi_data[0].sideload(:department).rawid).to eq("dep-abc") + end + + it "links to the target by public id, not the foreign key" do + render + + expect(jsonapi_data[0].relationships["department"]["links"]["related"]) + .to eq("/poro/departments/dep-abc") + end + + it "renders the public id as linkage without loading the association" do + render + + expect(jsonapi_data[0].relationships["department"]["data"]) + .to eq({"type" => "departments", "id" => "dep-abc"}) + expect(json).to_not have_key("included") + end + + it "renders neither linkage nor a link when the foreign key no longer resolves" do + position.update_attributes(department_id: 999) + render + + expect(jsonapi_data[0].relationships["department"]) + .to eq({"data" => nil, "links" => {"related" => nil}}) + end + + context "on a sideloaded resource" do + let(:employee_resource) do + position_resource = resource + Class.new(PORO::EmployeeResource) do + def self.name + "PORO::EmployeeResource" + end + + has_many :positions, resource: position_resource + end + end + let!(:employee) { PORO::Employee.create(first_name: "Jane") } + + before do + position.update_attributes(employee_id: employee.id) + end + + it "resolves the sideloaded records' foreign keys, not the top-level ones" do + json = JSON.parse(employee_resource.all(include: "positions").to_jsonapi) + + expect(json["included"][0]["relationships"]["department"]["data"]) + .to eq({"type" => "departments", "id" => "dep-abc"}) + end + + it "resolves a nested record's key that no top-level record carries" do + employee_resource_class = employee_resource + resource.belongs_to :employee, resource: employee_resource_class + other_department = PORO::Department.create(name: "Sales", public_id: "dep-def") + other_position = PORO::Position.create( + title: "Seller", + employee_id: employee.id, + department_id: other_department.id + ) + params[:filter] = {id: position.id} + params[:include] = "employee.positions" + render + + nested = jsonapi_included("positions").find { |node| node.rawid == other_position.id.to_s } + expect(nested.relationships["department"]["data"]) + .to eq({"type" => "departments", "id" => "dep-def"}) + end + end + + it "resolves more foreign keys than one page of the target holds" do + 25.times do |index| + other_department = PORO::Department.create(name: "D#{index}", public_id: "dep-#{index}") + PORO::Position.create(title: "P#{index}", department_id: other_department.id) + end + params[:page] = {size: 30} + render + + ids = jsonapi_data.map { |node| node.relationships["department"]["data"]&.dig("id") } + expect(ids).to_not include(nil) + expect(ids.uniq.size).to eq(26) + end + + it "resolves through a target with a required filter" do + department_resource.filter :name, :string, required: true + render + + expect(jsonapi_data[0].relationships["department"]["data"]) + .to eq({"type" => "departments", "id" => "dep-abc"}) + end + + it "resolves through a target whose default filter hides the row" do + department_resource.default_filter :name do |scope| + scope[:conditions][:name] = "Nothing" + scope + end + render + + expect(jsonapi_data[0].relationships["department"]["data"]) + .to eq({"type" => "departments", "id" => "dep-abc"}) + end + end + + describe "declared with a block" do + let(:resource) do + Class.new(PORO::EmployeeResource) do + def self.name + "PORO::EmployeeResource" + end + + public_id do + encode { |primary_key| "enc-#{primary_key}" } + decode { |public_id| public_id[/\Aenc-(\d+)\z/, 1]&.to_i } + end + end + end + + let(:position_resource) do + employee_resource = resource + Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + belongs_to :employee, resource: employee_resource + end + end + + let!(:position) do + PORO::Position.create(employee_id: employee1.id, title: "Engineer") + end + + around do |e| + previous = Graphiti.config.context_for_endpoint + Graphiti.config.context_for_endpoint = ->(path, action) { double("context", sideload_allowlist: nil) } + e.run + Graphiti.config.context_for_endpoint = previous + end + + it "renders the encoded primary key as the jsonapi id" do + render + expect(jsonapi_data.map(&:rawid)).to eq(["enc-#{employee1.id}", "enc-#{employee2.id}"]) + end + + it "decodes filter[id]" do + params[:filter] = {id: "enc-#{employee2.id}"} + expect(records.map(&:first_name)).to eq(["John"]) + end + + it "matches nothing when filter[id] does not decode" do + params[:filter] = {id: employee2.id} + expect(records).to be_empty + end + + it "matches nothing when a decoded id does not encode back to what was sent" do + resource.config[:public_id_decode] = ->(public_id) { public_id.to_i } + params[:filter] = {id: employee2.id.to_s} + expect(records).to be_empty + end + + it "sorts by the primary key" do + params[:sort] = "-id" + expect(records.map(&:id)).to eq([employee2.id, employee1.id]) + end + + it "updates and destroys by encoded id" do + Graphiti.with_context({}, :update) do + proxy = resource.find(data: {type: "employees", id: "enc-#{employee2.id}", attributes: {first_name: "Johnny"}}) + expect(proxy.update_attributes).to eq(true) + end + expect(PORO::Employee.find(employee2.id).first_name).to eq("Johnny") + + Graphiti.with_context({}, :destroy) do + expect(resource.find(id: "enc-#{employee2.id}").destroy).to eq(true) + end + expect(PORO::Employee.find(employee2.id)).to be_nil + end + + it "decodes a client-supplied id on create into the primary key" do + Graphiti.with_context({}, :create) do + proxy = resource.build(data: {type: "employees", id: "enc-900", attributes: {first_name: "Jake"}}) + expect(proxy.save).to eq(true) + expect(proxy.data.id).to eq(900) + end + end + + it "rejects a create whose id does not decode, rather than dropping it" do + Graphiti.with_context({}, :create) do + proxy = resource.build(data: {type: "employees", id: employee1.id.to_s, attributes: {first_name: "Jake"}}) + + expect { proxy.save }.to raise_error(Graphiti::Errors::ConflictRequest, /data.id does not name a record/) + end + end + + it "links a has_many by encoded id and decodes it on the way back" do + resource.has_many :positions, resource: position_resource, link: true + render + + expect(json["data"][0]["relationships"]["positions"]["links"]["related"]) + .to eq("/poro/positions?filter[employee_id]=enc-#{employee1.id}") + expect(position_resource.all(filter: {employee_id: "enc-#{employee1.id}"}).data.map(&:id)).to eq([position.id]) + expect(position_resource.all(filter: {employee_id: employee1.id}).data).to be_empty + end + + it "renders belongs_to linkage and link by encoded id without a query" do + expect(resource).to_not receive(:translate_ids) + position_resource.sideload(:employee).instance_variable_set(:@link, true) + json = JSON.parse(position_resource.all(resource_ids: true).to_jsonapi) + + relationship = json["data"][0]["relationships"]["employee"] + expect(relationship["data"]).to eq("type" => "employees", "id" => "enc-#{employee1.id}") + expect(relationship["links"]["related"]).to eq("/poro/employees/enc-#{employee1.id}") + end + + it "still guards a readable foreign key attribute" do + expect { + position_resource.attribute :employee_id, :integer + }.to raise_error(Graphiti::Errors::PublicIdLeak) + end + + it "records itself in the schema as true" do + schema = Graphiti::Schema.generate([resource]) + expect(schema[:resources][0][:public_id]).to eq(true) + end + + it "rejects a column name and a block together" do + expect { + Class.new(PORO::EmployeeResource) do + public_id :public_id do + encode { |primary_key| primary_key } + decode { |public_id| public_id } + end + end + }.to raise_error(Graphiti::Errors::InvalidPublicId, /not both/) + end + + it "rejects a block missing decode" do + expect { + Class.new(PORO::EmployeeResource) do + public_id do + encode { |primary_key| primary_key } + end + end + }.to raise_error(Graphiti::Errors::InvalidPublicId, /both encode and decode/) + end + + it "rejects a declaration with neither" do + expect { + Class.new(PORO::EmployeeResource) { public_id } + }.to raise_error(Graphiti::Errors::InvalidPublicId, /needs a column name or a block/) + end + end + + describe "one publishing resource among plain ones" do + let(:resource) do + Class.new(PORO::EmployeeResource) do + def self.name + "PORO::EmployeeResource" + end + end + end + + let(:position_resource) do + employee_resource = resource + Class.new(PORO::PositionResource) do + def self.name + "PORO::PositionResource" + end + + public_id do + encode { |primary_key| "pos-#{primary_key}" } + decode { |public_id| public_id[/\Apos-(\d+)\z/, 1]&.to_i } + end + + belongs_to :employee, resource: employee_resource, link: true + end + end + + let!(:position) { PORO::Position.create(employee_id: employee1.id, title: "Engineer") } + + around do |e| + previous = Graphiti.config.context_for_endpoint + Graphiti.config.context_for_endpoint = ->(path, action) { double("context", sideload_allowlist: nil) } + e.run + Graphiti.config.context_for_endpoint = previous + end + + before do + resource.has_many :positions, resource: position_resource, link: true + end + + it "leaves the plain parent's ids and links alone" do + render + + expect(jsonapi_data.map(&:rawid)).to eq([employee1.id.to_s, employee2.id.to_s]) + expect(json["data"][0]["relationships"]["positions"]["links"]["related"]) + .to eq("/poro/positions?filter[employee_id]=#{employee1.id}") + end + + it "sideloads the publishing child and renders its ids encoded" do + params[:include] = "positions" + render + + expect(jsonapi_included("positions").map(&:rawid)).to eq(["pos-#{position.id}"]) + end + + it "nests back through the child to the plain parent" do + params[:include] = "positions.employee" + render + + included_position = json["included"].find { |node| node["type"] == "positions" } + expect(included_position["id"]).to eq("pos-#{position.id}") + expect(included_position["relationships"]["employee"]["data"]).to eq("type" => "employees", "id" => employee1.id.to_s) + end + + it "names the plain parent by primary key in the child's belongs_to" do + json = JSON.parse(position_resource.all(resource_ids: true).to_jsonapi) + + relationship = json["data"][0]["relationships"]["employee"] + expect(relationship["data"]).to eq("type" => "employees", "id" => employee1.id.to_s) + expect(relationship["links"]["related"]).to eq("/poro/employees/#{employee1.id}") + end + + it "finds the child by encoded id and its parent by primary key" do + json = JSON.parse(position_resource.find(id: "pos-#{position.id}", include: "employee").to_jsonapi) + + expect(json["data"]["id"]).to eq("pos-#{position.id}") + expect(json["included"][0]["id"]).to eq(employee1.id.to_s) + end + end +end diff --git a/spec/schema_diff_spec.rb b/spec/schema_diff_spec.rb index 6dbfc53e..1b486a92 100644 --- a/spec/schema_diff_spec.rb +++ b/spec/schema_diff_spec.rb @@ -324,6 +324,60 @@ def self.name end end + context "when a resource gains an public_id" do + before do + resource_b.attribute :public_id, :string + resource_b.public_id :public_id + end + + it "returns error" do + expect(diff).to include( + 'SchemaDiff::EmployeeResource changed public_id from nil to "public_id".' + ) + end + end + + context "when a resource changes which attribute it publishes as the id" do + before do + resource_a.attribute :public_id, :string + resource_a.public_id :public_id + resource_b.attribute :slug, :string + resource_b.public_id :slug + end + + it "returns error" do + expect(diff).to include( + 'SchemaDiff::EmployeeResource changed public_id from "public_id" to "slug".' + ) + end + end + + context "when a resource moves from a public id column to an encoded one" do + before do + resource_a.attribute :public_id, :string + resource_a.public_id :public_id + resource_b.public_id do + encode { |primary_key| primary_key.to_s } + decode { |public_id| public_id.to_i } + end + end + + it "returns error" do + expect(diff).to include( + 'SchemaDiff::EmployeeResource changed public_id from "public_id" to true.' + ) + end + end + + context "when a resource keeps the same public_id" do + before do + resource_a.attribute :public_id, :string + resource_a.public_id :public_id + end + + it { is_expected.to eq([]) } + end + context "when extra attribute added" do before do resource_b.extra_attribute :foo, :string diff --git a/spec/schema_spec.rb b/spec/schema_spec.rb index 06b68680..027c0d7a 100644 --- a/spec/schema_spec.rb +++ b/spec/schema_spec.rb @@ -248,6 +248,25 @@ def self.name expect(schema[:types].to_a).to eq(expected[:types].sort) end + context "when a resource publishes a different attribute as its id" do + before do + employee_resource.attribute :public_id, :string + employee_resource.public_id :public_id + end + + it "records which attribute that is" do + expect(schema[:resources][0][:public_id]).to eq("public_id") + end + + it "hides the internal primary key filter" do + expect(schema[:resources][0][:filters]).to_not have_key(:_primary_key) + end + end + + it "omits public_id when the id is the primary key" do + expect(schema[:resources][0]).to_not have_key(:public_id) + end + # Dynamically-created resources, e.g. remote resources context "when resource has missing name" do let(:no_name) do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2518acbb..55a60e27 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -41,10 +41,14 @@ # earlier examples defined - which makes results depend on spec order. config.around do |example| registered = Graphiti.resources.dup + public_id_sources = Graphiti.public_id_sources.instance_variable_get(:@sources).dup + public_ids_declared = Graphiti.public_ids_declared? setup_was = Graphiti.setup? example.run ensure Graphiti.resources.replace(registered) + Graphiti.public_id_sources.instance_variable_get(:@sources).replace(public_id_sources) + Graphiti.public_ids_declared = public_ids_declared Graphiti.instance_variable_set(:@setup, setup_was) end diff --git a/spec/stats/payload_spec.rb b/spec/stats/payload_spec.rb index 2b04ff92..3f996cb5 100644 --- a/spec/stats/payload_spec.rb +++ b/spec/stats/payload_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" RSpec.describe Graphiti::Stats::Payload do - let(:dsl) { double } + let(:dsl) { double(model_attribute_for: :attr1) } let(:query) { double(stats: {attr1: [:count, :average], attr2: [:maximum]}) } let(:scope) { double.as_null_object } let(:data) { double.as_null_object } From 9aa7a2b5e08edae466fbb3307fc98b3eb2cbc1ee Mon Sep 17 00:00:00 2001 From: Jeff Keen Date: Fri, 4 Sep 2026 09:58:58 -0500 Subject: [PATCH 4/5] feat: declare resource settings in the DSL form (adapter :active_record, model Employee), matching attribute :name The self.x = y form sat oddly next to attribute :name form. Settings now take the same form as everything else in the body, and the old way still works. abstract_class alone marks the class as abstract, and adapter :active_record looks up the adapter by name. Updated generators and docs to favor the new format. --- docs/concepts/backends-and-models.md | 4 +- docs/concepts/links.md | 16 +++--- docs/concepts/relationships.md | 4 +- docs/concepts/resources.md | 54 ++++++++++--------- docs/getting-started/installation.md | 2 +- docs/intro.md | 26 ++++----- docs/topics/authorization.md | 2 +- docs/topics/openstruct-models.md | 2 +- docs/topics/remote-resources.md | 14 ++--- docs/topics/without-activerecord.md | 2 +- docs/tutorial/step_0.md | 10 ++-- docs/tutorial/step_9.md | 2 +- docs/upgrading.md | 41 ++++++++------ lib/generators/graphiti/generator_mixin.rb | 2 +- .../templates/application_resource.rb.erb | 10 ++-- lib/graphiti/resource/configuration.rb | 24 +++++++-- .../rails/install_generator_spec.rb | 2 +- .../rails/resource_generator_spec.rb | 4 +- spec/performance/performance_history.tsv | 40 ++++++++++++++ spec/resource_spec.rb | 51 ++++++++++++++++++ 20 files changed, 214 insertions(+), 98 deletions(-) diff --git a/docs/concepts/backends-and-models.md b/docs/concepts/backends-and-models.md index 7d3313eb..2e044468 100644 --- a/docs/concepts/backends-and-models.md +++ b/docs/concepts/backends-and-models.md @@ -14,7 +14,7 @@ A **scope** is whatever your backend needs to run a query. Graphiti doesn't care ```ruby class EmployeeResource < ApplicationResource - self.adapter = Graphiti::Adapters::Null + adapter :null attribute :name, :string @@ -49,7 +49,7 @@ Writing that per Resource gets old. Once the pattern stabilizes, move it into an ```ruby class EmployeeResource < ApplicationResource - self.adapter = BackendAdapter + adapter BackendAdapter attribute :name, :string end ``` diff --git a/docs/concepts/links.md b/docs/concepts/links.md index 90c66f3b..5ae8f21e 100644 --- a/docs/concepts/links.md +++ b/docs/concepts/links.md @@ -80,7 +80,7 @@ this happens automatically: ```ruby class ApplicationResource < Graphiti::Resource # ... code ... - self.endpoint_namespace = '/api/v1' + endpoint_namespace '/api/v1' end class PostResource < ApplicationResource @@ -145,7 +145,7 @@ secondary_endpoint '/top_posts', [:index] ```ruby class ApplicationResource < Graphiti::Resource - self.relationship_links = false + relationship_links false end class PostResource < ApplicationResource @@ -166,7 +166,7 @@ Endpoints are validated in two directions, each with its own setting. ```ruby class ApplicationResource < Graphiti::Resource - self.validate_requests = false + validate_requests false end ``` @@ -174,7 +174,7 @@ end ```ruby class ApplicationResource < Graphiti::Resource - self.validate_links = false + validate_links false end ``` @@ -188,7 +188,7 @@ To only render relationship links when requested in the URL with `?links=true`: ```ruby class ApplicationResource < Graphiti::Resource - self.relationship_links = :on_demand + relationship_links :on_demand end ``` @@ -206,7 +206,7 @@ Every collection response returns pagination links: ```ruby class ApplicationResource < Graphiti::Resource - self.page_links = true + page_links true end ``` @@ -216,7 +216,7 @@ Links are rendered only when the request asks for them with `?page_links=true` ( ```ruby class ApplicationResource < Graphiti::Resource - self.page_links = :on_demand + page_links :on_demand end ``` @@ -231,7 +231,7 @@ To change the URL associated with a Resource: ```ruby class PostResource < ApplicationResource # Most commonly seen in ApplicationResource - self.endpoint_namespace = '/api/v1' + endpoint_namespace '/api/v1' primary_endpoint '/posts', [:index, :show] # OR diff --git a/docs/concepts/relationships.md b/docs/concepts/relationships.md index 7c32aedb..5a7f045c 100644 --- a/docs/concepts/relationships.md +++ b/docs/concepts/relationships.md @@ -164,7 +164,7 @@ To change how far a `belongs_to` goes, across a whole API, set it on the resourc ```ruby class ApplicationResource < Graphiti::Resource - self.belongs_to_resource_ids_by_default = :foreign_key + belongs_to_resource_ids_by_default :foreign_key end ``` @@ -191,7 +191,7 @@ A client never has to work out which rule applied. The relationship object says A relationship with neither is left out of the payload. Relationships are linked by default, so the link shape is the one you normally see. -`self.relationship_placeholders = true` brings back the 1.x shape, `{"meta": {"included": false}}`. It is not part of JSON:API and carries nothing a client can act on. +`relationship_placeholders true` brings back the 1.x shape, `{"meta": {"included": false}}`. It is not part of JSON:API and carries nothing a client can act on. The setting covers `belongs_to` and `polymorphic_belongs_to`, and no collection, deliberately. An API-wide `:always` on collections would be the N+1 from [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646) applied everywhere at once. diff --git a/docs/concepts/resources.md b/docs/concepts/resources.md index e08185f3..a7980b26 100644 --- a/docs/concepts/resources.md +++ b/docs/concepts/resources.md @@ -76,17 +76,17 @@ The model is only looked up when a guard declares a parameter for it, so zero-ar ```ruby # On ApplicationResource, affects every subclass -self.attributes_readable_by_default = false # default true -self.attributes_writable_by_default = false # default true -self.attributes_filterable_by_default = false # default true -self.attributes_sortable_by_default = false # default true -self.attributes_schema_by_default = false # default true +attributes_readable_by_default false # default true +attributes_writable_by_default false # default true +attributes_filterable_by_default false # default true +attributes_sortable_by_default false # default true +attributes_schema_by_default false # default true ``` Each `*_by_default` setting can also be a guard symbol, delegating the check to a method. Useful for wiring every attribute through one authorization system: ```ruby -self.attributes_readable_by_default = :attribute_readable? +attributes_readable_by_default :attribute_readable? def attribute_readable?(model_instance, attribute_name) PolicyChecker.new(model_instance).attribute_readable?(attribute_name) @@ -194,7 +194,7 @@ To serialize values exactly as the model returns them, skipping the type's `read ```ruby class ApplicationResource < Graphiti::Resource - self.typecast_reads = false + typecast_reads false end ``` @@ -479,8 +479,8 @@ Two settings you might want to adjust, both usually set on `ApplicationResource` ```ruby class ApplicationResource < Graphiti::Resource - self.page_default_size = 10 # unset falls back to 20 - self.page_max_size = 100 # default 1_000 + page_default_size 10 # unset falls back to 20 + page_max_size 100 # default 1_000 end ``` @@ -509,7 +509,7 @@ end ```ruby class PostResource < ApplicationResource - self.page_cursors = true # default false + page_cursors true # default false end ``` @@ -600,14 +600,14 @@ end ```ruby class PostResource < ApplicationResource - self.model = Post - self.type = 'posts' + model Post + type 'posts' # Only used if you care about Links primary_endpoint '/posts', [:index, :show, :create, :update, :destroy] - self.default_sort = [{ title: :asc }] # default nil - self.page_default_size = 10 # default 20 + default_sort [{ title: :asc }] # default nil + page_default_size 10 # default 20 end ``` @@ -616,36 +616,38 @@ Typically inherited from `ApplicationResource`, where cross-cutting settings liv ```ruby class ApplicationResource < Graphiti::Resource # Required when there's no corresponding model - self.abstract_class = true + abstract_class # Subclasses override as needed - self.adapter = Graphiti::Adapters::ActiveRecord + adapter :active_record # Default attribute flags. See #limiting-behavior - self.attributes_readable_by_default = true - self.attributes_writable_by_default = true - self.attributes_sortable_by_default = true - self.attributes_filterable_by_default = true + attributes_readable_by_default true + attributes_writable_by_default true + attributes_sortable_by_default true + attributes_filterable_by_default true # Used for link generation - self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') + base_url ENV.fetch('BASE_URL', 'http://localhost:3000') # Suggest referencing this in config/routes.rb: # scope path: '/api/v1' do # resources :posts # end - self.endpoint_namespace = '/api/v1' + endpoint_namespace '/api/v1' # Refuse requests reaching this Resource from a URL it isn't allowlisted for - self.validate_requests = true + validate_requests true # Refuse to render a link pointing at an endpoint that isn't routable - self.validate_links = true + validate_links true # Render relationship links: true, false, or :on_demand - self.relationship_links = true + relationship_links true end ``` +The bare form arrived in 2.1. Every setting also accepts the assignment form, `self.page_default_size = 10`, which is what code written for earlier versions uses, and the two are interchangeable. + ### Polymorphic Resources {#polymorphic-resources} Polymorphic Resources are similar to [ActiveRecord STI](https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html): a single query returns multiple Resource types. Querying `/tasks` can return `bugs`, `features`, and `epics`. @@ -683,7 +685,7 @@ end ```ruby class TaskResource < ApplicationResource # Reference child classes - self.polymorphic = [ + polymorphic [ 'BugResource', 'FeatureResource', 'EpicResource' diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 30f526ab..af9e6085 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -56,7 +56,7 @@ Resource: ```ruby # Assuming you already have a Post ActiveRecord Model class PostResource < Graphiti::Resource - self.adapter = Graphiti::Adapters::ActiveRecord + adapter :active_record attribute :title, :string end diff --git a/docs/intro.md b/docs/intro.md index 9b4e549e..81018550 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -20,7 +20,7 @@ Here is the whole loop. A Resource declares what's exposed: ```ruby title="app/resources/employee_resource.rb" class EmployeeResource < ApplicationResource - self.model = Employee # usually inferred from the class name, here for clarity + model Employee # usually inferred from the class name, here for clarity attribute :first_name, :string attribute :last_name, :string @@ -113,21 +113,21 @@ Every Resource inherits from an `ApplicationResource`, the same way models inher ```ruby title="app/resources/application_resource.rb" class ApplicationResource < Graphiti::Resource # Required when there's no corresponding model - self.abstract_class = true + abstract_class # Subclasses override as needed - self.adapter = Graphiti::Adapters::ActiveRecord + adapter :active_record # Flip any of these to lock down every Resource at once, # e.g. a read-only API - self.attributes_readable_by_default = true - self.attributes_writable_by_default = true - self.attributes_sortable_by_default = true - self.attributes_filterable_by_default = true + attributes_readable_by_default true + attributes_writable_by_default true + attributes_sortable_by_default true + attributes_filterable_by_default true # Used for link generation - self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') - self.endpoint_namespace = '/api/v1' + base_url ENV.fetch('BASE_URL', 'http://localhost:3000') + endpoint_namespace '/api/v1' def current_user context.current_user @@ -142,11 +142,11 @@ An individual Resource declares its attributes and relationships, plus anything ```ruby class EmployeeResource < ApplicationResource # Both inferred from the class name. Set them only when they differ - self.model = Employee - self.type = :employees # the JSONAPI type + model Employee + type :employees # the JSONAPI type - self.default_sort = [{ name: :desc }] # default nil - self.page_default_size = 10 # default 20 + default_sort [{ name: :desc }] # default nil + page_default_size 10 # default 20 attribute :name, :string attribute :age, :integer diff --git a/docs/topics/authorization.md b/docs/topics/authorization.md index 35b10e4d..131cabe2 100644 --- a/docs/topics/authorization.md +++ b/docs/topics/authorization.md @@ -107,7 +107,7 @@ You can set the same guard for every attribute on a Resource with `attributes_re ```ruby class ApplicationResource < Graphiti::Resource - self.attributes_writable_by_default = :writable_by_default? + attributes_writable_by_default :writable_by_default? def writable_by_default?(model_instance, attribute_name) PolicyChecker.new(context.current_user).writable?(model_instance, attribute_name) diff --git a/docs/topics/openstruct-models.md b/docs/topics/openstruct-models.md index ca442ba4..d68b08c7 100644 --- a/docs/topics/openstruct-models.md +++ b/docs/topics/openstruct-models.md @@ -4,7 +4,7 @@ title: 'OpenStruct Models' # OpenStruct Models -[Model Requirements](/concepts/backends-and-models#model-requirements) covers what any Model needs to respond to, and [Usage Without ActiveRecord](/topics/without-activerecord) walks through building a Resource around a PORO. `OpenStruct` satisfies those requirements with zero boilerplate - no `attr_accessor` list, no constructor - which is exactly why Graphiti uses it internally for [remote resources](/topics/remote-resources): `Resource::Remote` and the default `Sideload` model both set `self.model = OpenStruct` (`lib/graphiti/resource/remote.rb`, `lib/graphiti/sideload.rb`), since a remote resource doesn't know its shape ahead of time. That convenience comes with sharp edges if you reach for `OpenStruct` as a model in your own Resources. +[Model Requirements](/concepts/backends-and-models#model-requirements) covers what any Model needs to respond to, and [Usage Without ActiveRecord](/topics/without-activerecord) walks through building a Resource around a PORO. `OpenStruct` satisfies those requirements with zero boilerplate - no `attr_accessor` list, no constructor - which is exactly why Graphiti uses it internally for [remote resources](/topics/remote-resources): `Resource::Remote` and the default `Sideload` model both set `model OpenStruct` (`lib/graphiti/resource/remote.rb`, `lib/graphiti/sideload.rb`), since a remote resource doesn't know its shape ahead of time. That convenience comes with sharp edges if you reach for `OpenStruct` as a model in your own Resources. ## What Graphiti expects from it {#expectations} diff --git a/docs/topics/remote-resources.md b/docs/topics/remote-resources.md index d624ec30..4e9bad45 100644 --- a/docs/topics/remote-resources.md +++ b/docs/topics/remote-resources.md @@ -54,9 +54,9 @@ So, that means we can build an Adapter that makes an HTTP request to another Gra ```ruby class CommentResource < ApplicationResource - self.remote = "http://my-api.com/api/v1/comments" + remote "http://my-api.com/api/v1/comments" # under-the-hood, this sets: - # self.adapter = Graphiti::Adapters::GraphitiAPI + # adapter :graphiti_api end ``` @@ -96,7 +96,7 @@ We need only define the association locally: ```ruby class CommentResource < ApplicationResource - self.remote = "http://my-api.com/api/v1/comments" + remote "http://my-api.com/api/v1/comments" belongs_to :author end @@ -107,7 +107,7 @@ remote API. Again, works just like normal: ```ruby class CommentResource < ApplicationResource - self.remote = "http://my-api.com/api/v1/comments" + remote "http://my-api.com/api/v1/comments" attribute :body, :string do @object.body.truncate(100) @@ -137,7 +137,7 @@ end # end # # class CommentResource < ApplicationResource -# self.remote = 'http://my-api.com/api/v1/comments' +# remote 'http://my-api.com/api/v1/comments' # end ``` @@ -154,7 +154,7 @@ which allows for various adapters and middleware. In addition: ```ruby class CommentResource < ApplicationResource - self.remote = "..." + remote "..." # Customize faraday timeout self.timeout = 10 @@ -166,7 +166,7 @@ end ```ruby class CommentResource < ApplicationResource - self.remote = "..." + remote "..." def make_request(url) # request here is from Faraday: diff --git a/docs/topics/without-activerecord.md b/docs/topics/without-activerecord.md index 4e235bde..dfe17445 100644 --- a/docs/topics/without-activerecord.md +++ b/docs/topics/without-activerecord.md @@ -64,7 +64,7 @@ Adapter. ```ruby class PostResource < ApplicationResource - self.adapter = Graphiti::Adapters::Null + adapter :null attribute :title, :string diff --git a/docs/tutorial/step_0.md b/docs/tutorial/step_0.md index 82295212..be5a3e6f 100644 --- a/docs/tutorial/step_0.md +++ b/docs/tutorial/step_0.md @@ -38,21 +38,21 @@ Let's look at the above `ApplicationResource`: ```ruby class ApplicationResource < Graphiti::Resource - self.abstract_class = true + abstract_class # We'll be using ActiveRecord - self.adapter = Graphiti::Adapters::ActiveRecord + adapter :active_record # Links are generated from base_url + endpoint_namespace - self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') - self.endpoint_namespace = '/api/v1' + base_url ENV.fetch('BASE_URL', 'http://localhost:3000') + endpoint_namespace '/api/v1' end ``` This should be pretty self-explanatory except for ```ruby -self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') +base_url ENV.fetch('BASE_URL', 'http://localhost:3000') ``` When deriving and validating [Links](/concepts/links), we'll use the `BASE_URL` variable if diff --git a/docs/tutorial/step_9.md b/docs/tutorial/step_9.md index b1a9b114..3bcee49f 100644 --- a/docs/tutorial/step_9.md +++ b/docs/tutorial/step_9.md @@ -118,7 +118,7 @@ Now edit to support polymorphism and associations: ```ruby class TaskResource < ApplicationResource - self.polymorphic = %w(FeatureResource BugResource EpicResource) + polymorphic %w(FeatureResource BugResource EpicResource) attribute :employee_id, :integer, only: [:filterable] attribute :team_id, :integer, only: [:filterable] diff --git a/docs/upgrading.md b/docs/upgrading.md index a6d10423..83534cbc 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -1,9 +1,9 @@ --- -title: 'Upgrading to Graphiti 2.0' +title: 'Upgrading from 1.x' slug: /upgrading --- -# Upgrading to Graphiti 2.0 +# Upgrading from 1.x Graphiti 2.0 requires **Ruby 3.2+** and **ActiveSupport 7.1+**. Rails is not a dependency, but if you use it, 7.1+. Ruby 3.1 and earlier are past end of life, and Rails 6.1 and 7.0 do not support Ruby 3.2. Apps that cannot move yet should stay on the 1.x branch, which remains open for hotfixes. @@ -169,13 +169,13 @@ Or for the whole API, on the resource everything inherits from: ```ruby class ApplicationResource < Graphiti::Resource - self.abstract_class = true + abstract_class - self.belongs_to_resource_ids_by_default = :never + belongs_to_resource_ids_by_default :never end ``` -If you carry the `Sideload::BelongsTo` monkey patch from [#167](https://github.com/graphiti-api/graphiti/issues/167), delete it and set nothing. The default now covers the safe cases on its own. To force ids onto the rest the way the patch did, set `self.belongs_to_resource_ids_by_default = :always`, at a query per record for each one. +If you carry the `Sideload::BelongsTo` monkey patch from [#167](https://github.com/graphiti-api/graphiti/issues/167), delete it and set nothing. The default now covers the safe cases on its own. To force ids onto the rest the way the patch did, set `belongs_to_resource_ids_by_default :always`, at a query per record for each one. The three settings, and when a `belongs_to` cannot use its foreign key, are covered in [Customizing Relationships](/concepts/relationships#belongs-to-resource-ids). @@ -195,7 +195,7 @@ That shape comes from `jsonapi-serializable`, which fills in a relationship obje To keep them, on one resource or on the resource everything inherits from: ```ruby -self.relationship_placeholders = true +relationship_placeholders true ``` @@ -327,16 +327,16 @@ These are now resource settings. Set them on `ApplicationResource` to keep the o | 1.x | 2.0 | | --- | --- | -| `Graphiti.config.links_on_demand = true` | `self.relationship_links = :on_demand` | -| `Graphiti.config.pagination_links = true` | `self.page_links = true` | -| `Graphiti.config.pagination_links_on_demand = true` | `self.page_links = :on_demand` | -| `Graphiti.config.typecast_reads = false` | `self.typecast_reads = false` | +| `Graphiti.config.links_on_demand = true` | `relationship_links :on_demand` | +| `Graphiti.config.pagination_links = true` | `page_links true` | +| `Graphiti.config.pagination_links_on_demand = true` | `page_links :on_demand` | +| `Graphiti.config.typecast_reads = false` | `typecast_reads false` | ### Link rendering {#deprecated-links} | 1.x | 2.0 | | --- | --- | -| `self.autolink = false` | `self.relationship_links = false` | +| `self.autolink = false` | `relationship_links false` | Link rendering is one mode per link now. It takes `true`, `false`, or `:on_demand`, which renders only when the request asks with `?links=true`. `self.relationship_links` sets the resource default and `link:` overrides it per relationship. @@ -346,9 +346,9 @@ One behavior shift: `link: true` on a resource now always renders, even when the | 1.x | 2.0 | | --- | --- | -| `self.default_page_size = 10` | `self.page_default_size = 10` | -| `self.max_page_size = 500` | `self.page_max_size = 500` | -| `self.cursor_paginatable = true` | `self.page_cursors = true` | +| `self.default_page_size = 10` | `page_default_size 10` | +| `self.max_page_size = 500` | `page_max_size 500` | +| `self.cursor_paginatable = true` | `page_cursors true` | Everything relating to the `page` param shares its prefix: `page_default_size`, `page_max_size`, `page_cursors` and `page_links`. The on-demand param follows, so use `?page_links=true` (`?pagination_links=true` still works). `page_links` takes the same three modes as `relationship_links`, but has no per-relationship level. @@ -356,8 +356,8 @@ Everything relating to the `page` param shares its prefix: `page_default_size`, | 1.x | 2.0 | | --- | --- | -| `self.filters_accept_nil_by_default = true` | `self.filter_blanks_treated_as = :null` | -| `self.filters_deny_empty_by_default = true` | `self.filter_blanks_treated_as = :rejected` | +| `self.filters_accept_nil_by_default = true` | `filter_blanks_treated_as :null` | +| `self.filters_deny_empty_by_default = true` | `filter_blanks_treated_as :rejected` | | `filter :name, allow_nil: true` | `filter :name, blanks: :null` | | `filter :name, deny_empty: true` | `filter :name, blanks: :rejected` | @@ -367,7 +367,7 @@ Everything relating to the `page` param shares its prefix: `page_default_size`, | 1.x | 2.0 | | --- | --- | -| `self.validate_endpoints = false` | `self.validate_requests = false`, `self.validate_links = false` | +| `self.validate_endpoints = false` | `validate_requests false`, `validate_links false` | `validate_endpoints` did two unrelated jobs, so it split. `validate_requests` refuses requests to undeclared endpoints, and `validate_links` refuses to render links to unroutable ones. The old name sets both, and turning off link validation no longer disarms the inbound guard. @@ -414,3 +414,10 @@ handler.formatted_response(:json) # => [404, "{\"errors\":[...]}", :json] `GraphitiErrors.logger` has no replacement. `Graphiti.logger` is the nearest thing. + +## 2.1 {#2-1} + +Nothing to change. New, and optional: + +- Resource settings can be declared without `self.` and `=`: `model Show`, `default_sort [{id: :desc}]`, `abstract_class`. The assignment form still works. See [Configuration](/concepts/resources#configuration). +- `public_id` hides database ids from clients behind a column or an encoding. See [Public Ids](/concepts/resources#public-ids). diff --git a/lib/generators/graphiti/generator_mixin.rb b/lib/generators/graphiti/generator_mixin.rb index 0bca49eb..ec6b8f09 100644 --- a/lib/generators/graphiti/generator_mixin.rb +++ b/lib/generators/graphiti/generator_mixin.rb @@ -37,7 +37,7 @@ def resource_setting_groups name_width = Graphiti::Resource::SETTINGS.keys.map(&:length).max assignments = Graphiti::Resource::SETTINGS.to_h do |name, setting| value = setting[:format] || setting[:default].inspect - [name, "self.#{name.to_s.ljust(name_width)} = #{value}"] + [name, "#{name.to_s.ljust(name_width)} #{value}"] end hint_column = assignments.values.map(&:length).max + 2 diff --git a/lib/generators/graphiti/templates/application_resource.rb.erb b/lib/generators/graphiti/templates/application_resource.rb.erb index bfd103bb..5c995a96 100644 --- a/lib/generators/graphiti/templates/application_resource.rb.erb +++ b/lib/generators/graphiti/templates/application_resource.rb.erb @@ -2,14 +2,14 @@ # All Resources should inherit from ApplicationResource. <%- end -%> class ApplicationResource < Graphiti::Resource - self.abstract_class = true - self.adapter = Graphiti::Adapters::ActiveRecord + abstract_class + adapter :active_record <%- unless omit_comments? -%> # Or follow config/environments: - # self.base_url = ActionDispatch::Http::URL.url_for(Rails.application.routes.default_url_options) + # base_url ActionDispatch::Http::URL.url_for(Rails.application.routes.default_url_options) <%- end -%> - self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000') - self.endpoint_namespace = '<%= api_namespace %>' + base_url ENV.fetch('BASE_URL', 'http://localhost:3000') + endpoint_namespace '<%= api_namespace %>' <%- unless omit_comments? -%> # Defaults every Resource inherits. See https://graphiti.dev/concepts/resources diff --git a/lib/graphiti/resource/configuration.rb b/lib/graphiti/resource/configuration.rb index 955ed8b4..946bce47 100644 --- a/lib/graphiti/resource/configuration.rb +++ b/lib/graphiti/resource/configuration.rb @@ -63,7 +63,20 @@ module Configuration SETTINGS = SETTING_GROUPS.values.reduce(:merge).freeze # :nodoc: + DSL_SETTINGS = [:adapter, :base_url, :endpoint_namespace, :model, :remote, :remote_base_url, :type, :polymorphic, :polymorphic_child, :serializer, :graphql_entrypoint, *SETTINGS.keys].freeze # :nodoc: + + UNSET = Object.new.freeze # :nodoc: + module Overrides + # Real methods with a default, not define_method with a splat: these readers run per attribute per record. + DSL_SETTINGS.each do |name| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{name}(value = UNSET) + value.equal?(UNSET) ? super() : (self.#{name} = value) + end + RUBY + end + SETTINGS.each_pair do |name, setting| next unless setting[:values] @@ -113,6 +126,7 @@ def graphql_entrypoint=(val) # The .stat call stores a proc based on adapter # So if we assign a new adapter, reconfigure def adapter=(val) + val = Adapters.const_get(Adapters.constants.find { |name| name.to_s.underscore == val.to_s }) if val.is_a?(Symbol) super stat total: [:count] end @@ -135,8 +149,10 @@ def model=(val) config[:sideloads].each_value { |sideload| sideload.register_public_id_source if eagerly_apply_sideload?(sideload) } end - def model - klass = super + def model(value = UNSET) + return public_send(:model=, value) unless value.equal?(UNSET) + + klass = super() unless klass || abstract_class? if (klass = infer_model) self.model = klass @@ -349,11 +365,11 @@ def get_attr(name, flag, opts = {}) end def abstract_class? - !!abstract_class + !!@abstract_class end def abstract_class - @abstract_class + self.abstract_class = true end def abstract_class=(val) diff --git a/spec/integration/rails/install_generator_spec.rb b/spec/integration/rails/install_generator_spec.rb index edf37938..2d551f55 100644 --- a/spec/integration/rails/install_generator_spec.rb +++ b/spec/integration/rails/install_generator_spec.rb @@ -88,7 +88,7 @@ class Application < Rails::Application install! expect(generated("app/resources/application_resource.rb")) - .to include(%(self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000'))) + .to include(%(base_url ENV.fetch('BASE_URL', 'http://localhost:3000'))) end end end diff --git a/spec/integration/rails/resource_generator_spec.rb b/spec/integration/rails/resource_generator_spec.rb index 022313e1..a0872906 100644 --- a/spec/integration/rails/resource_generator_spec.rb +++ b/spec/integration/rails/resource_generator_spec.rb @@ -168,8 +168,8 @@ def generate!(*arguments) contents = generated("app/resources/application_resource.rb") expect(contents).to include(" # Links\n") - expect(contents).to include("# self.relationship_links = true # true, false, or :on_demand") - expect(contents).to include("# self.belongs_to_resource_ids_by_default = :foreign_key # :foreign_key, :always, or :never") + expect(contents).to include("# relationship_links true # true, false, or :on_demand") + expect(contents).to include("# belongs_to_resource_ids_by_default :foreign_key # :foreign_key, :always, or :never") end it "only generates request specs for the requested actions" do diff --git a/spec/performance/performance_history.tsv b/spec/performance/performance_history.tsv index 84d020c1..206ebe66 100644 --- a/spec/performance/performance_history.tsv +++ b/spec/performance/performance_history.tsv @@ -2439,3 +2439,43 @@ v2.0.0 4.0 on deep_and_sibling_50 resolve 9563 26.157 Apple M1 Max/10 bce6f3e3b7 v2.0.0 4.0 on deep_and_sibling_50 render 59675 36.137 Apple M1 Max/10 bce6f3e3b779 2b9685be19cf v2.0.0 4.0 on stats_100 resolve 1183 0.2 Apple M1 Max/10 bce6f3e3b779 2b9685be19cf v2.0.0 4.0 on stats_100 render 18549 2.842 Apple M1 Max/10 bce6f3e3b779 2b9685be19cf +pending 4.0 off flat_10 resolve 242 0.057 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off flat_10 render 2038 0.366 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off flat_100 resolve 961 0.179 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off flat_100 render 18328 2.957 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off sparse_100 resolve 979 0.183 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off sparse_100 render 16948 2.502 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off include_1 resolve 3050 0.808 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off include_1 render 27729 5.042 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off include_2 resolve 3562 1.207 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off include_2 render 35987 6.816 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off include_3 resolve 5685 1.956 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off include_3 render 47542 9.263 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off siblings_50 resolve 4824 19.735 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off siblings_50 render 37764 25.774 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off chain_and_sibling_50 resolve 5335 24.954 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off chain_and_sibling_50 render 46021 34.562 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off deep_and_sibling_50 resolve 7458 34.889 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off deep_and_sibling_50 render 57575 43.806 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off stats_100 resolve 980 0.184 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 off stats_100 render 18351 2.936 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on flat_10 resolve 242 0.058 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on flat_10 render 2038 0.363 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on flat_100 resolve 961 0.181 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on flat_100 render 18328 2.925 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on sparse_100 resolve 979 0.184 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on sparse_100 render 16948 2.474 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on include_1 resolve 3050 0.806 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on include_1 render 27729 4.941 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on include_2 resolve 3562 1.201 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on include_2 render 35987 6.765 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on include_3 resolve 5685 1.965 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on include_3 render 47542 9.06 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on siblings_50 resolve 5425 12.792 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on siblings_50 render 38365 20.111 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on chain_and_sibling_50 resolve 6130 18.787 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on chain_and_sibling_50 render 46816 28.879 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on deep_and_sibling_50 resolve 8447 28.069 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on deep_and_sibling_50 render 58564 38.04 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on stats_100 resolve 980 0.186 Apple M1 Max/10 8a96a56177db 2b9685be19cf +pending 4.0 on stats_100 render 18351 2.927 Apple M1 Max/10 8a96a56177db 2b9685be19cf diff --git a/spec/resource_spec.rb b/spec/resource_spec.rb index 052c7d49..e1ce9fac 100644 --- a/spec/resource_spec.rb +++ b/spec/resource_spec.rb @@ -221,6 +221,57 @@ def self.name end end + context "when overriding defaults in the DSL form" do + let(:klass) do + Class.new(app_resource) do + model PORO::Employee + adapter PORO::Adapter + default_sort [{name: :asc}] + page_default_size 4 + attributes_writable_by_default false + filter_blanks_treated_as :rejected + end + end + + it "assigns like the setter" do + expect(klass.model).to eq(PORO::Employee) + expect(klass.adapter).to eq(PORO::Adapter) + expect(klass.default_sort).to eq([{name: :asc}]) + expect(klass.page_default_size).to eq(4) + expect(klass.attributes_writable_by_default).to eq(false) + expect(klass.filter_blanks_treated_as).to eq(:rejected) + end + + it "assigns nil rather than reading" do + klass.default_sort nil + expect(klass.default_sort).to be_nil + end + + it "still validates values" do + expect { klass.filter_blanks_treated_as :bogus }.to raise_error(Graphiti::Errors::InvalidFilterBlanks) + end + + it "assigns the link settings too" do + klass.base_url "http://example.test" + klass.endpoint_namespace "/api/v2" + expect(klass.base_url).to eq("http://example.test") + expect(klass.endpoint_namespace).to eq("/api/v2") + end + + it "resolves a symbol adapter" do + klass.adapter :null + expect(klass.adapter).to eq(Graphiti::Adapters::Null) + klass.adapter :graphiti_api + expect(klass.adapter).to eq(Graphiti::Adapters::GraphitiAPI) + end + + it "marks abstract with a bare call" do + abstract = Class.new(app_resource) { abstract_class } + expect(abstract).to be_abstract_class + expect(Class.new(abstract)).not_to be_abstract_class + end + end + context "when manually setting serializer" do let(:klass) do Class.new(app_resource) do From 46c4869c99b8400949f3695910d9a568618ffada Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:06:53 +0000 Subject: [PATCH 5/5] chore(deps): bump nanoid from 3.3.17 to 3.3.18 in /website Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.17 to 3.3.18. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.17...3.3.18) --- updated-dependencies: - dependency-name: nanoid dependency-version: 3.3.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/package-lock.json | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/website/package-lock.json b/website/package-lock.json index 8a1fba9a..c1aa94e4 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -14055,9 +14055,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -17251,13 +17251,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "license": "MIT", - "peer": true - }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",