diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 00000000..60990a06
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+# standardrb reformat from the 1.4 -> 1.56 upgrade
+5fe4fe5188f75461be817528c8d911090ce94340
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a7ecea79..ce8d4225 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [main]
+ branches: [main, 1.x, beta]
pull_request: {}
workflow_dispatch: {}
@@ -15,145 +15,62 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
- ruby-version: "3.0"
+ ruby-version: "3.2"
bundler-cache: true
- name: Run standardrb
- run: bundle exec standardrb --no-fix --format progress
+ run: |
+ bundle exec standardrb --no-fix --format progress || {
+ echo "::error::Lint failed. Almost everything standard flags is auto-fixable — run 'bundle exec standardrb --fix' locally and push the result."
+ exit 1
+ }
test:
- name: "Tests - ${{ matrix.ruby }} | ${{ matrix.gemfile }} | Appraisal: ${{ matrix.appraisal }}"
+ name: "Tests - ${{ matrix.ruby }} | ${{ matrix.gemfile }}"
runs-on: ubuntu-latest
strategy:
fail-fast: false
+ # Every cell is listed explicitly rather than built from a cross product
+ # plus `exclude:`. An exclude row naming a gemfile that does not exist is
+ # silently ignored, which is how this matrix carried four dead rows for a
+ # "rails_7_0_graphiti_rails.gemfile" that was never generated. A bad
+ # gemfile path in an include row fails the job loudly instead.
+ #
+ # Ruby coverage per Rails version tracks that version's own supported
+ # range; see Appraisals for the matching sqlite3 constraints.
matrix:
- ruby:
- - "2.7"
- - "3.0"
- - "3.1"
- - "3.2"
- - "3.3"
- - "3.4"
- gemfile:
- - Gemfile
- - gemfiles/rails_5_2.gemfile
- - gemfiles/rails_6.gemfile
- - gemfiles/rails_7.gemfile
- - gemfiles/rails_5_2_graphiti_rails.gemfile
- - gemfiles/rails_6_graphiti_rails.gemfile
- - gemfiles/rails_7_graphiti_rails.gemfile
- - gemfiles/rails_7_1_graphiti_rails.gemfile
- - gemfiles/rails_7_2_graphiti_rails.gemfile
- - gemfiles/rails_8_0_graphiti_rails.gemfile
- - gemfiles/rails_8_1_graphiti_rails.gemfile
- appraisal:
- - true
- - false
include:
- - ruby: ruby-head
- gemfile: gemfiles/rails_7_1.gemfile
- appraisal: true
- - ruby: ruby-head
- gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
- appraisal: true
- - ruby: ruby-head
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
- appraisal: true
- - ruby: ruby-head
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
- appraisal: true
- - ruby: ruby-head
- gemfile: Gemfile
- appraisal: false
- exclude:
- # Skip some extra variants
- - gemfile: Gemfile
- appraisal: true
- - gemfile: gemfiles/rails_5_2.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_6.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_7.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_6_graphiti_rails.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_7_graphiti_rails.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
- appraisal: false
- - gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
- appraisal: false
+ # No Rails - the library on its own, across every supported Ruby
+ - { ruby: "3.2", gemfile: Gemfile, appraisal: false }
+ - { ruby: "3.3", gemfile: Gemfile, appraisal: false }
+ - { ruby: "3.4", gemfile: Gemfile, appraisal: false }
+ - { ruby: "4.0", gemfile: Gemfile, appraisal: false }
+ - { ruby: ruby-head, gemfile: Gemfile, appraisal: false }
- # Rails 8.1 needs ruby 3.2 +
- - gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
- ruby: 2.7
- - gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
- ruby: 3.0
- - gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
- ruby: 3.1
+ # Rails 7.1 - the floor
+ - { ruby: "3.2", gemfile: gemfiles/rails_7_1.gemfile, appraisal: true }
+ - { ruby: "3.4", gemfile: gemfiles/rails_7_1.gemfile, appraisal: true }
+ - { ruby: "4.0", gemfile: gemfiles/rails_7_1.gemfile, appraisal: true }
- # Rails 8 needs ruby 3.2 +
- - gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
- ruby: 2.7
- - gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
- ruby: 3.0
- - gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
- ruby: 3.1
+ # Rails 7.2
+ - { ruby: "3.2", gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
+ - { ruby: "3.4", gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
+ - { ruby: "4.0", gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
+ - { ruby: ruby-head, gemfile: gemfiles/rails_7_2.gemfile, appraisal: true }
- # Rails 7.2 needs ruby 3.1 +
- - gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
- ruby: 2.7
- - gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
- ruby: 3.0
+ # Rails 8.0
+ - { ruby: "3.2", gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
+ - { ruby: "3.4", gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
+ - { ruby: "4.0", gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
+ - { ruby: ruby-head, gemfile: gemfiles/rails_8_0.gemfile, appraisal: true }
- # Rails 7.1 needs ruby 3.0 +
- - gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
- ruby: 2.7
- - gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
- ruby: 3.0
+ # Rails 8.1
+ - { ruby: "3.2", gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
+ - { ruby: "3.4", gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
+ - { ruby: "4.0", gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
+ - { ruby: ruby-head, gemfile: gemfiles/rails_8_1.gemfile, appraisal: true }
- # Rails 7.0 needs ruby 3.1 +
- - gemfile: gemfiles/rails_7_0_graphiti_rails.gemfile
- ruby: 3.2
- - gemfile: gemfiles/rails_7_0_graphiti_rails.gemfile
- ruby: 3.3
- - gemfile: gemfiles/rails_7_0_graphiti_rails.gemfile
- ruby: 3.4
-
- # Rails 6 needs < ruby 3.4
- - gemfile: gemfiles/rails_6.gemfile
- ruby: 3.4
- - gemfile: gemfiles/rails_6_graphiti_rails.gemfile
- ruby: 3.4
-
- # Rails 5 can't run on Ruby 3
- - gemfile: gemfiles/rails_5_2.gemfile
- ruby: 3.0
- - gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
- ruby: 3.0
- - gemfile: gemfiles/rails_5_2.gemfile
- ruby: 3.1
- - gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
- ruby: 3.1
- - gemfile: gemfiles/rails_5_2.gemfile
- ruby: 3.2
- - gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
- ruby: 3.2
- - gemfile: gemfiles/rails_5_2.gemfile
- ruby: 3.3
- - gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
- ruby: 3.3
- - gemfile: gemfiles/rails_5_2.gemfile
- ruby: 3.4
- - gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
- ruby: 3.4
continue-on-error: ${{ matrix.ruby == 'ruby-head' }}
env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
@@ -161,21 +78,80 @@ jobs:
- name: Set up Appraisal
if: matrix.appraisal
run: echo "APPRAISAL_INITIALIZED=true" >> $GITHUB_ENV
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run tests
run: bundle exec rspec
+ # spec_helper narrows "bundle exec rspec" to spec/integration/rails when
+ # APPRAISAL_INITIALIZED is set, so the step above never runs the library
+ # suite. This is the only cell that runs it against a pinned Rails.
+ - name: Run the library suite against this Rails
+ if: matrix.appraisal
+ run: env -u APPRAISAL_INITIALIZED bundle exec rspec
+ example-app:
+ name: "Example app (employee_directory)"
+ runs-on: ubuntu-latest
+ # The example app lives in its own repo and can lag behind; it is an
+ # integration canary, not a merge gate.
+ continue-on-error: true
+ env:
+ GRAPHITI_PATH: ${{ github.workspace }}/graphiti
+ BUNDLE_FROZEN: "false" # the path override rewrites the checked-in lockfile
+ defaults:
+ run:
+ working-directory: employee_directory
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ path: graphiti
+ - uses: actions/checkout@v4
+ with:
+ repository: graphiti-api/employee_directory
+ path: employee_directory
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+ - name: Bundle against this graphiti
+ run: bundle install
+ - name: Run the example app suite
+ run: |
+ bundle exec rails db:test:prepare
+ bundle exec rspec
+ examples:
+ name: "Examples (plain ruby, sinatra)"
+ runs-on: ubuntu-latest
+ # In-tree examples run against the working copy via their path: Gemfiles.
+ steps:
+ - uses: actions/checkout@v4
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+ - name: Run the plain ruby example
+ working-directory: examples/plain_ruby
+ run: |
+ bundle install
+ bundle exec ruby index.rb
+ - name: Run the sinatra example
+ working-directory: examples/sinatra
+ run: |
+ bundle install
+ bundle exec ruby smoke.rb
publish:
name: Release
runs-on: ubuntu-latest
+ # Only main auto-releases. Beta releases are cut by manually running
+ # "Generate New Release" with the beta branch selected.
if: github.ref == 'refs/heads/main'
- needs: [test]
+ needs: [lint, test]
steps:
- name: Dispatch Release
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Generate New Release
+ # Without an explicit ref the dispatch runs against the default
+ # branch rather than the branch that triggered it.
+ ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..1149054e
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,60 @@
+name: Docs
+
+# Publishes the documentation site from beta, and from main once 2.0 lands
+# there. graphiti.dev now points at this repo via website/static/CNAME, so this
+# deploy is the live site: the 1.x Jekyll site is frozen under
+# website/static/1.13 and ships with every build, and graphiti-api.github.io no
+# longer serves the domain.
+on:
+ push:
+ branches: [beta, main]
+ paths:
+ - 'docs/**'
+ - 'website/**'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch: {}
+
+# One static group: deploys from different branches force-push the same
+# gh-pages branch, so they must not interleave.
+concurrency:
+ group: docs
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+
+jobs:
+ deploy:
+ name: Build and publish
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ - name: Install
+ working-directory: website
+ run: npm ci
+
+ - name: Build
+ working-directory: website
+ run: npm run build
+
+ - name: Publish to gh-pages
+ working-directory: website/build
+ run: |
+ set -euo pipefail
+ # Pages skips directories beginning with an underscore unless Jekyll
+ # is switched off, and Docusaurus emits some.
+ touch .nojekyll
+ git init --quiet --initial-branch=gh-pages
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add --all
+ git commit --quiet -m "docs: build ${GITHUB_SHA::7} from ${GITHUB_REF_NAME}"
+ git push --force --quiet \
+ "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" \
+ gh-pages
diff --git a/.github/workflows/notify-example-app.yml b/.github/workflows/notify-example-app.yml
new file mode 100644
index 00000000..c2d0b6bc
--- /dev/null
+++ b/.github/workflows/notify-example-app.yml
@@ -0,0 +1,18 @@
+name: Notify example app
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ dispatch:
+ name: Dispatch employee_directory update
+ runs-on: ubuntu-latest
+ steps:
+ - name: Send repository_dispatch
+ run: |
+ gh api -X POST repos/graphiti-api/employee_directory/dispatches \
+ -f event_type=graphiti-release \
+ -f 'client_payload[version]=${{ github.event.release.tag_name }}'
+ env:
+ GH_TOKEN: ${{ secrets.EXAMPLE_APP_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 944ffe9e..d75da6e9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,27 +10,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v5
with:
- submodules: true
persist-credentials: false
+ # semantic-release reads the tag history to work out the next version
+ fetch-depth: 0
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
- ruby-version: "3.1"
+ ruby-version: "4.0"
- name: Build
run: |
gem install bundler
- git submodule update --init --recursive
bundle install --jobs 4 --retry 3
- name: Setup Node.js
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
- node-version: 14
+ # semantic-release 25 and its plugins require ^22.22.2 || >=24.15
+ node-version: "24"
+ cache: npm
- name: Install Dependencies
- run: yarn install --frozen-lockfile
+ run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GEM_HOST_API_KEY: ${{ secrets.GEM_HOST_API_KEY }}
- run: node_modules/.bin/semantic-release
+ run: npx semantic-release
diff --git a/.gitignore b/.gitignore
index eaf46d2d..de139bd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,12 @@ spec/.rspec-examples
spec/dummy/log/*
*~
.vscode/
+
+# Docusaurus
+website/build/
+website/.docusaurus/
+
+.DS_Store
+.idea/
+examples/*/Gemfile.lock
+examples/sinatra/*.sqlite3*
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..64a57d97
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,9 @@
+# semantic-release-rubygem@1.2.0 declares `peerDependencies: { semantic-release:
+# ">=16.0.0 <18.0.0" }`, but it was last published in July 2020 and that range has
+# simply not been updated since. This project has been releasing against
+# semantic-release 19 for years, and the plugin loads cleanly under 25 exposing
+# its verifyConditions/prepare/publish hooks - it only touches `env` and `cwd` on
+# the plugin context, both stable across semantic-release majors.
+#
+# Without this, `npm ci` fails the install with ERESOLVE.
+legacy-peer-deps=true
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 00000000..eb39e538
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.3
diff --git a/.standard.yml b/.standard.yml
index 39471890..5c66111e 100644
--- a/.standard.yml
+++ b/.standard.yml
@@ -1,9 +1,18 @@
fix: true
parallel: true
-ruby_version: 2.7
+ruby_version: 3.2
ignore:
- 'vendor/**/*'
+ # The Rails app template's heredocs are written into the user's files, so
+ # their literal indentation is load-bearing and the <<~ autofix would
+ # change what gets generated.
+ - 'templates/**/*':
+ - Layout/HeredocIndentation
+ - Layout/ClosingHeredocIndentation
+ # The docs site, including the frozen 1.x archive under static/. None of it
+ # is ours to restyle, and some of it is generated.
+ - 'website/**/*'
- 'spec/**/*':
# There are some false hits when doing repeated
# Class.new blocks in different test cases
diff --git a/.tool-versions b/.tool-versions
new file mode 100644
index 00000000..05913eae
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1 @@
+ruby 4.0.6
diff --git a/Appraisals b/Appraisals
index 4de03ce0..aed9dbe3 100644
--- a/Appraisals
+++ b/Appraisals
@@ -1,60 +1,46 @@
-appraise "rails-5_2" do
- gem "rails", "~> 5.2.0"
- gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
-end
-
-appraise "rails-5_2-graphiti-rails" do
- gem "rails", "~> 5.2.0"
- gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
- gem "graphiti-rails", "~> 0.4.0"
-end
-
-appraise "rails-6" do
- gem "rails", "~> 6.0"
- gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
-end
+# Graphiti's Rails integration ships in the gem itself as of 2.0, so there is
+# one appraisal per Rails version rather than a with/without graphiti-rails pair.
+#
+# Rails 7.1 is the floor: 2.0 requires Ruby >= 3.2, and Rails 6.1 and 7.0 do not
+# support it. Apps on older Rails stay on the graphiti 1.x branch.
+#
+# Every Rails requirement pins the minor ("~> 7.1.0", not "~> 7.1"). The looser
+# form lets a newer minor satisfy it, which is how rails-7-1 and rails-8-0 ended
+# up resolving to 7.2 and 8.1 - two appraisals apiece testing the same Rails and
+# neither testing the version it was named for.
+#
+# sqlite3 pins follow what each Rails version's adapter demands at require time
+# (activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb):
+# Rails 7.1 -> gem "sqlite3", "~> 1.4" (2.x raises Gem::LoadError)
+# Rails 7.2 -> gem "sqlite3", ">= 1.4"
+# Rails 8.0 / 8.1 -> gem "sqlite3", ">= 2.1"
+# "~> 1.4" rather than "~> 1.4.0" is deliberate: it resolves to 1.7.x, which
+# still builds on modern Rubies, where 1.4.x no longer does.
-appraise "rails-6-graphiti-rails" do
- gem "rails", "~> 6.0"
- gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
- gem "graphiti-rails", "~> 0.4.0"
-end
-
-appraise "rails-7" do
- gem "rails", "~> 7.0"
+appraise "rails-7-1" do
+ gem "rails", "~> 7.1.0"
gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
+ gem "responders"
+ gem "sqlite3", "~> 1.4"
end
-appraise "rails-7-graphiti-rails" do
- gem "rails", "~> 7.0"
+appraise "rails-7-2" do
+ gem "rails", "~> 7.2.0"
gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
- gem "graphiti-rails", "~> 0.4.0"
+ gem "responders"
+ gem "sqlite3", "~> 2.1"
end
-appraise "rails-7-1" do
- gem "rails", "~> 7.1"
+appraise "rails-8-0" do
+ gem "rails", "~> 8.0.0"
gem "rspec-rails"
- # This appraisal is the ruby-head job; sqlite3 1.4.x cannot build on Ruby 4.x
+ gem "responders"
gem "sqlite3", "~> 2.1"
- gem "database_cleaner"
end
-appraise "rails-7-1-graphiti-rails" do
- gem "rails", "~> 7.1"
+appraise "rails-8-1" do
+ gem "rails", "~> 8.1.0"
gem "rspec-rails"
- gem "sqlite3", "~> 1.4.0"
- gem "database_cleaner"
- gem "graphiti-rails", "~> 0.4.0"
+ gem "responders"
+ gem "sqlite3", "~> 2.1"
end
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2ffff6d..ef16aa25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,233 @@
graphiti changelog
+# [2.0.0-beta.13](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.12...v2.0.0-beta.13) (2026-08-31)
+
+
+### Bug Fixes
+
+* compare the endpoint id to the payload id as strings ([62051d7](https://github.com/graphiti-api/graphiti/commit/62051d7531432357db7bc464cce77ccc55168d07))
+* evaluate writable guards under the action being performed ([d56b86a](https://github.com/graphiti-api/graphiti/commit/d56b86a5ffaac4106aaea1e2ee5400069a4205fe))
+* **generators:** stop injecting a routes host into config/application.rb ([db92d06](https://github.com/graphiti-api/graphiti/commit/db92d0649ca0246662a86b1172229eaa2a817d2b))
+* keep a belongs_to linkage when another include shares its name ([2c9c7c7](https://github.com/graphiti-api/graphiti/commit/2c9c7c7ac06cd319ac95e1445eb55a0322841de2))
+* keep deep filters narrow when an include path repeats ([833a10b](https://github.com/graphiti-api/graphiti/commit/833a10be120a4f2cd615ad05f750c7f246d62801))
+* keep request state in fiber storage so it survives into child fibers ([6569935](https://github.com/graphiti-api/graphiti/commit/6569935c2140d4dd91cd6d4a7f9ec7c1bd05c888))
+* only register the jsonapi mime type when it is missing ([88401ab](https://github.com/graphiti-api/graphiti/commit/88401abc8c77f1e859b4b8da3f9574c75834eb43))
+* with_options accepts the predicate backed relationship options ([71ab441](https://github.com/graphiti-api/graphiti/commit/71ab4418bccc4cff7e6cc10151c1728641d25d34))
+
+
+### Features
+
+* write every error code Graphiti renders into the generated locale file ([15f19c1](https://github.com/graphiti-api/graphiti/commit/15f19c1dbe56559d9d041fa50e2cc5ea7b2dcf4b))
+
+
+### Performance Improvements
+
+* record latest performance stats ([7380ef9](https://github.com/graphiti-api/graphiti/commit/7380ef912e9f3b99618bf47ed04e06d1efad4e6c))
+
+# [2.0.0-beta.12](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.11...v2.0.0-beta.12) (2026-08-30)
+
+
+### Bug Fixes
+
+* compare each concurrency path against itself in the table to better illustrate the performance wins ([d0f0e92](https://github.com/graphiti-api/graphiti/commit/d0f0e92860b3b4affe3fca6dbb9b871fe8a1374f))
+
+
+### Features
+
+* add schema rake tasks, so checking a schema no longer means running the suite ([6f4783d](https://github.com/graphiti-api/graphiti/commit/6f4783d5654659125b7eb358bf1175b6feaacb7c))
+* declare Graphiti's client errors in rescue_responses ([e369824](https://github.com/graphiti-api/graphiti/commit/e369824d6e11b671ffd212caad3d0a43a7ceefcf))
+* mark deprecated settings in the generated ApplicationResource ([52b0642](https://github.com/graphiti-api/graphiti/commit/52b0642e689a16d14dacaf4e28a2641f10774cf0))
+* take error text from locale keys ([d01fd06](https://github.com/graphiti-api/graphiti/commit/d01fd06fc1ede8c3dd798ec48de4d5b01645d87e)), closes [#216](https://github.com/graphiti-api/graphiti/issues/216)
+
+# [2.0.0-beta.11](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.10...v2.0.0-beta.11) (2026-08-27)
+
+
+### Bug Fixes
+
+* always render included when the client asked to include ([8691b3f](https://github.com/graphiti-api/graphiti/commit/8691b3fd08f3cae0481ba72839911fdc71ef5634))
+* autolink = true no longer clobbers an inherited :on_demand ([2d26f8c](https://github.com/graphiti-api/graphiti/commit/2d26f8cb567e82a16918345de9114b8ceef64523))
+* read the links param from the query, not the context object ([3b934cb](https://github.com/graphiti-api/graphiti/commit/3b934cb25be049e1e80ba400123da944cfd23d04))
+* stop assigning the same child twice when a resource repeats in the include path ([588503b](https://github.com/graphiti-api/graphiti/commit/588503b109508495d8170373d80019c6971f7d9c))
+
+
+### Features
+
+* omit relationships that render no ids and no link ([916aa8c](https://github.com/graphiti-api/graphiti/commit/916aa8c9c596c5d9b6c802c5ab13b766a13d1e70))
+
+
+### Performance Improvements
+
+* memoize whether a belongs_to renders ids from its foreign key ([dcb60b2](https://github.com/graphiti-api/graphiti/commit/dcb60b2096c414756ccff534773d2e45594f0144))
+* only deduplicate resources that two include paths can both reach ([744f014](https://github.com/graphiti-api/graphiti/commit/744f014249382bc25d5ea43f692778ad7adc25c3))
+* resolve a lone sideload inline rather than through the pool ([3415592](https://github.com/graphiti-api/graphiti/commit/3415592d75900389a8f11f53b65ab399e297e4a9))
+* reuse the resolved data and proxies when building cache keys ([8a17350](https://github.com/graphiti-api/graphiti/commit/8a17350542ba0062fdcddd115afe8041b62198c7))
+* skip the on-demand link strip when no relationship renders links on demand ([cf86a8c](https://github.com/graphiti-api/graphiti/commit/cf86a8c249077d9f9d5a13de49989d102bbaed04))
+
+# [2.0.0-beta.10](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.9...v2.0.0-beta.10) (2026-08-24)
+
+
+### Bug Fixes
+
+* apply sideloads at definition once setup! has run ([39ef393](https://github.com/graphiti-api/graphiti/commit/39ef39383520816319282854c0ae99677bcd5127)), closes [#524](https://github.com/graphiti-api/graphiti/issues/524)
+* give client-caused errors real detail and stop 500ing on bad query params ([35acc3e](https://github.com/graphiti-api/graphiti/commit/35acc3e6c78f68eeeb05728ea16264b136455c30)), closes [#531](https://github.com/graphiti-api/graphiti/issues/531)
+* let an abstract resource declare relationships ([6036392](https://github.com/graphiti-api/graphiti/commit/6036392ebd611c7ed492beb3fe5754433eac9ffd)), closes [#453](https://github.com/graphiti-api/graphiti/issues/453)
+* reject a non-object data payload instead of raising TypeError ([51603b8](https://github.com/graphiti-api/graphiti/commit/51603b854270715b80a75d9aa05a4020e6bbdce5))
+* Update some missing sections and terms after renames, register unsupported pagination error ([39cff19](https://github.com/graphiti-api/graphiti/commit/39cff19f6f1f73126514c8b18234a18ad5eb7379))
+
+
+### Features
+
+* carry CurrentAttributes into concurrent sideloads ([7092096](https://github.com/graphiti-api/graphiti/commit/7092096f6d99ad277cc56817c616d946f655d55b))
+* fold allow_nil and deny_empty into one blanks: filter option ([a108403](https://github.com/graphiti-api/graphiti/commit/a108403e5cd87fd2fdf177da30c4c4f97b9fe750))
+* move link rendering onto the resource as page_links and relationship_links ([57878d1](https://github.com/graphiti-api/graphiti/commit/57878d14fdfc4741e8d418c8edbc4a8687335843))
+* move typecast_reads onto the resource and let the railtie own Rails config ([236e970](https://github.com/graphiti-api/graphiti/commit/236e9706d7d8f08e9640c5c22ac596e2f7466849))
+* one link mode per relationship, cascading from relationship_links ([f0e1da1](https://github.com/graphiti-api/graphiti/commit/f0e1da1e13a8d12b47458495b378779f5687ffe2))
+* point connection pool timeouts at the sizing formula and add an audit task to check ([6e017c6](https://github.com/graphiti-api/graphiti/commit/6e017c60fd45338e4e6c8527adebfd46be4bfd8d))
+* split validate_endpoints into validate_requests and validate_links ([100a370](https://github.com/graphiti-api/graphiti/commit/100a370deec6a0e6ae165bdf77e50b6b14d068d3))
+
+# [2.0.0-beta.9](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.8...v2.0.0-beta.9) (2026-08-22)
+
+
+### Bug Fixes
+
+* bridge the last 1.x names that died with a bare NameError ([34e1dbd](https://github.com/graphiti-api/graphiti/commit/34e1dbd3e34bb8e6be56292c51fcb32674f75995))
+* build the entity map when the root query is, not on first use ([134d18b](https://github.com/graphiti-api/graphiti/commit/134d18b12b9ebb6903cca813209f60e5626b25a6))
+* coerce debug flags from env strings ([46be6aa](https://github.com/graphiti-api/graphiti/commit/46be6aa05d4e2a9e5d6b2562f223b4b05582bb39))
+* keep a customized sideload's instances out of entity dedup ([b1beee3](https://github.com/graphiti-api/graphiti/commit/b1beee3288fae2b20b009983aaa2f6c71189e2ca))
+* raise a more helpful named error when belongs_to linkage reads an unselected foreign key ([f59ea0b](https://github.com/graphiti-api/graphiti/commit/f59ea0bda94b388b1c2c0ae0e50b258f8ce0eb11))
+* resolve inline when already on a sideload pool thread ([11c9de7](https://github.com/graphiti-api/graphiti/commit/11c9de7fb4bfc2d83b02aaba3e58c1f94c84cec4))
+* scope debugger chunks to the request rather than the class ([9a77896](https://github.com/graphiti-api/graphiti/commit/9a77896c6e56d23b89d0a2aef358d8e8e0c28c54))
+* surface the first child error from concurrent polymorphic sideloads ([5a08c0e](https://github.com/graphiti-api/graphiti/commit/5a08c0edab2bc1ee0c87c6abbe1067297569411e))
+
+
+### Performance Improvements
+
+* nest the entity map instead of keying it by an array ([20bf625](https://github.com/graphiti-api/graphiti/commit/20bf6259d0347b02568b7d47054bb799e69629c3))
+* skip the promise machinery when nothing is sideloaded ([d879923](https://github.com/graphiti-api/graphiti/commit/d879923070a35d1a0e908b2d04746aa81effb262))
+* skip zipping a lone sideload promise ([7ef2cea](https://github.com/graphiti-api/graphiti/commit/7ef2cea654c48175504105f907b5b651c9fcdaf3))
+
+# [2.0.0-beta.8](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.7...v2.0.0-beta.8) (2026-08-10)
+
+
+### Bug Fixes
+
+* accept a single value for array filters ([#517](https://github.com/graphiti-api/graphiti/issues/517)) ([ee685b6](https://github.com/graphiti-api/graphiti/commit/ee685b625e967a8225d48b486bd093e3a6970176))
+* accept a single value for array filters ([#517](https://github.com/graphiti-api/graphiti/issues/517)) ([23f3e80](https://github.com/graphiti-api/graphiti/commit/23f3e80b583b0bf0843d8bb9334970ae56d83299))
+* drop the relationship guard install notice ([65b8be6](https://github.com/graphiti-api/graphiti/commit/65b8be65455a7d034c9dca082434c1dbfe610a7a))
+* reject invalid page parameters ([#537](https://github.com/graphiti-api/graphiti/issues/537)) ([fbb5683](https://github.com/graphiti-api/graphiti/commit/fbb5683393301e17ffcea84990ac5a95bdd3e15f)), closes [#347](https://github.com/graphiti-api/graphiti/issues/347)
+* treat empty polymorphic configuration as unset ([#538](https://github.com/graphiti-api/graphiti/issues/538)) ([a27eeea](https://github.com/graphiti-api/graphiti/commit/a27eeea6eeb72749376c1f7959cadc89012142b9)), closes [#199](https://github.com/graphiti-api/graphiti/issues/199)
+* typecast null values in filter lists ([#515](https://github.com/graphiti-api/graphiti/issues/515)) ([3756fd1](https://github.com/graphiti-api/graphiti/commit/3756fd12c48d2fb3cf5e2d40c38aab92f12218b0))
+* typecast null values in filter lists ([#515](https://github.com/graphiti-api/graphiti/issues/515)) ([3dcf4c8](https://github.com/graphiti-api/graphiti/commit/3dcf4c868abb12eb6b6d59cd0814b227d4838ece))
+
+
+### Features
+
+* add full set of commented-out Resource defaults in ApplicationResource when using the generator ([367678e](https://github.com/graphiti-api/graphiti/commit/367678eb3c2c737331f8edb00e00e70483a778b4))
+* **generators:** explicit rendering by default, respond_with only when Responders is included ([70741fe](https://github.com/graphiti-api/graphiti/commit/70741fef2a3a585bbef32c3e535027ff610fda77))
+* in-tree plain ruby and sinatra examples ([b92c6d9](https://github.com/graphiti-api/graphiti/commit/b92c6d9af22f759cc1f1eaccc250ecd04901e2aa))
+* ship the Rails application template in this repo ([76d5329](https://github.com/graphiti-api/graphiti/commit/76d53291f94c576792e3649044784f88fbaa47a7))
+
+# [2.0.0-beta.7](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.6...v2.0.0-beta.7) (2026-08-10)
+
+
+### Features
+
+* dedupe under concurrency and across distinct resources ([800b1a2](https://github.com/graphiti-api/graphiti/commit/800b1a2bf9bb59702efd00afe8e4f54ae06b500e))
+* deduplicate sideloaded entities across include paths ([0c2829a](https://github.com/graphiti-api/graphiti/commit/0c2829a634e9b96e2403ff168be9a48c6a663c78))
+* deprecate SpecHelpers::Sugar in favor of the full helper names ([b155849](https://github.com/graphiti-api/graphiti/commit/b155849f55fe84b2d0911fa4681d82682dfa39ae))
+
+# [2.0.0-beta.6](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.5...v2.0.0-beta.6) (2026-08-09)
+
+
+### Bug Fixes
+
+* reject invalid page parameters ([#537](https://github.com/graphiti-api/graphiti/issues/537)) ([bb3698b](https://github.com/graphiti-api/graphiti/commit/bb3698b52911c658c5d023ad8fd55b5106195001)), closes [#347](https://github.com/graphiti-api/graphiti/issues/347)
+* treat empty polymorphic configuration as unset ([#538](https://github.com/graphiti-api/graphiti/issues/538)) ([5c17899](https://github.com/graphiti-api/graphiti/commit/5c1789993fbb4638d4443cf014b8bef358addb80)), closes [#199](https://github.com/graphiti-api/graphiti/issues/199)
+
+
+### Features
+
+* Add graphiti:audit task to audit resources for issues ([84436ea](https://github.com/graphiti-api/graphiti/commit/84436eab7c7d0713ec6f355b62ba8b06913ee31a))
+* raise MissingRelationshipMethod when rendering reads an association the model does not define ([1c41cee](https://github.com/graphiti-api/graphiti/commit/1c41cee787b4f747d2906b2230f8fb926e3d887d))
+* rename always_include_resource_ids to resource_ids, with a belongs_to_resource_ids_by_default setting ([8b11151](https://github.com/graphiti-api/graphiti/commit/8b111516822f6fc6e3675b59941688791cb34e74))
+
+# [2.0.0-beta.5](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.4...v2.0.0-beta.5) (2026-08-07)
+
+
+### Bug Fixes
+
+* drop the relationship guard install notice ([c204312](https://github.com/graphiti-api/graphiti/commit/c20431214e126cb8e72c82022ee4c852b10a024b))
+* keep rake task helpers out of the global namespace ([0a41a60](https://github.com/graphiti-api/graphiti/commit/0a41a6022fa4c9151948714c31db367aee330e22)), closes [graphiti-api/graphiti-rails#91](https://github.com/graphiti-api/graphiti-rails/issues/91)
+
+
+### Features
+
+* add rspec matchers for resource relationships and attributes ([70525f7](https://github.com/graphiti-api/graphiti/commit/70525f7e10c511aa0c867ecc873f63f1790b8247)), closes [graphiti-api/graphiti_spec_helpers#14](https://github.com/graphiti-api/graphiti_spec_helpers/issues/14)
+* let the resource generator name the controller ([6ea7141](https://github.com/graphiti-api/graphiti/commit/6ea714181f3da011cae9f42477b4c3681a7a90ff)), closes [graphiti-api/graphiti-rails#53](https://github.com/graphiti-api/graphiti-rails/issues/53)
+
+# [2.0.0-beta.4](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.3...v2.0.0-beta.4) (2026-08-07)
+
+
+### Bug Fixes
+
+* a subclass redeclaring a relationship reaches its serializer ([976dbc4](https://github.com/graphiti-api/graphiti/commit/976dbc47e7bbf951da925a13a34f1bc9679470f8))
+* make each appraisal test the Rails version it is named for ([9e00652](https://github.com/graphiti-api/graphiti/commit/9e00652e124a411a2916d0fef52dc4fd2dc59020))
+* require active_support so graphiti boots without Rails ([d431a03](https://github.com/graphiti-api/graphiti/commit/d431a0328fb4071429f39f4c69cda9c6cee308f2))
+
+
+### Code Refactoring
+
+* bridge the remaining 1.x names ([3d80ef9](https://github.com/graphiti-api/graphiti/commit/3d80ef98cf765a152032b7fed89b3cdc10be23dc))
+
+
+### Features
+
+* belongs_to renders resource linkage by default ([024824d](https://github.com/graphiti-api/graphiti/commit/024824d319619811ade886c72ce345cfeb584dbc)), closes [#168](https://github.com/graphiti-api/graphiti/issues/168) [#185](https://github.com/graphiti-api/graphiti/issues/185) [#167](https://github.com/graphiti-api/graphiti/issues/167) [#167](https://github.com/graphiti-api/graphiti/issues/167)
+* fold graphiti_spec_helpers into graphiti ([da955a1](https://github.com/graphiti-api/graphiti/commit/da955a1e090ba44be31bf043ed90baa6661a4772))
+* fold graphiti-rails into graphiti ([7740f8a](https://github.com/graphiti-api/graphiti/commit/7740f8a5803f1fbf02ef4e27d8baa6e0809d2b43)), closes [graphiti-rails#52](https://github.com/graphiti-rails/issues/52)
+* handle exceptions with rescue_registry, fold in graphiti_errors ([e48171c](https://github.com/graphiti-api/graphiti/commit/e48171c117e7b8141fb6f966a972b0c1cd50c5ab))
+* require Ruby 3.2 and Rails 7.1 ([635b249](https://github.com/graphiti-api/graphiti/commit/635b249a99f7d1ef726ae2328707ee6210cc0608))
+
+
+### BREAKING CHANGES
+
+* nothing removed, everything warns and goes away in 3.0. Except `include GraphitiErrors`, which now raises, as rescue_registry replaced it, so there's nothing to point it at.
+* graphiti_errors is no longer a dependency and must be removed from the Gemfile, along with any `include GraphitiErrors`.
+
+GraphitiErrors::Validation::Serializer is now Graphiti::ErrorSerializers::Validation, and GraphitiErrors.enable!/disable! becomes handle_request_exceptions. 409 responses now report code "conflict" and title "Conflict Error".
+* Ruby >= 3.2 and Rails >= 7.1 are now required.
+* remove graphiti-rails from your Gemfile. Controllers serving Graphiti resources must `include Graphiti::Rails::Controller` — previously every controller received it whether it wanted it or not. Graphiti::Responders is now Graphiti::Rails::Responders.
+* remove graphiti_spec_helpers from your Gemfile. Prefer Graphiti::SpecHelpers and "graphiti/spec_helpers/rspec"; the old namespace and require paths still resolve, warn, and are removed in 3.0.
+
+# [2.0.0-beta.3](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.2...v2.0.0-beta.3) (2026-07-31)
+
+
+### Features
+
+* carry the assigned model on the resource, not through override signatures ([8ad848d](https://github.com/graphiti-api/graphiti/commit/8ad848d9bcd24cd63efb67e8ce32d5c8f3cfe147))
+
+# [2.0.0-beta.2](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2026-07-30)
+
+
+### Features
+
+* add Resource.wrap as an easy way to use graphiti serialization on models fetched via other means (and not via graphiti's finders) ([#513](https://github.com/graphiti-api/graphiti/issues/513)) ([fcd19e2](https://github.com/graphiti-api/graphiti/commit/fcd19e2d023091d85947dbdd89259b587e0f582b))
+* deprecate mutating attributes in around_persistence hooks ([#514](https://github.com/graphiti-api/graphiti/issues/514)) [skip ci] ([8410ab0](https://github.com/graphiti-api/graphiti/commit/8410ab0a916077601cf85d8260a05ef9098149eb))
+
+# [2.0.0-beta.1](https://github.com/graphiti-api/graphiti/compare/v1.12.2...v2.0.0-beta.1) (2026-07-30)
+
+
+### Features
+
+* drop Ruby 2.7 and Rails 5.2 support ([e905ddb](https://github.com/graphiti-api/graphiti/commit/e905ddb5c842299ffbfe5ec3c4ff98ec9b603a51))
+* the model you inspect is the model that saves ([#465](https://github.com/graphiti-api/graphiti/issues/465)) ([a905fff](https://github.com/graphiti-api/graphiti/commit/a905fffc3a2aa5e663ff4cadcd632e68f053c81a))
+
+
+### BREAKING CHANGES
+
+* around_persistence hooks receive the assigned model instead of the attributes hash. Move attribute-hash modifications to before_attributes, or set values on the model. Custom create/update overrides that should receive a pre-assigned model must accept an assigned_model: keyword. See UPGRADING.md
+* Ruby >= 3.0 / Rails >= 6 are now required.
+
## [1.13.4](https://github.com/graphiti-api/graphiti/compare/v1.13.3...v1.13.4) (2026-08-20)
@@ -31,6 +259,7 @@ graphiti changelog
* accept a single value for array filters ([#517](https://github.com/graphiti-api/graphiti/issues/517)) ([23f3e80](https://github.com/graphiti-api/graphiti/commit/23f3e80b583b0bf0843d8bb9334970ae56d83299))
* typecast null values in filter lists ([#515](https://github.com/graphiti-api/graphiti/issues/515)) ([3dcf4c8](https://github.com/graphiti-api/graphiti/commit/3dcf4c868abb12eb6b6d59cd0814b227d4838ece))
+
# [1.13.0](https://github.com/graphiti-api/graphiti/compare/v1.12.2...v1.13.0) (2026-07-30)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d98287f1..4e0a3156 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -65,7 +65,7 @@ The Rails integration specs are gated behind an environment variable and are
covering none of the ActiveRecord adapter:
```sh
-BUNDLE_GEMFILE=gemfiles/rails_8_0_graphiti_rails.gemfile \
+BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile \
APPRAISAL_INITIALIZED=1 \
bundle exec rspec spec/integration/rails/
```
@@ -75,3 +75,5 @@ Lint with:
```sh
bundle exec standardrb
```
+
+CI fails PRs that don't pass lint, and almost everything standard flags is auto-fixable — run `bundle exec standardrb --fix` before pushing. (A bare `rake` also runs lint before the specs.)
diff --git a/Gemfile b/Gemfile
index 6d80a9aa..b2cbaec9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -3,6 +3,8 @@ source "https://rubygems.org"
# Specify your gem's dependencies in graphiti.gemspec
gemspec
+gem "rescue_registry", "~> 1.1"
+
group :test do
gem "database_cleaner"
gem "pry"
diff --git a/README.md b/README.md
index 1e2346a1..b3ae29c7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-#### Graphiti
+### Graphiti
[](https://github.com/graphiti-api/graphiti/actions/workflows/ci.yml)
[](https://badge.fury.io/rb/graphiti)
@@ -7,222 +7,40 @@
[](https://discord.gg/wgqkMBsSRV)
-[](https://www.graphiti.dev)
+[](https://graphiti.dev)
-Graphiti is a resource-oriented framework that sits on top of your models (usually ActiveRecord) and exposes them via a JSON:API-compliant interface. It abstracts common concerns like serialization, filtering, sorting, pagination, and sideloading relationships, so you can build powerful APIs with minimal boilerplate. By defining resources instead of controllers and serializers, Graphiti helps you keep your API logic organized, consistent, and easy to maintain.
+Graphiti sits on top of your models (usually ActiveRecord) 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.
-#### Examples
-Here's an example resource from the [example app](https://github.com/graphiti-api/employee_directory/) just to give you a taste of the possibilities.
+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 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.
+A Resource looks like this:
```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
- attribute :title, :string, only: [:filterable, :sortable]
has_many :positions
- has_many :tasks
- many_to_many :teams
- polymorphic_has_many :notes, as: :notable
- has_one :current_position, resource: PositionResource do
- params do |hash|
- hash[:filter][:current] = true
- end
- end
-
- filter :title, only: [:eq] do
- eq do |scope, value|
- scope.joins(:current_position).merge(Position.where(title: value))
- end
- end
-
- sort :title do |scope, value|
- scope.joins(:current_position).merge(Position.order(title: value))
- end
-
- sort :department_name, :string do |scope, value|
- scope.joins(current_position: :department)
- .merge(Department.order(name: value))
- end
end
```
-A pretty boilerplate controller that just interfaces with the resource
-```ruby
-class EmployeesController < ApplicationController
- def index
- employees = EmployeeResource.all(params)
- respond_with(employees)
- end
-
- def show
- employee = EmployeeResource.find(params)
- respond_with(employee)
- end
-
- def create
- employee = EmployeeResource.build(params)
-
- if employee.save
- render jsonapi: employee, status: 201
- else
- render jsonapi_errors: employee
- end
- end
-
- def update
- employee = EmployeeResource.find(params)
-
- if employee.update_attributes
- render jsonapi: employee
- else
- render jsonapi_errors: employee
- end
- end
-
- def destroy
- employee = EmployeeResource.find(params)
-
- if employee.destroy
- render jsonapi: { meta: {} }, status: 200
- else
- render jsonapi_errors: employee
- end
- end
-end
-```
-
-
-
-
-Now you can query your endpoints simply and powerfully, like:
-
-
-
-Request:
-```http://localhost:3000/api/v1/employees?filter[title][eq]=Future Government Administrator&filter[age][lt]=40```
-
-
-JSON-API response
-
-```json
-{
- "data": [
- {
- "id": "1",
- "type": "employees",
- "attributes": {
- "first_name": "Quinn",
- "last_name": "Homenick",
- "age": 36,
- "created_at": "2025-03-21T23:04:40+00:00",
- "updated_at": "2025-03-21T23:04:40+00:00"
- },
- "relationships": {
- "positions": {
- "links": {
- "related": "/api/v1/positions?filter[employee_id]=1"
- },
- "data": [
- {
- "type": "positions",
- "id": "1"
- },
- {
- "type": "positions",
- "id": "2"
- }
- ]
- },
- "tasks": {
- "links": {
- "related": "/api/v1/tasks?filter[employee_id]=1"
- }
- },
- "teams": {
- "links": {
- "related": "/api/v1/teams?filter[employee_id]=1"
- }
- },
- "notes": {
- "links": {
- "related": "/api/v1/notes?filter[notable_id]=1&filter[notable_type][eql]=Employee"
- }
- },
- "current_position": {
- "links": {
- "related": "/api/v1/positions?filter[current]=true&filter[employee_id]=1"
- },
- "data": {
- "type": "positions",
- "id": "1"
- }
- }
- }
- }
- ],
- "included": [
- {
- "id": "1",
- "type": "positions",
- "attributes": {
- "title": "Future Government Administrator",
- "active": true
- },
- "relationships": {
- "employee": {
- "links": {
- "related": "/api/v1/employees/1"
- }
- },
- "department": {
- "links": {
- "related": "/api/v1/departments/3"
- }
- }
- }
- },
- {
- "id": "2",
- "type": "positions",
- "attributes": {
- "title": "Manufacturing Specialist",
- "active": false
- },
- "relationships": {
- "employee": {
- "links": {
- "related": "/api/v1/employees/1"
- }
- },
- "department": {
- "links": {
- "related": "/api/v1/departments/2"
- }
- }
- }
- }
- ],
- "meta": {}
-}
-```
+That Resource serves `?filter[age][gt]=30`, `?sort=-age`, `?page[size]=10`, `?include=positions` and more, without writing any of them. See [graphiti.dev](https://graphiti.dev/) for the whole loop, or the [example app](https://github.com/graphiti-api/employee_directory/) for a full working API.
-
+### Documentation
+Docs live at [graphiti.dev](https://graphiti.dev/) and are rendered from this repo: the markdown is in [`/docs`](docs), and the Docusaurus site that serves it is in [`/website`](website). Fixes and improvements are welcome (every page has an "Edit this page" link at the bottom, which makes opening a PR with a change easy).
+To preview locally:
-[Graphiti Guides](https://www.graphiti.dev/guides/)
+```bash
+cd website
+npm install
+npm run start
+```
[Join the Discord](https://discord.gg/wgqkMBsSRV)
-
-
-
-
diff --git a/Rakefile b/Rakefile
index ce064ce2..2c61ab40 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,6 +1,9 @@
require "bundler/gem_tasks"
require "rspec/core/rake_task"
require "appraisal"
+# Standard is silent on success; show the inspected-files summary.
+ENV["STANDARDOPTS"] ||= "--format progress"
+require "standard/rake"
RSpec::Core::RakeTask.new(:spec) do |t|
if ENV["APPRAISAL_INITIALIZED"]
@@ -8,8 +11,41 @@ RSpec::Core::RakeTask.new(:spec) do |t|
end
end
-if !ENV["APPRAISAL_INITIALIZED"] && !ENV["TRAVIS"]
- task default: [:spec, :appraisal]
-else
+# Allocations mean the same anywhere and timings do not, so the history has a
+# reference CPU, named on every row, and recording timings refuses to mix them.
+namespace :performance do
+ # Shelling out keeps a failure to the script's own message, with no rake backtrace on top.
+ def measure(*arguments)
+ exit(1) unless system("bundle", "exec", "ruby", "spec/performance/measure_releases.rb", *arguments)
+ end
+
+ desc "Read a change: measure the working tree, print the comparison and plot it"
+ task :read do
+ measure("--read")
+ end
+
+ desc "Open the recorded history as a chart. Any machine, measures nothing"
+ task :chart do
+ exit(1) unless system("bundle", "exec", "ruby", "spec/performance/chart_page.rb", "--no-current")
+ end
+
+ desc "Fill in the history. Missing releases by default, TAG=v2.0.0-beta.9 for one, ALL=1 for every. Reference CPU"
+ task :record do
+ measure(*(if ENV["TAG"]
+ [ENV["TAG"]]
+ else
+ ENV["ALL"] ? ["--all"] : ["--missing"]
+ end))
+ end
+
+ desc "Measure the working tree for the release to name, and commit it. Reference CPU"
+ task :pending do
+ measure("--pending")
+ end
+end
+
+if ENV["APPRAISAL_INITIALIZED"]
task default: [:spec]
+else
+ task default: [:standard, :spec, :appraisal]
end
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 00000000..edbcc30f
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,5 @@
+# Upgrading Graphiti
+
+The upgrade guide lives with the rest of the documentation, so it can link into the concept pages and offer the same version picker: **https://graphiti.dev/upgrading**
+
+In this repository it is [docs/upgrading.md](docs/upgrading.md).
diff --git a/deprecated_generators/graphiti/generator_mixin.rb b/deprecated_generators/graphiti/generator_mixin.rb
deleted file mode 100644
index 237a9908..00000000
--- a/deprecated_generators/graphiti/generator_mixin.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-module Graphiti
- module GeneratorMixin
- def prompt(header: nil, description: nil, default: nil)
- say(set_color("\n#{header}", :magenta, :bold)) if header
- say("\n#{description}") if description
- answer = ask(set_color("\n(default: #{default}):", :magenta, :bold))
- answer = default if answer.blank? && default != "nil"
- say(set_color("\nGot it!\n", :white, :bold))
- answer
- end
-
- def api_namespace
- @api_namespace ||= begin
- ns = graphiti_config["namespace"]
- ns.delete_suffix("/")
-
- if ns.blank?
- ns = prompt \
- header: "What is your API namespace?",
- description: "This will be used as a route prefix, e.g. if you want the route '/books_api/v1/authors' your namespace would be '/books_api/v1'",
- default: "/api/v1"
- update_config!("namespace" => ns)
- end
-
- ns
- end
- end
-
- def actions
- @options["actions"] || %w[index show create update destroy]
- end
-
- def actions?(*methods)
- methods.any? { |m| actions.include?(m) }
- end
-
- def graphiti_config
- File.exist?(".graphiticfg.yml") ? YAML.load_file(".graphiticfg.yml") : {}
- end
-
- def update_config!(attrs)
- config = graphiti_config.merge(attrs)
- File.open(".graphiticfg.yml", "w") { |f| f.write(config.to_yaml) }
- end
- end
-end
diff --git a/deprecated_generators/graphiti/install_generator.rb b/deprecated_generators/graphiti/install_generator.rb
deleted file mode 100644
index 7496ee4a..00000000
--- a/deprecated_generators/graphiti/install_generator.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-require_relative "generator_mixin"
-
-module Graphiti
- class InstallGenerator < ::Rails::Generators::Base
- include GeneratorMixin
-
- source_root File.expand_path("../templates", __FILE__)
-
- class_option :'omit-comments',
- type: :boolean,
- default: false,
- aliases: ["-c"],
- desc: "Generate without documentation comments"
-
- desc "This generator boostraps graphiti"
- def install
- to = File.join("app/resources", "application_resource.rb")
- template("application_resource.rb.erb", to)
-
- inject_into_file "app/controllers/application_controller.rb", after: "class ApplicationController < ActionController::API\n" do
- app_controller_code
- end
-
- inject_into_file "app/controllers/application_controller.rb", after: "class ApplicationController < ActionController::Base\n" do
- app_controller_code
- end
-
- inject_into_file "config/application.rb", after: "Rails::Application\n" do
- <<-'RUBY'
- # In order for Graphiti to generate links, you need to set the routes host.
- # When not explicitly set, via the HOST env var, this will fall back to
- # the rails server settings.
- # Rails::Server is not defined in console or rake tasks, so this will only
- # use those defaults when they are available.
- routes.default_url_options[:host] = ENV.fetch('HOST') do
- if defined?(Rails::Server)
- argv_options = Rails::Server::Options.new.parse!(ARGV)
- "http://#{argv_options[:Host]}:#{argv_options[:Port]}"
- end
- end
- RUBY
- end
-
- inject_into_file "spec/rails_helper.rb", after: /RSpec.configure.+^end$/m do
- "\n\nGraphitiSpecHelpers::RSpec.schema!"
- end
-
- insert_into_file "config/routes.rb", after: "Rails.application.routes.draw do\n" do
- if defined?(VandalUi)
- <<-STR
- scope path: ApplicationResource.endpoint_namespace, defaults: { format: :jsonapi } do
- mount VandalUi::Engine, at: '/vandal'
- # your routes go here
- end
- STR
- else
- <<-STR
- scope path: ApplicationResource.endpoint_namespace, defaults: { format: :jsonapi } do
- # your routes go here
- end
- STR
- end
- end
- end
-
- private
-
- def omit_comments?
- @options["omit-comments"]
- end
-
- def app_controller_code
- str = ""
- str << " include Graphiti::Rails\n"
- str << " include Graphiti::Responders\n"
- str << "\n"
- str << " register_exception Graphiti::Errors::RecordNotFound,\n"
- str << " status: 404\n"
- str << "\n"
- str << " rescue_from Exception do |e|\n"
- str << " handle_exception(e)\n"
- str << " end\n"
- str
- end
- end
-end
diff --git a/deprecated_generators/graphiti/templates/application_resource.rb.erb b/deprecated_generators/graphiti/templates/application_resource.rb.erb
deleted file mode 100644
index 1c5b8c3d..00000000
--- a/deprecated_generators/graphiti/templates/application_resource.rb.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-<%- unless omit_comments? -%>
-# ApplicationResource is similar to ApplicationRecord - a base class that
-# holds configuration/methods for subclasses.
-# All Resources should inherit from ApplicationResource.
-<%- end -%>
-class ApplicationResource < Graphiti::Resource
- <%- unless omit_comments? -%>
- # Use the ActiveRecord Adapter for all subclasses.
- # Subclasses can still override this default.
- <%- end -%>
- self.abstract_class = true
- self.adapter = Graphiti::Adapters::ActiveRecord
- self.base_url = Rails.application.routes.default_url_options[:host]
- self.endpoint_namespace = '<%= api_namespace %>'
-end
diff --git a/docs/concepts/backends-and-models.md b/docs/concepts/backends-and-models.md
new file mode 100644
index 00000000..7d3313eb
--- /dev/null
+++ b/docs/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/docs/concepts/endpoints.md b/docs/concepts/endpoints.md
new file mode 100644
index 00000000..1f845c1d
--- /dev/null
+++ b/docs/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/docs/concepts/links.md b/docs/concepts/links.md
new file mode 100644
index 00000000..90c66f3b
--- /dev/null
+++ b/docs/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/docs/concepts/overview.md b/docs/concepts/overview.md
new file mode 100644
index 00000000..6b0c5490
--- /dev/null
+++ b/docs/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.
+
+
+
+
+
+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/docs/concepts/persisting.md b/docs/concepts/persisting.md
new file mode 100644
index 00000000..6350c4ef
--- /dev/null
+++ b/docs/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/docs/concepts/relationships.md b/docs/concepts/relationships.md
new file mode 100644
index 00000000..7c32aedb
--- /dev/null
+++ b/docs/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/docs/concepts/resources.md b/docs/concepts/resources.md
new file mode 100644
index 00000000..dd81043b
--- /dev/null
+++ b/docs/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/docs/getting-started/first-api.md b/docs/getting-started/first-api.md
new file mode 100644
index 00000000..8b8b1570
--- /dev/null
+++ b/docs/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:
+
+
+
+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:
+
+
+
+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/docs/getting-started/installation.md b/docs/getting-started/installation.md
new file mode 100644
index 00000000..30f526ab
--- /dev/null
+++ b/docs/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/docs/intro.md b/docs/intro.md
new file mode 100644
index 00000000..9b4e549e
--- /dev/null
+++ b/docs/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/docs/js/authentication.md b/docs/js/authentication.md
new file mode 100644
index 00000000..1477e917
--- /dev/null
+++ b/docs/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).
+
+
diff --git a/docs/js/ddau.md b/docs/js/ddau.md
new file mode 100644
index 00000000..bf9b2ebc
--- /dev/null
+++ b/docs/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/docs/js/extra-params.md b/docs/js/extra-params.md
new file mode 100644
index 00000000..11ebfae7
--- /dev/null
+++ b/docs/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/docs/js/index.md b/docs/js/index.md
new file mode 100644
index 00000000..3cfc2013
--- /dev/null
+++ b/docs/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/docs/js/installation.md b/docs/js/installation.md
new file mode 100644
index 00000000..f1862448
--- /dev/null
+++ b/docs/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"
+ }
+})
+```
+
+
diff --git a/docs/js/models.md b/docs/js/models.md
new file mode 100644
index 00000000..af34e48c
--- /dev/null
+++ b/docs/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
+})
+```
+
+
+
+## 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
+```
+
+
+## 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
+```
+
+
+`#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()
+```
+
+
+## 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()
+```
+
+
+### 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()
+```
+
+
+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()
+```
+
+
+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
+ })
+```
+
diff --git a/docs/js/state-syncing.md b/docs/js/state-syncing.md
new file mode 100644
index 00000000..4316736c
--- /dev/null
+++ b/docs/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/docs/js/writes.md b/docs/js/writes.md
new file mode 100644
index 00000000..6c4fd585
--- /dev/null
+++ b/docs/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();
+```
+
+
diff --git a/docs/reference/vandal.md b/docs/reference/vandal.md
new file mode 100644
index 00000000..ffb589f3
--- /dev/null
+++ b/docs/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/docs/reference/why.md b/docs/reference/why.md
new file mode 100644
index 00000000..f8743607
--- /dev/null
+++ b/docs/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/docs/topics/authorization.md b/docs/topics/authorization.md
new file mode 100644
index 00000000..35b10e4d
--- /dev/null
+++ b/docs/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/docs/topics/caching.md b/docs/topics/caching.md
new file mode 100644
index 00000000..7b9d4f21
--- /dev/null
+++ b/docs/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/docs/topics/customizing-sideloads.md b/docs/topics/customizing-sideloads.md
new file mode 100644
index 00000000..bded319d
--- /dev/null
+++ b/docs/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/docs/topics/debugging.md b/docs/topics/debugging.md
new file mode 100644
index 00000000..c7179b0b
--- /dev/null
+++ b/docs/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/docs/topics/error-handling.md b/docs/topics/error-handling.md
new file mode 100644
index 00000000..20e39578
--- /dev/null
+++ b/docs/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/docs/topics/etags.md b/docs/topics/etags.md
new file mode 100644
index 00000000..e64ce556
--- /dev/null
+++ b/docs/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/docs/topics/hopping-relationships.md b/docs/topics/hopping-relationships.md
new file mode 100644
index 00000000..f918bd2c
--- /dev/null
+++ b/docs/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/docs/topics/json-attributes.md b/docs/topics/json-attributes.md
new file mode 100644
index 00000000..2e9501a9
--- /dev/null
+++ b/docs/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/docs/topics/openstruct-models.md b/docs/topics/openstruct-models.md
new file mode 100644
index 00000000..ca442ba4
--- /dev/null
+++ b/docs/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/docs/topics/remote-resources.md b/docs/topics/remote-resources.md
new file mode 100644
index 00000000..d624ec30
--- /dev/null
+++ b/docs/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/docs/topics/testing.md b/docs/topics/testing.md
new file mode 100644
index 00000000..426f7699
--- /dev/null
+++ b/docs/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/docs/topics/without-activerecord.md b/docs/topics/without-activerecord.md
new file mode 100644
index 00000000..4e235bde
--- /dev/null
+++ b/docs/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/docs/tutorial/index.md b/docs/tutorial/index.md
new file mode 100644
index 00000000..04ee1962
--- /dev/null
+++ b/docs/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/docs/tutorial/step_0.md b/docs/tutorial/step_0.md
new file mode 100644
index 00000000..82295212
--- /dev/null
+++ b/docs/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!
+
+
+
diff --git a/docs/tutorial/step_1.md b/docs/tutorial/step_1.md
new file mode 100644
index 00000000..8623e226
--- /dev/null
+++ b/docs/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`:
+
+
+
+
+
id
+
first_name
+
last_name
+
age
+
created_at
+
updated_at
+
+
+
+
+
1
+
Homer
+
Simpson
+
39
+
2018-09-04
+
2018-09-04
+
+
+
2
+
Waylon
+
Smithers
+
65
+
2018-09-04
+
2018-09-04
+
+
+
3
+
Monty
+
Burns
+
123
+
2018-09-04
+
2018-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):
+
+
+
+
+
+
+
+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.
+
+
+
diff --git a/docs/tutorial/step_2.md b/docs/tutorial/step_2.md
new file mode 100644
index 00000000..0ffe6476
--- /dev/null
+++ b/docs/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`:
+
+
+
+
+
id
+
employee_id
+
title
+
active
+
historical_index
+
created_at
+
updated_at
+
+
+
+
+
1
+
900
+
Engineer
+
true
+
1
+
2018-09-04
+
2018-09-04
+
+
+
2
+
900
+
Intern
+
true
+
2
+
2018-09-04
+
2018-09-04
+
+
+
3
+
800
+
Manager
+
true
+
1
+
2018-09-04
+
2018-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.
+
+
+
+
diff --git a/docs/tutorial/step_3.md b/docs/tutorial/step_3.md
new file mode 100644
index 00000000..8faa5ee6
--- /dev/null
+++ b/docs/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`:
+
+
+
+
+
id
+
name
+
+
+
+
+
1
+
Engineering
+
+
+
2
+
Safety
+
+
+
3
+
QA
+
+
+
+
+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.
+
+
+
diff --git a/docs/tutorial/step_4.md b/docs/tutorial/step_4.md
new file mode 100644
index 00000000..4bd930f2
--- /dev/null
+++ b/docs/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).
+
+
+
diff --git a/docs/tutorial/step_5.md b/docs/tutorial/step_5.md
new file mode 100644
index 00000000..55da867e
--- /dev/null
+++ b/docs/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`.
+
+
+
diff --git a/docs/tutorial/step_6.md b/docs/tutorial/step_6.md
new file mode 100644
index 00000000..62e29c6c
--- /dev/null
+++ b/docs/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.
+
+
+
diff --git a/docs/tutorial/step_7.md b/docs/tutorial/step_7.md
new file mode 100644
index 00000000..15cc8f2e
--- /dev/null
+++ b/docs/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`.
+
+
+
+
+
id
+
department_id
+
name
+
+
+
+
+
1
+
1
+
The A Team
+
+
+
2
+
1
+
The B Team
+
+
+
3
+
2
+
The C Team
+
+
+
+
+To satisfy this many-to-many use case, we'll need a join model,
+`TeamMembership`:
+
+
+
+
+
id
+
team_id
+
employee_id
+
+
+
+
+
1
+
1
+
1
+
+
+
2
+
2
+
1
+
+
+
3
+
3
+
2
+
+
+
+
+### 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.
+
+
+
diff --git a/docs/tutorial/step_8.md b/docs/tutorial/step_8.md
new file mode 100644
index 00000000..03a1f462
--- /dev/null
+++ b/docs/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).
+
+
+
+
+
id
+
notable_id
+
notable_type
+
body
+
+
+
+
+
1
+
1
+
Employee
+
A Sample Note!
+
+
+
2
+
1
+
Department
+
Another Sample Note!
+
+
+
3
+
1
+
Team
+
A 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
+```
+
+
+
diff --git a/docs/tutorial/step_9.md b/docs/tutorial/step_9.md
new file mode 100644
index 00000000..b1a9b114
--- /dev/null
+++ b/docs/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`.
+
+
+
+
+
id
+
milestone_id
+
type
+
title
+
+
+
+
+
1
+
null
+
Bug
+
Incorrect Value!
+
+
+
2
+
null
+
Feature
+
Build great stuff!
+
+
+
3
+
1
+
Epic
+
Build 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/docs/upgrading.md b/docs/upgrading.md
new file mode 100644
index 00000000..b190c309
--- /dev/null
+++ b/docs/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.0.beta" # follows the betas and picks up 2.0 final when it ships
+- 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/examples/plain_ruby/Gemfile b/examples/plain_ruby/Gemfile
new file mode 100644
index 00000000..97fbe42b
--- /dev/null
+++ b/examples/plain_ruby/Gemfile
@@ -0,0 +1,6 @@
+source "https://rubygems.org"
+
+gem "graphiti", path: "../.."
+gem "activerecord"
+gem "sqlite3"
+gem "kaminari"
diff --git a/examples/plain_ruby/README.md b/examples/plain_ruby/README.md
new file mode 100644
index 00000000..fee25e02
--- /dev/null
+++ b/examples/plain_ruby/README.md
@@ -0,0 +1,10 @@
+# Graphiti in a plain .rb file
+
+Graphiti with nothing but Ruby: an in-memory sqlite database, three Resources, and one script that queries with filtering, sorting, pagination and nested sideloads, then renders JSON:API, plain JSON and XML.
+
+```bash
+bundle install
+bundle exec ruby index.rb
+```
+
+The Gemfile points at the gem source two directories up, so this always runs against the checkout you're in.
diff --git a/examples/plain_ruby/index.rb b/examples/plain_ruby/index.rb
new file mode 100644
index 00000000..8136bf6a
--- /dev/null
+++ b/examples/plain_ruby/index.rb
@@ -0,0 +1,50 @@
+require "pp"
+require "active_record"
+require "kaminari"
+require "graphiti"
+require "./seeds"
+
+class ApplicationResource < Graphiti::Resource
+ self.abstract_class = true
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.autolink = false
+end
+
+class EmployeeResource < ApplicationResource
+ attribute :first_name, :string
+ attribute :last_name, :string
+ attribute :age, :integer
+
+ has_many :positions
+end
+
+class PositionResource < ApplicationResource
+ attribute :employee_id, :integer, only: [:filterable]
+ attribute :department_id, :integer, only: [:filterable]
+ attribute :title, :string
+
+ belongs_to :department
+end
+
+class DepartmentResource < ApplicationResource
+ attribute :name, :string
+end
+
+Graphiti.setup!
+
+employees = EmployeeResource.all({
+ sort: "-id",
+ filter: {age: {gt: 30}},
+ page: {size: 10, number: 1},
+ include: "positions.department"
+})
+
+employees.each do |e|
+ puts "#{e.first_name} | #{e.positions[0].title} | #{e.positions[0].department.name}"
+end
+
+pp JSON.parse(employees.to_jsonapi)
+puts "\n\n"
+pp JSON.parse(employees.to_json)
+puts "\n\n"
+puts employees.to_xml
diff --git a/examples/plain_ruby/seeds.rb b/examples/plain_ruby/seeds.rb
new file mode 100644
index 00000000..de9c9bc9
--- /dev/null
+++ b/examples/plain_ruby/seeds.rb
@@ -0,0 +1,49 @@
+ActiveRecord::Base.establish_connection adapter: "sqlite3",
+ database: ":memory:"
+
+ActiveRecord::Migration.verbose = false
+ActiveRecord::Schema.define(version: 1) do
+ create_table :employees do |t|
+ t.string :first_name
+ t.string :last_name
+ t.integer :age
+ end
+
+ create_table :positions do |t|
+ t.integer :employee_id
+ t.integer :department_id
+ t.string :title
+ end
+
+ create_table :departments do |t|
+ t.string :name
+ end
+end
+
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+end
+
+class Employee < ApplicationRecord
+ has_many :positions
+end
+
+class Position < ApplicationRecord
+ belongs_to :employee
+ belongs_to :department
+end
+
+class Department < ApplicationRecord
+ has_many :positions
+end
+
+e = Employee.create!(first_name: "Walter", last_name: "White", age: 50)
+d = Department.create!(name: "Product")
+Position.create!(title: "Cook", department: d, employee: e)
+
+e = Employee.create!(first_name: "Jesse", last_name: "Pinkman", age: 23)
+Position.create!(title: "Junior Cook", department: d, employee: e)
+
+d = Department.create!(name: "Legal")
+e = Employee.create!(first_name: "Saul", last_name: "Goodman", age: 47)
+Position.create!(title: "Lawyer", department: d, employee: e)
diff --git a/examples/rails/README.md b/examples/rails/README.md
new file mode 100644
index 00000000..1316bfa5
--- /dev/null
+++ b/examples/rails/README.md
@@ -0,0 +1,9 @@
+# Graphiti on Rails
+
+The full Rails example lives in its own repo: [graphiti-api/employee_directory](https://github.com/graphiti-api/employee_directory) — a complete API with Resources, relationships, generated specs, and the [Vandal](https://graphiti.dev/reference/vandal) schema explorer.
+
+It's a standalone repo because it's a real, runnable application with its own database and test suite. To start a new app of your own instead, use the [application template](https://graphiti.dev/getting-started/installation):
+
+```bash
+rails new blog --api -m https://raw.githubusercontent.com/graphiti-api/graphiti/main/templates/rails/all.rb
+```
diff --git a/examples/sinatra/Gemfile b/examples/sinatra/Gemfile
new file mode 100644
index 00000000..c6e9292a
--- /dev/null
+++ b/examples/sinatra/Gemfile
@@ -0,0 +1,10 @@
+source "https://rubygems.org"
+
+gem "graphiti", path: "../.."
+gem "sinatra"
+gem "rackup"
+gem "puma"
+gem "activerecord"
+gem "sqlite3"
+gem "kaminari"
+gem "rack-test"
diff --git a/examples/sinatra/README.md b/examples/sinatra/README.md
new file mode 100644
index 00000000..528f3df2
--- /dev/null
+++ b/examples/sinatra/README.md
@@ -0,0 +1,22 @@
+# Graphiti on Sinatra
+
+Graphiti serving HTTP without Rails: the same Resources as the plain-ruby example, exposed over JSON:API endpoints by a Sinatra app, with `rescue_registry` rendering not-found errors as JSON:API.
+
+```bash
+bundle install
+bundle exec rackup
+```
+
+Then visit [http://localhost:9292](http://localhost:9292) for a small explorer page that fetches each endpoint live, or curl directly:
+
+```bash
+curl -g 'localhost:9292/api/v1/employees?filter[age][gt]=30&include=positions.department'
+```
+
+Run the smoke test (what CI runs):
+
+```bash
+bundle exec ruby smoke.rb
+```
+
+The Gemfile points at the gem source two directories up, so this always runs against the checkout you're in.
diff --git a/examples/sinatra/app.rb b/examples/sinatra/app.rb
new file mode 100644
index 00000000..46d137c5
--- /dev/null
+++ b/examples/sinatra/app.rb
@@ -0,0 +1,61 @@
+require "sinatra/base"
+require "active_record"
+require "kaminari"
+require "graphiti"
+require "rescue_registry"
+require_relative "seeds"
+
+class ApplicationResource < Graphiti::Resource
+ self.abstract_class = true
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.endpoint_namespace = "/api/v1"
+ self.autolink = false
+end
+
+class EmployeeResource < ApplicationResource
+ attribute :first_name, :string
+ attribute :last_name, :string
+ attribute :age, :integer
+
+ has_many :positions
+end
+
+class PositionResource < ApplicationResource
+ attribute :employee_id, :integer, only: [:filterable]
+ attribute :department_id, :integer, only: [:filterable]
+ attribute :title, :string
+
+ belongs_to :department
+end
+
+class DepartmentResource < ApplicationResource
+ attribute :name, :string
+end
+
+Graphiti.setup!
+
+class EmployeeDirectory < Sinatra::Base
+ set :show_exceptions, :after_handler
+
+ before "/api/*" do
+ content_type "application/vnd.api+json"
+ end
+
+ get "/" do
+ send_file File.join(__dir__, "public", "index.html")
+ end
+
+ get "/api/v1/employees" do
+ EmployeeResource.all(params).to_jsonapi
+ end
+
+ get "/api/v1/employees/:id" do
+ EmployeeResource.find(params).to_jsonapi
+ end
+
+ error Graphiti::Errors::RecordNotFound do
+ handler = RescueRegistry::ExceptionHandler.new(env["sinatra.error"], status: 404)
+ status 404
+ handler.build_payload.to_json
+ end
+end
diff --git a/examples/sinatra/config.ru b/examples/sinatra/config.ru
new file mode 100644
index 00000000..ee4e813b
--- /dev/null
+++ b/examples/sinatra/config.ru
@@ -0,0 +1,2 @@
+require_relative "app"
+run EmployeeDirectory
diff --git a/examples/sinatra/public/index.html b/examples/sinatra/public/index.html
new file mode 100644
index 00000000..92ab8c4e
--- /dev/null
+++ b/examples/sinatra/public/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+Graphiti on Sinatra
+
+
+
+
Graphiti on Sinatra
+
Graphiti serving JSON:API without Rails. Click an endpoint to fetch it live — filtering, sorting, pagination and sideloads all come from the Resource definitions in app.rb.
+
+
+
+
+
+
+
+
+
Response appears here.
+
+
+
+
diff --git a/examples/sinatra/seeds.rb b/examples/sinatra/seeds.rb
new file mode 100644
index 00000000..466b9425
--- /dev/null
+++ b/examples/sinatra/seeds.rb
@@ -0,0 +1,52 @@
+# A file-backed database, rebuilt on every boot. An in-memory one won't work
+# here: sqlite scopes :memory: to a single connection, and puma's request
+# threads each get their own from the pool.
+ActiveRecord::Base.establish_connection adapter: "sqlite3",
+ database: File.join(__dir__, "employee_directory.sqlite3")
+
+ActiveRecord::Migration.verbose = false
+ActiveRecord::Schema.define(version: 1) do
+ create_table :employees, force: true do |t|
+ t.string :first_name
+ t.string :last_name
+ t.integer :age
+ end
+
+ create_table :positions, force: true do |t|
+ t.integer :employee_id
+ t.integer :department_id
+ t.string :title
+ end
+
+ create_table :departments, force: true do |t|
+ t.string :name
+ end
+end
+
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+end
+
+class Employee < ApplicationRecord
+ has_many :positions
+end
+
+class Position < ApplicationRecord
+ belongs_to :employee
+ belongs_to :department
+end
+
+class Department < ApplicationRecord
+ has_many :positions
+end
+
+e = Employee.create!(first_name: "Walter", last_name: "White", age: 50)
+d = Department.create!(name: "Product")
+Position.create!(title: "Cook", department: d, employee: e)
+
+e = Employee.create!(first_name: "Jesse", last_name: "Pinkman", age: 23)
+Position.create!(title: "Junior Cook", department: d, employee: e)
+
+d = Department.create!(name: "Legal")
+e = Employee.create!(first_name: "Saul", last_name: "Goodman", age: 47)
+Position.create!(title: "Lawyer", department: d, employee: e)
diff --git a/examples/sinatra/smoke.rb b/examples/sinatra/smoke.rb
new file mode 100644
index 00000000..1fbac5d5
--- /dev/null
+++ b/examples/sinatra/smoke.rb
@@ -0,0 +1,42 @@
+require "rack/test"
+require "json"
+require_relative "app"
+
+class Smoke
+ include Rack::Test::Methods
+
+ def app
+ EmployeeDirectory
+ end
+
+ def assert(condition, message)
+ raise message unless condition
+ end
+
+ def run
+ get "http://localhost/"
+ assert last_response.status == 200, "expected 200 from the index page"
+ assert last_response.body.include?("Graphiti on Sinatra"), "expected the explorer page"
+ assert last_response.content_type.include?("text/html"), "browsers won't render the page as #{last_response.content_type}"
+
+ get "http://localhost/api/v1/employees", filter: {age: {gt: 30}}, sort: "-id", include: "positions.department"
+ assert last_response.status == 200, "expected 200, got #{last_response.status}"
+ payload = JSON.parse(last_response.body)
+ names = payload["data"].map { |d| d["attributes"]["first_name"] }
+ assert names == ["Saul", "Walter"], "expected Saul and Walter, got #{names}"
+ included = payload["included"].map { |i| i["type"] }.sort.uniq
+ assert included == ["departments", "positions"], "expected sideloaded positions and departments"
+
+ get "http://localhost/api/v1/employees/1"
+ assert last_response.status == 200, "expected 200, got #{last_response.status}"
+ assert last_response.content_type == "application/vnd.api+json", "expected the JSON:API content type, got #{last_response.content_type}"
+
+ get "http://localhost/api/v1/employees/999"
+ assert last_response.status == 404, "expected 404, got #{last_response.status}"
+ assert JSON.parse(last_response.body).key?("errors"), "expected a JSON:API errors payload"
+
+ puts "Sinatra example OK"
+ end
+end
+
+Smoke.new.run
diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile
deleted file mode 100644
index 38872d68..00000000
--- a/gemfiles/rails_5_2.gemfile
+++ /dev/null
@@ -1,18 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 5.2.0"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_5_2_graphiti_rails.gemfile b/gemfiles/rails_5_2_graphiti_rails.gemfile
deleted file mode 100644
index 0f483569..00000000
--- a/gemfiles/rails_5_2_graphiti_rails.gemfile
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 5.2.0"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_6.gemfile b/gemfiles/rails_6.gemfile
deleted file mode 100644
index 8d8f1fb3..00000000
--- a/gemfiles/rails_6.gemfile
+++ /dev/null
@@ -1,18 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 6.0"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_6_graphiti_rails.gemfile b/gemfiles/rails_6_graphiti_rails.gemfile
deleted file mode 100644
index ea204ff2..00000000
--- a/gemfiles/rails_6_graphiti_rails.gemfile
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 6.0"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_7.gemfile b/gemfiles/rails_7.gemfile
deleted file mode 100644
index 054b43bb..00000000
--- a/gemfiles/rails_7.gemfile
+++ /dev/null
@@ -1,18 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 7.0"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile
index ef55184f..9d72cd39 100644
--- a/gemfiles/rails_7_1.gemfile
+++ b/gemfiles/rails_7_1.gemfile
@@ -2,12 +2,13 @@
source "https://rubygems.org"
-gem "rails", "~> 7.1"
+gem "rails", "~> 7.1.0"
gem "rspec-rails"
-gem "sqlite3", "~> 2.1"
-gem "database_cleaner"
+gem "responders"
+gem "sqlite3", "~> 1.4"
group :test do
+ gem "database_cleaner"
gem "pry"
gem "pry-byebug", platform: [:mri]
gem "appraisal"
diff --git a/gemfiles/rails_7_1_graphiti_rails.gemfile b/gemfiles/rails_7_1_graphiti_rails.gemfile
deleted file mode 100644
index 97c0a5e5..00000000
--- a/gemfiles/rails_7_1_graphiti_rails.gemfile
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 7.1"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_8_0_graphiti_rails.gemfile b/gemfiles/rails_7_2.gemfile
similarity index 76%
rename from gemfiles/rails_8_0_graphiti_rails.gemfile
rename to gemfiles/rails_7_2.gemfile
index 46a3c4ce..4e288bb6 100644
--- a/gemfiles/rails_8_0_graphiti_rails.gemfile
+++ b/gemfiles/rails_7_2.gemfile
@@ -2,13 +2,13 @@
source "https://rubygems.org"
-gem "rails", "~> 8.0"
+gem "rails", "~> 7.2.0"
gem "rspec-rails"
+gem "responders"
gem "sqlite3", "~> 2.1"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
group :test do
+ gem "database_cleaner"
gem "pry"
gem "pry-byebug", platform: [:mri]
gem "appraisal"
diff --git a/gemfiles/rails_7_graphiti_rails.gemfile b/gemfiles/rails_7_graphiti_rails.gemfile
deleted file mode 100644
index 0aae1f4d..00000000
--- a/gemfiles/rails_7_graphiti_rails.gemfile
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-gem "rails", "~> 7.0"
-gem "rspec-rails"
-gem "sqlite3", "~> 1.4.0"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
-
-group :test do
- gem "pry"
- gem "pry-byebug", platform: [:mri]
- gem "appraisal"
- gem "guard"
- gem "guard-rspec"
-end
-
-gemspec path: "../"
diff --git a/gemfiles/rails_8_1_graphiti_rails.gemfile b/gemfiles/rails_8_0.gemfile
similarity index 76%
rename from gemfiles/rails_8_1_graphiti_rails.gemfile
rename to gemfiles/rails_8_0.gemfile
index a15c6c20..a6860e3d 100644
--- a/gemfiles/rails_8_1_graphiti_rails.gemfile
+++ b/gemfiles/rails_8_0.gemfile
@@ -2,13 +2,13 @@
source "https://rubygems.org"
-gem "rails", "~> 8.1"
+gem "rails", "~> 8.0.0"
gem "rspec-rails"
+gem "responders"
gem "sqlite3", "~> 2.1"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
group :test do
+ gem "database_cleaner"
gem "pry"
gem "pry-byebug", platform: [:mri]
gem "appraisal"
diff --git a/gemfiles/rails_7_2_graphiti_rails.gemfile b/gemfiles/rails_8_1.gemfile
similarity index 76%
rename from gemfiles/rails_7_2_graphiti_rails.gemfile
rename to gemfiles/rails_8_1.gemfile
index 4da00bb1..33206d1a 100644
--- a/gemfiles/rails_7_2_graphiti_rails.gemfile
+++ b/gemfiles/rails_8_1.gemfile
@@ -2,13 +2,13 @@
source "https://rubygems.org"
-gem "rails", "~> 7.2"
+gem "rails", "~> 8.1.0"
gem "rspec-rails"
+gem "responders"
gem "sqlite3", "~> 2.1"
-gem "database_cleaner"
-gem "graphiti-rails", "~> 0.4.0"
group :test do
+ gem "database_cleaner"
gem "pry"
gem "pry-byebug", platform: [:mri]
gem "appraisal"
diff --git a/graphiti.gemspec b/graphiti.gemspec
index 322e8682..b587feb3 100644
--- a/graphiti.gemspec
+++ b/graphiti.gemspec
@@ -12,26 +12,32 @@ Gem::Specification.new do |spec|
spec.homepage = "https://github.com/graphiti-api/graphiti"
spec.license = "MIT"
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|templates|website|docs|examples)/}) }
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
- spec.required_ruby_version = ">= 2.7"
+ spec.required_ruby_version = ">= 3.2"
spec.add_dependency "jsonapi-serializable", "~> 0.3.0"
spec.add_dependency "jsonapi-renderer", "~> 0.2", ">= 0.2.2"
spec.add_dependency "dry-types", ">= 0.15.0", "< 2.0"
- spec.add_dependency "graphiti_errors", "~> 1.1.0"
+ spec.add_dependency "rescue_registry", "~> 1.1"
spec.add_dependency "concurrent-ruby", ">= 1.2", "< 2.0"
- spec.add_dependency "activesupport", ">= 5.2"
- # Bundled (no longer default) as of Ruby 3.5; graphiti uses OpenStruct in lib/
+ spec.add_dependency "activesupport", ">= 7.1"
+ # Bundled (no longer default) as of Ruby 4.0; graphiti uses OpenStruct in lib/
spec.add_dependency "ostruct", ">= 0.5"
- spec.add_development_dependency "faraday", "~> 0.15"
- spec.add_development_dependency "kaminari", "~> 0.17"
+ spec.add_development_dependency "faraday", ">= 2.0", "< 3"
+ spec.add_development_dependency "kaminari", "~> 1.2"
spec.add_development_dependency "bundler"
spec.add_development_dependency "rake", ">= 10.0"
- spec.add_development_dependency "standard", "~> 1.4.0"
- spec.add_development_dependency "activemodel", ">= 5.2"
- spec.add_development_dependency "graphiti_spec_helpers", "1.0.beta.4"
+ spec.add_development_dependency "standard", "~> 1.56"
+ spec.add_development_dependency "activemodel", ">= 7.1"
+ # Only the specs need this, for ActiveSupport's Hash.from_xml when asserting
+ # on to_xml output. It used to arrive transitively via rubocop; rubocop 1.88
+ # dropped it, and it is a bundled (not default) gem since Ruby 3.0.
+ spec.add_development_dependency "rexml"
+ # Was reaching the suite transitively through graphiti_spec_helpers, which is
+ # now part of this gem.
+ spec.add_development_dependency "rspec", "~> 3.0"
end
diff --git a/deprecated_generators/graphiti/api_test_generator.rb b/lib/generators/graphiti/api_test_generator.rb
similarity index 89%
rename from deprecated_generators/graphiti/api_test_generator.rb
rename to lib/generators/graphiti/api_test_generator.rb
index 1d4a26ac..7aa7cbee 100644
--- a/deprecated_generators/graphiti/api_test_generator.rb
+++ b/lib/generators/graphiti/api_test_generator.rb
@@ -4,7 +4,7 @@ module Graphiti
class ApiTestGenerator < ::Rails::Generators::Base
include GeneratorMixin
- source_root File.expand_path("templates", __dir__)
+ source_root File.expand_path("../templates", __FILE__)
argument :resource, type: :string
class_option :actions,
@@ -13,6 +13,12 @@ class ApiTestGenerator < ::Rails::Generators::Base
aliases: ["--actions", "-a"],
desc: 'Array of controller actions, e.g. "index show destroy"'
+ class_option :rawid,
+ type: :boolean,
+ default: false,
+ aliases: ["--rawid", "-r"],
+ desc: "Generate tests using rawid"
+
desc "Generates rspec request specs at spec/api"
def generate
generate_api_specs
diff --git a/lib/generators/graphiti/generator_mixin.rb b/lib/generators/graphiti/generator_mixin.rb
new file mode 100644
index 00000000..0bca49eb
--- /dev/null
+++ b/lib/generators/graphiti/generator_mixin.rb
@@ -0,0 +1,84 @@
+module Graphiti
+ module GeneratorMixin
+ def prompt(header: nil, description: nil, default: nil)
+ say(set_color("\n#{header}", :magenta, :bold)) if header
+ say("\n#{description}") if description
+ answer = ask(set_color("\n(default: #{default}):", :magenta, :bold))
+ answer = default if answer.blank? && default != "nil"
+ say(set_color("\nGot it!\n", :white, :bold))
+ answer
+ end
+
+ def api_namespace
+ @api_namespace ||= begin
+ ns = graphiti_config["namespace"]
+
+ if ns.blank?
+ ns = prompt \
+ header: "What is your API namespace?",
+ description: "This will be used as a route prefix, e.g. if you want the route '/books_api/v1/authors' your namespace would be '/books_api/v1'",
+ default: "/api/v1"
+ update_config!("namespace" => ns)
+ end
+
+ ns
+ end
+ end
+
+ def actions
+ @options["actions"] || %w[index show create update destroy]
+ end
+
+ def actions?(*methods)
+ methods.any? { |m| actions.include?(m) }
+ end
+
+ 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}"]
+ end
+ hint_column = assignments.values.map(&:length).max + 2
+
+ Graphiti::Resource::SETTING_GROUPS.transform_values do |settings|
+ settings.map do |name, setting|
+ hint = resource_setting_hint(setting)
+ hint ? "#{assignments[name].ljust(hint_column)}# #{hint}" : assignments[name]
+ end
+ end
+ end
+
+ def resource_setting_hint(setting)
+ hint = setting[:note] || setting[:values]&.map(&:inspect)
+ &.to_sentence(two_words_connector: " or ", last_word_connector: ", or ")
+
+ return hint unless setting[:deprecated]
+
+ ["deprecated, removed in 3.0", hint].compact.join(". ")
+ end
+
+ def graphiti_config
+ File.exist?(".graphiticfg.yml") ? YAML.load_file(".graphiticfg.yml") : {}
+ end
+
+ def update_config!(attrs)
+ config = graphiti_config.merge(attrs)
+ File.write(".graphiticfg.yml", config.to_yaml)
+ end
+
+ def id_or_rawid
+ @options["rawid"] ? "rawid" : "id"
+ end
+
+ def sort_raw_ids
+ return unless @options["rawid"]
+ ".sort"
+ end
+
+ def sort_raw_ids_descending
+ return unless @options["rawid"]
+ ".sort.reverse"
+ end
+ end
+end
diff --git a/lib/generators/graphiti/install_generator.rb b/lib/generators/graphiti/install_generator.rb
new file mode 100644
index 00000000..0ef65fb6
--- /dev/null
+++ b/lib/generators/graphiti/install_generator.rb
@@ -0,0 +1,75 @@
+require_relative "generator_mixin"
+require_relative "locale_generator"
+
+module Graphiti
+ class InstallGenerator < ::Rails::Generators::Base
+ include GeneratorMixin
+
+ source_root File.expand_path("templates", __dir__)
+
+ class_option :"omit-comments",
+ type: :boolean,
+ default: false,
+ aliases: ["-c"],
+ desc: "Generate without documentation comments"
+
+ desc "This generator boostraps graphiti"
+ def install
+ to = File.join("app/resources", "application_resource.rb")
+ template("application_resource.rb.erb", to)
+
+ invoke(Graphiti::LocaleGenerator, [], options)
+
+ inject_into_file "app/controllers/application_controller.rb", after: "class ApplicationController < ActionController::API\n" do
+ app_controller_code
+ end
+
+ inject_into_file "app/controllers/application_controller.rb", after: "class ApplicationController < ActionController::Base\n" do
+ app_controller_code
+ end
+
+ # Thor aborts the whole generator when this file is missing.
+ if defined?(RSpec) && File.exist?(File.join(destination_root, "spec/rails_helper.rb"))
+ inject_into_file "spec/rails_helper.rb", after: /RSpec.configure.+^end$/m do
+ <<~RUBY
+
+ require "graphiti/spec_helpers/rspec"
+
+ RSpec.configure do |config|
+ config.include Graphiti::SpecHelpers::RSpec
+ end
+
+ Graphiti::SpecHelpers::RSpec.schema!
+ RUBY
+ end
+ end
+
+ insert_into_file "config/routes.rb", after: "Rails.application.routes.draw do\n" do
+ namespace_scope
+ end
+ end
+
+ private
+
+ def omit_comments?
+ @options["omit-comments"]
+ end
+
+ # The resource generator re-finds this scope by its format default.
+ def namespace_scope
+ lines = [" scope path: \"#{api_namespace}\", defaults: {format: :jsonapi} do\n"]
+ lines << " mount VandalUi::Engine, at: '/vandal'\n" if defined?(VandalUi)
+ lines << " # your routes go here\n"
+ lines << " end\n"
+ lines.join
+ end
+
+ def app_controller_code
+ str = +" include Graphiti::Rails::Controller\n"
+ if defined?(::Responders)
+ str << " include Graphiti::Rails::Responders\n"
+ end
+ str
+ end
+ end
+end
diff --git a/lib/generators/graphiti/locale_generator.rb b/lib/generators/graphiti/locale_generator.rb
new file mode 100644
index 00000000..932d4331
--- /dev/null
+++ b/lib/generators/graphiti/locale_generator.rb
@@ -0,0 +1,86 @@
+module Graphiti
+ class LocaleGenerator < ::Rails::Generators::Base
+ source_root File.expand_path("templates", __dir__)
+
+ class_option :"omit-comments",
+ type: :boolean,
+ default: false,
+ aliases: ["-c"],
+ desc: "Generate without documentation comments"
+
+ # Every code Graphiti renders a title for, and the title it renders today.
+ ERROR_TITLES = {
+ bad_request: "Request Error",
+ not_found: "Not Found",
+ conflict: "Conflict Error",
+ unprocessable_entity: "Validation Error",
+ internal_server_error: "Internal Server Error"
+ }.freeze
+
+ # The two codes no registration produces.
+ ERROR_SOURCES = {
+ unprocessable_entity: ["a write that failed model validations"],
+ internal_server_error: ["any exception Graphiti did not register"]
+ }.freeze
+
+ # Written into the app's locale file, not defaulted in Graphiti.
+ ERROR_DETAILS = {
+ internal_server_error: "We've probably received an error report already, but please contact us if the issue persists."
+ }.freeze
+
+ COMMENT_WIDTH = 68
+
+ desc "Writes the error text Graphiti renders, ready to edit"
+ def locale
+ template("locale.yml.erb", File.join("config/locales", "graphiti.en.yml"))
+ end
+
+ private
+
+ def omit_comments?
+ @options["omit-comments"]
+ end
+
+ def error_titles
+ ERROR_TITLES
+ end
+
+ def error_detail(code)
+ ERROR_DETAILS[code]
+ end
+
+ # What raises this code, so the file says where its text is used.
+ def error_source_lines(code)
+ ERROR_SOURCES[code] || wrap(exception_names(code))
+ end
+
+ def exception_names(code)
+ Graphiti::Rails::CLIENT_ERROR_STATUSES
+ .select { |_exception, status| status == code }
+ .keys.map { |exception| exception.delete_prefix("Graphiti::Errors::") }
+ end
+
+ def wrap(names)
+ items = names.map.with_index do |name, index|
+ (index == names.length - 1) ? name : "#{name},"
+ end
+
+ items.each_with_object([]) do |item, lines|
+ joined = "#{lines.last} #{item}"
+ if lines.empty? || joined.length > COMMENT_WIDTH
+ lines << item
+ else
+ lines[-1] = joined
+ end
+ end
+ end
+
+ def default_messages
+ Graphiti::Util::SimpleErrors::DEFAULT_MESSAGES
+ end
+
+ def default_format
+ Graphiti::Util::SimpleErrors::DEFAULT_FORMAT
+ end
+ end
+end
diff --git a/deprecated_generators/graphiti/resource_generator.rb b/lib/generators/graphiti/resource_generator.rb
similarity index 63%
rename from deprecated_generators/graphiti/resource_generator.rb
rename to lib/generators/graphiti/resource_generator.rb
index 5129d53b..5566c3bd 100644
--- a/deprecated_generators/graphiti/resource_generator.rb
+++ b/lib/generators/graphiti/resource_generator.rb
@@ -8,24 +8,36 @@ class ResourceGenerator < ::Rails::Generators::NamedBase
argument :attributes, type: :array, default: [], banner: "field[:type][:index] field[:type][:index]"
- class_option :'omit-comments',
+ class_option :"omit-comments",
type: :boolean,
default: false,
aliases: ["--omit-comments", "-c"],
desc: "Generate without documentation comments"
+ class_option :rawid,
+ type: :boolean,
+ default: false,
+ aliases: ["--rawid", "-r"],
+ desc: "Generate tests using rawid"
+
class_option :actions,
type: :array,
default: nil,
aliases: ["--actions", "-a"],
desc: 'Array of controller actions to support, e.g. "index show destroy"'
- class_option :'attributes-from',
+ class_option :"attributes-from",
banner: "Model",
type: :string,
aliases: ["--model", "-m"],
desc: "Specify to use attributes from a particular model"
+ # No short alias: -c is already --omit-comments.
+ class_option :controller,
+ banner: "Name",
+ type: :string,
+ desc: "Generate the controller under this name instead of the resource's, e.g. Api::V1::Posts"
+
desc "This generator creates a resource file at app/resources, as well as corresponding controller/specs/route/etc"
def generate_all
generate_model
@@ -94,7 +106,10 @@ def default_attributes
end
if attributes_class.table_exists?
attributes_class.columns.map do |c|
- OpenStruct.new({name: c.name.to_sym, type: c.type})
+ OpenStruct.new({
+ name: c.name.to_sym,
+ type: convert_column_type_to_graphiti_resource_type(c.type)
+ })
end
else
raise "#{attributes_class} table must exist. Please run migrations."
@@ -112,12 +127,30 @@ def resource_attributes
end
end
+ # Generated controllers only use respond_with when it will actually work:
+ # the responders gem being installed is not enough, the controller chain
+ # must include Graphiti::Rails::Responders.
def responders?
- defined?(Responders)
+ "ApplicationController".safe_constantize&.include?(Graphiti::Rails::Responders)
+ end
+
+ # Api::V1::Posts, Api::V1::PostsController and api/v1/posts all name the
+ # same controller. Normalizing here means the class name, the file path and
+ # the route all come from one place.
+ def controller_class_name
+ @controller_class_name ||= begin
+ given = options[:controller]
+ base = given ? given.sub(/Controller\z/, "").camelize : model_klass.name
+ "#{base.pluralize}Controller"
+ end
+ end
+
+ def controller_path
+ controller_class_name.sub(/Controller\z/, "").underscore
end
def generate_controller
- to = File.join("app/controllers", class_path, "#{file_name.pluralize}_controller.rb")
+ to = File.join("app/controllers", "#{controller_path}_controller.rb")
template("controller.rb.erb", to)
end
@@ -132,13 +165,14 @@ def application_resource_defined?
end
def generate_route
- # Rails 5.2 adds `plural_route_name`, fallback to `plural_table_name`
- plural_name = try(:plural_route_name) || plural_table_name
-
- code = "resources :#{plural_name}"
+ code = "resources :#{file_name.pluralize}"
+ # Rails would otherwise infer the controller from the route name, which
+ # is only right when the controller was named after the resource.
+ code << %(, controller: "#{controller_path}") if options[:controller]
code << %(, only: [#{actions.map { |a| ":#{a}" }.join(", ")}]) if actions.length < 5
code << "\n"
- inject_into_file "config/routes.rb", after: /ApplicationResource.*$\n/ do
+ # Matches the install generator's scope, including its pre-2.0 form.
+ inject_into_file "config/routes.rb", after: /format: :jsonapi.*$\n/ do
indent(code, 4)
end
end
@@ -146,12 +180,14 @@ def generate_route
def generate_resource_specs
opts = {}
opts[:actions] = @options[:actions] if @options[:actions]
+ opts[:rawid] = @options[:rawid] if @options[:rawid]
invoke "graphiti:resource_test", [resource_klass], opts
end
def generate_api_specs
opts = {}
opts[:actions] = @options[:actions] if @options[:actions]
+ opts[:rawid] = @options[:rawid] if @options[:rawid]
invoke "graphiti:api_test", [resource_klass], opts
end
@@ -176,5 +212,34 @@ def resource_klass
def type
model_klass.name.underscore.pluralize
end
+
+ def convert_column_type_to_graphiti_resource_type(type)
+ # TODO: Support database specific types.
+ case type
+ when :string, :text
+ :string
+ when :float, :decimal
+ :integer
+ when :integer, :bigint
+ :integer
+ when :datetime, :time
+ :datetime
+ when :date
+ :date
+ when :boolean
+ :boolean
+ when :numeric
+ # TODO: Return type.
+ type
+ when :primary_key
+ # TODO: Return type.
+ type
+ when :binary
+ # TODO: Return type.
+ type
+ else
+ type
+ end
+ end
end
end
diff --git a/deprecated_generators/graphiti/resource_test_generator.rb b/lib/generators/graphiti/resource_test_generator.rb
similarity index 100%
rename from deprecated_generators/graphiti/resource_test_generator.rb
rename to lib/generators/graphiti/resource_test_generator.rb
diff --git a/lib/generators/graphiti/templates/application_resource.rb.erb b/lib/generators/graphiti/templates/application_resource.rb.erb
new file mode 100644
index 00000000..bfd103bb
--- /dev/null
+++ b/lib/generators/graphiti/templates/application_resource.rb.erb
@@ -0,0 +1,24 @@
+<%- unless omit_comments? -%>
+# All Resources should inherit from ApplicationResource.
+<%- end -%>
+class ApplicationResource < Graphiti::Resource
+ self.abstract_class = true
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ <%- unless omit_comments? -%>
+ # Or follow config/environments:
+ # self.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 %>'
+ <%- unless omit_comments? -%>
+
+ # Defaults every Resource inherits. See https://graphiti.dev/concepts/resources
+ <%- resource_setting_groups.each_pair do |group, lines| -%>
+
+ # <%= group.to_s.humanize %>
+ <%- lines.each do |line| -%>
+ # <%= line %>
+ <%- end -%>
+ <%- end -%>
+ <%- end -%>
+end
diff --git a/deprecated_generators/graphiti/templates/controller.rb.erb b/lib/generators/graphiti/templates/controller.rb.erb
similarity index 55%
rename from deprecated_generators/graphiti/templates/controller.rb.erb
rename to lib/generators/graphiti/templates/controller.rb.erb
index 851e8917..b240aeca 100644
--- a/deprecated_generators/graphiti/templates/controller.rb.erb
+++ b/lib/generators/graphiti/templates/controller.rb.erb
@@ -1,25 +1,43 @@
<% module_namespacing do -%>
-class <%= model_klass.name.pluralize %>Controller < ApplicationController
+class <%= controller_class_name %> < ApplicationController
<%- if actions?('index') -%>
+ <%- if responders? -%>
def index
<%= file_name.pluralize %> = <%= resource_klass %>.all(params)
- <%- if responders? -%>
respond_with(<%= file_name.pluralize %>)
- <%- else -%>
- render jsonapi: <%= file_name.pluralize %>
- <%- end -%>
end
+ <%- else -%>
+ <%- unless omit_comments? -%>
+ # With Graphiti::Rails::Responders included, these blocks collapse to
+ # respond_with(<%= file_name.pluralize %>). See https://graphiti.dev/getting-started/installation#responders
+ <%- end -%>
+ def index
+ <%= file_name.pluralize %> = <%= resource_klass %>.all(params)
+
+ respond_to do |format|
+ format.jsonapi { render jsonapi: <%= file_name.pluralize %> }
+ format.json { render json: <%= file_name.pluralize %> }
+ end
+ end
+ <%- end -%>
<%- end -%>
<%- if actions?('show') -%>
+ <%- if responders? -%>
def show
<%= file_name %> = <%= resource_klass %>.find(params)
- <%- if responders? -%>
respond_with(<%= file_name %>)
- <%- else -%>
- render jsonapi: <%= file_name %>
- <%- end -%>
end
+ <%- else -%>
+ def show
+ <%= file_name %> = <%= resource_klass %>.find(params)
+
+ respond_to do |format|
+ format.jsonapi { render jsonapi: <%= file_name %> }
+ format.json { render json: <%= file_name %> }
+ end
+ end
+ <%- end -%>
<%- end -%>
<%- if actions?('create') -%>
@@ -38,7 +56,7 @@ class <%= model_klass.name.pluralize %>Controller < ApplicationController
def update
<%= file_name %> = <%= resource_klass %>.find(params)
- if <%= file_name %>.update_attributes
+ if <%= file_name %>.update
render jsonapi: <%= file_name %>
else
render jsonapi_errors: <%= file_name %>
diff --git a/deprecated_generators/graphiti/templates/create_request_spec.rb.erb b/lib/generators/graphiti/templates/create_request_spec.rb.erb
similarity index 100%
rename from deprecated_generators/graphiti/templates/create_request_spec.rb.erb
rename to lib/generators/graphiti/templates/create_request_spec.rb.erb
diff --git a/deprecated_generators/graphiti/templates/destroy_request_spec.rb.erb b/lib/generators/graphiti/templates/destroy_request_spec.rb.erb
similarity index 100%
rename from deprecated_generators/graphiti/templates/destroy_request_spec.rb.erb
rename to lib/generators/graphiti/templates/destroy_request_spec.rb.erb
diff --git a/deprecated_generators/graphiti/templates/index_request_spec.rb.erb b/lib/generators/graphiti/templates/index_request_spec.rb.erb
similarity index 73%
rename from deprecated_generators/graphiti/templates/index_request_spec.rb.erb
rename to lib/generators/graphiti/templates/index_request_spec.rb.erb
index df863aea..4d5ea0ab 100644
--- a/deprecated_generators/graphiti/templates/index_request_spec.rb.erb
+++ b/lib/generators/graphiti/templates/index_request_spec.rb.erb
@@ -15,8 +15,8 @@ RSpec.describe "<%= type %>#index", type: :request do
expect(<%= resource_class %>).to receive(:all).and_call_original
make_request
expect(response.status).to eq(200), response.body
- expect(d.map(&:jsonapi_type).uniq).to match_array(['<%= type %>'])
- expect(d.map(&:id)).to match_array([<%= var %>1.id, <%= var %>2.id])
+ expect(jsonapi_data.map(&:jsonapi_type).uniq).to match_array(['<%= type %>'])
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to match_array([<%= var %>1.id, <%= var %>2.id])
end
end
end
diff --git a/lib/generators/graphiti/templates/locale.yml.erb b/lib/generators/graphiti/templates/locale.yml.erb
new file mode 100644
index 00000000..1c6b04c7
--- /dev/null
+++ b/lib/generators/graphiti/templates/locale.yml.erb
@@ -0,0 +1,30 @@
+<%- unless omit_comments? -%>
+# For another language, add a block below or a graphiti..yml file.
+<%- end -%>
+en:
+ graphiti:
+ errors:
+<%- unless omit_comments? -%>
+ # Keyed by error code. Edit any value to reword it. Comments name what
+ # raises each code, relative to Graphiti::Errors.
+<%- end -%>
+<%- error_titles.each_pair do |code, title| -%>
+ <%= code %>:
+ <%- unless omit_comments? -%>
+ <%- error_source_lines(code).each do |line| -%>
+ # <%= line %>
+ <%- end -%>
+ <%- end -%>
+ title: <%= title.inspect %>
+ <%- if error_detail(code) -%>
+ detail: <%= error_detail(code).inspect %>
+ <%- end -%>
+<%- end -%>
+<%- unless omit_comments? -%>
+ # format joins the two below: "data.attributes.title is an unknown attribute".
+<%- end -%>
+ format: <%= default_format.inspect %>
+ messages:
+<%- default_messages.each do |code, message| -%>
+ <%= code %>: <%= message.inspect %>
+<%- end -%>
diff --git a/deprecated_generators/graphiti/templates/resource.rb.erb b/lib/generators/graphiti/templates/resource.rb.erb
similarity index 100%
rename from deprecated_generators/graphiti/templates/resource.rb.erb
rename to lib/generators/graphiti/templates/resource.rb.erb
diff --git a/deprecated_generators/graphiti/templates/resource_reads_spec.rb.erb b/lib/generators/graphiti/templates/resource_reads_spec.rb.erb
similarity index 77%
rename from deprecated_generators/graphiti/templates/resource_reads_spec.rb.erb
rename to lib/generators/graphiti/templates/resource_reads_spec.rb.erb
index 7845c432..8ce5f12a 100644
--- a/deprecated_generators/graphiti/templates/resource_reads_spec.rb.erb
+++ b/lib/generators/graphiti/templates/resource_reads_spec.rb.erb
@@ -7,11 +7,11 @@ RSpec.describe <%= resource_class %>, type: :resource do
it 'works' do
render
data = jsonapi_data[0]
- expect(data.id).to eq(<%= var %>.id)
+ expect(data.<%= id_or_rawid %>).to eq(<%= var %>.id)
expect(data.jsonapi_type).to eq('<%= type %>')
<%- attributes.each do |a| -%>
<%- if [:created_at, :updated_at].include?(a.name.to_sym) -%>
- expect(data.<%= a.name %>).to eq(datetime(<%= file_name %>.<%= a.name %>))
+ expect(data.<%= a.name %>).to eq(json_datetime(<%= file_name %>.<%= a.name %>))
<%- else -%>
expect(data.<%= a.name %>).to eq(<%= file_name %>.<%= a.name %>)
<%- end -%>
@@ -31,7 +31,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
it 'works' do
render
- expect(d.map(&:id)).to eq([<%= var %>2.id])
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to eq([<%= var %>2.id])
end
end
end
@@ -48,10 +48,10 @@ RSpec.describe <%= resource_class %>, type: :resource do
it 'works' do
render
- expect(d.map(&:id)).to eq([
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to eq([
<%= var %>1.id,
<%= var %>2.id
- ])
+ ]<%= sort_raw_ids %>)
end
end
@@ -62,10 +62,10 @@ RSpec.describe <%= resource_class %>, type: :resource do
it 'works' do
render
- expect(d.map(&:id)).to eq([
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to eq([
<%= var %>2.id,
<%= var %>1.id
- ])
+ ]<%= sort_raw_ids_descending %>)
end
end
end
diff --git a/deprecated_generators/graphiti/templates/resource_writes_spec.rb.erb b/lib/generators/graphiti/templates/resource_writes_spec.rb.erb
similarity index 96%
rename from deprecated_generators/graphiti/templates/resource_writes_spec.rb.erb
rename to lib/generators/graphiti/templates/resource_writes_spec.rb.erb
index d8ea1b8f..57bda37f 100644
--- a/deprecated_generators/graphiti/templates/resource_writes_spec.rb.erb
+++ b/lib/generators/graphiti/templates/resource_writes_spec.rb.erb
@@ -45,7 +45,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
xit 'works (add some attributes and enable this spec)' do
expect {
- expect(instance.update_attributes).to eq(true)
+ expect(instance.update).to eq(true)
}.to change { <%= var %>.reload.updated_at }
# .and change { <%= var %>.foo }.to('bar') <- example
end
diff --git a/deprecated_generators/graphiti/templates/show_request_spec.rb.erb b/lib/generators/graphiti/templates/show_request_spec.rb.erb
similarity index 78%
rename from deprecated_generators/graphiti/templates/show_request_spec.rb.erb
rename to lib/generators/graphiti/templates/show_request_spec.rb.erb
index 0eb83768..cf0d5d24 100644
--- a/deprecated_generators/graphiti/templates/show_request_spec.rb.erb
+++ b/lib/generators/graphiti/templates/show_request_spec.rb.erb
@@ -14,8 +14,8 @@ RSpec.describe "<%= type %>#show", type: :request do
expect(<%= resource_class %>).to receive(:find).and_call_original
make_request
expect(response.status).to eq(200)
- expect(d.jsonapi_type).to eq('<%= type %>')
- expect(d.id).to eq(<%= var %>.id)
+ expect(jsonapi_data.jsonapi_type).to eq('<%= type %>')
+ expect(jsonapi_data.<%= id_or_rawid %>).to eq(<%= var %>.id)
end
end
end
diff --git a/deprecated_generators/graphiti/templates/update_request_spec.rb.erb b/lib/generators/graphiti/templates/update_request_spec.rb.erb
similarity index 100%
rename from deprecated_generators/graphiti/templates/update_request_spec.rb.erb
rename to lib/generators/graphiti/templates/update_request_spec.rb.erb
diff --git a/lib/graphiti-rails.rb b/lib/graphiti-rails.rb
new file mode 100644
index 00000000..ce5cbb52
--- /dev/null
+++ b/lib/graphiti-rails.rb
@@ -0,0 +1,11 @@
+# Deprecated entry point. The graphiti-rails gem was absorbed into graphiti in
+# 2.0, and its integration loads automatically whenever Rails is defined, so
+# this require is no longer needed. It is kept so an explicit
+# `require "graphiti-rails"` left over from 1.x does not raise. Remove in 3.0.
+require "graphiti"
+require "graphiti/rails"
+
+Graphiti::DEPRECATOR.warn(
+ 'require "graphiti-rails" is no longer needed. graphiti-rails is part of graphiti as of 2.0, ' \
+ "and the Rails integration is loaded for you when Rails is present."
+)
diff --git a/lib/graphiti.rb b/lib/graphiti.rb
index 6802f53b..09bcfd57 100644
--- a/lib/graphiti.rb
+++ b/lib/graphiti.rb
@@ -3,6 +3,12 @@
require "uri"
require "ostruct" unless defined?(::OpenStruct)
+# The umbrella require, not just the core_ext files below: Graphiti.broadcast
+# calls ActiveSupport::Notifications.instrument, which reaches for
+# ActiveSupport::IsolatedExecutionState. That constant is autoloaded here and
+# nowhere else, so without this a non-Rails app raises NameError on the first
+# render unless something else happened to load ActiveSupport for us.
+require "active_support"
require "active_support/version"
require "active_support/deprecation"
require "active_support/deprecator" if ::ActiveSupport.version >= Gem::Version.new("7.1")
@@ -14,27 +20,51 @@
require "active_support/time"
require "dry-types"
-require "graphiti_errors"
require "jsonapi/serializable"
+# These gems are merged into graphiti as of 2.0, but their 1.x/0.x releases
+# still resolve against graphiti 2.x. Leaving one installed is never harmless:
+# graphiti_spec_helpers and graphiti-rails ship files that collide with ours
+# (lib/graphiti_spec_helpers.rb, lib/graphiti/rails.rb), so which copy a require
+# picks up comes down to load path order, and graphiti_errors installs a second,
+# competing exception handler on any controller that includes it. Fail loudly
+# rather than let either happen quietly. Checked here because graphiti is loaded
+# either way, whichever copy wins.
+{
+ "graphiti_spec_helpers" => 'The "graphiti_spec_helpers/rspec" require and the GraphitiSpecHelpers namespace are unchanged.',
+ "graphiti-rails" => 'Graphiti::Rails and its config.graphiti options are unchanged. Drop the "graphiti-rails" require if you have one, and add `include Graphiti::Rails::Controller` to controllers serving Graphiti resources. graphiti-rails installed that on every controller automatically. See graphiti.dev/upgrading.',
+ "graphiti_errors" => "Exception handling now goes through rescue_registry. Remove `include GraphitiErrors` from your controllers — Graphiti registers its own handlers, and you can add yours with `register_exception`."
+}.each do |gem_name, guidance|
+ next unless Gem.loaded_specs.key?(gem_name)
+
+ raise <<~MSG
+ #{gem_name} is merged into graphiti as of 2.0 and is no longer published
+ separately. Remove it from your Gemfile.
+
+ #{guidance}
+ MSG
+end
+
module Graphiti
- DEPRECATOR = ActiveSupport::Deprecation.new("2.0", "Graphiti")
+ DEPRECATOR = ActiveSupport::Deprecation.new("3.0", "Graphiti")
# @api private
def self.context
- Thread.current[:context] ||= {}
+ Fiber[:context] ||= ContextHash.new
end
# @api private
def self.context=(val)
- Thread.current[:context] = val
+ Fiber[:context] = ContextHash.new.tap do |hash|
+ val.each { |key, value| hash[key] = value }
+ end
end
# @api private
- def self.with_context(obj, namespace = nil)
+ def self.with_context(obj, action = nil)
prior = context
- self.context = {object: obj, namespace: namespace}
+ self.context = {object: obj, action: action}
yield
ensure
self.context = prior
@@ -121,6 +151,11 @@ def self.setup!
resources.each do |r|
r.apply_sideloads_to_serializer
end
+ @setup = true
+ end
+
+ def self.setup?
+ !!@setup
end
def self.cache=(val)
@@ -136,10 +171,14 @@ def self.cache
require "graphiti/jsonapi_serializable_ext"
require "graphiti/configuration"
require "graphiti/context"
+require "graphiti/context_hash"
require "graphiti/errors"
require "graphiti/types"
+require "graphiti/audit"
+require "graphiti/audit/report"
require "graphiti/schema"
require "graphiti/schema_diff"
+require "graphiti/schema/check"
require "graphiti/adapters/abstract"
require "graphiti/resource/sideloading"
require "graphiti/resource/links"
@@ -203,6 +242,11 @@ def self.cache
require "graphiti/debugger"
require "graphiti/util/cache_debug"
require "graphiti/util/uri_decoder"
+require "graphiti/error_serializers/translated_title"
+require "graphiti/error_serializers/validation"
+require "graphiti/error_serializers/invalid_request"
+require "graphiti/error_serializers/conflict_request"
+require "graphiti/error_serializers/deprecated_constants"
if defined?(ActiveRecord)
require "graphiti/adapters/active_record"
@@ -210,14 +254,6 @@ def self.cache
if defined?(Rails)
require "graphiti/rails"
- require "graphiti/responders"
-
- # graphiti-rails has own Railtie
- begin
- require "graphiti-rails"
- rescue LoadError
- require "graphiti/railtie"
- end
end
require "graphiti/runner"
diff --git a/lib/graphiti/adapters/abstract.rb b/lib/graphiti/adapters/abstract.rb
index 8b3dfb96..165682c5 100644
--- a/lib/graphiti/adapters/abstract.rb
+++ b/lib/graphiti/adapters/abstract.rb
@@ -371,10 +371,11 @@ def associate(parent, child, association_name, association_type)
activerecord_adapter.associate \
parent, child, association_name, association_type
elsif [:has_many, :many_to_many].include?(association_type)
- if parent.send(:"#{association_name}").nil?
+ associated = parent.send(:"#{association_name}")
+ if associated.nil?
parent.send(:"#{association_name}=", [child])
- else
- parent.send(:"#{association_name}") << child
+ elsif associated.none? { |existing| existing.equal?(child) }
+ associated << child
end
else
parent.send(:"#{association_name}=", child)
diff --git a/lib/graphiti/adapters/active_record.rb b/lib/graphiti/adapters/active_record.rb
index ae48b401..92532733 100644
--- a/lib/graphiti/adapters/active_record.rb
+++ b/lib/graphiti/adapters/active_record.rb
@@ -246,7 +246,7 @@ def associate_all(parent, children, association_name, association_type)
children.each do |child|
if association_type == :many_to_many &&
- [:create, :update].include?(Graphiti.context[:namespace]) &&
+ [:create, :update].include?(Graphiti.context[:action]) &&
!parent.send(association_name).exists?(child.id) &&
child.errors.blank?
parent.send(association_name) << child
@@ -281,20 +281,6 @@ def disassociate(parent, child, association_name, association_type)
# Nothing to do in the else case, happened when we merged foreign key
end
- # (see Adapters::Abstract#create)
- def create(model_class, create_params)
- instance = model_class.new(create_params)
- instance.save
- instance
- end
-
- # (see Adapters::Abstract#update)
- def update(model_class, update_params)
- instance = model_class.find(update_params.only(:id))
- instance.update_attributes(update_params.except(:id))
- instance
- end
-
def save(model_instance)
model_instance.save
model_instance
diff --git a/lib/graphiti/audit.rb b/lib/graphiti/audit.rb
new file mode 100644
index 00000000..8f8dd378
--- /dev/null
+++ b/lib/graphiti/audit.rb
@@ -0,0 +1,230 @@
+module Graphiti
+ # Resources register themselves as they load, so eager load before auditing.
+ class Audit
+ Finding = Struct.new(:severity, :check, :message, :remedy) do
+ def error?
+ severity == :error
+ end
+ end
+
+ Row = Struct.new(
+ :resource,
+ :relationship,
+ :type,
+ :target,
+ :resource_ids_source,
+ :resource_ids_source_if_always,
+ :resource_ids_default,
+ :resource_ids_blocker,
+ :preloaded,
+ :options,
+ :findings
+ ) do
+ def would_start_loading?
+ return false if resource_ids_default == :never
+
+ resource_ids_source != :load && resource_ids_source_if_always == :load
+ end
+
+ def severity
+ return :error if findings.any?(&:error?)
+ findings.empty? ? :ok : :warning
+ end
+
+ def error?
+ severity == :error
+ end
+ end
+
+ def self.run(resources = nil)
+ new(resources).run
+ end
+
+ def self.findings(resources = nil)
+ run(resources).flat_map(&:findings)
+ end
+
+ def initialize(resources = nil)
+ @resources = resources || Graphiti.resources.reject(&:abstract_class?)
+ end
+
+ def run
+ @resources.sort_by { |resource| resource.name.to_s }.flat_map { |resource| rows_for(resource) }
+ end
+
+ private
+
+ def rows_for(resource_class)
+ model = inferred_model(resource_class)
+
+ resource_class.sideloads.map do |name, sideload|
+ row_for(resource_class, name, sideload, model)
+ end
+ end
+
+ def row_for(resource_class, name, sideload, model)
+ source = resource_ids_source(sideload)
+
+ Row.new(
+ resource: resource_class.name,
+ relationship: name,
+ type: sideload.type,
+ target: target_name(sideload),
+ resource_ids_source: source,
+ resource_ids_source_if_always: resource_ids_source_if_always(resource_class, sideload),
+ resource_ids_default: resource_class.belongs_to_resource_ids_by_default,
+ resource_ids_blocker: sideload.resource_ids_blocker,
+ preloaded: (source == :load) ? association_preloaded?(resource_class, sideload) : nil,
+ options: declaration_options(sideload),
+ findings: [
+ missing_association_method(sideload, model),
+ missing_guard_method(resource_class, sideload),
+ missing_sideload_filter(sideload)
+ ].compact
+ )
+ rescue => error
+ Row.new(
+ resource: resource_class.name,
+ relationship: name,
+ type: sideload.type,
+ resource_ids_source: :none,
+ options: {},
+ findings: [
+ Finding.new(
+ severity: :error,
+ check: :broken_relationship,
+ message: "#{error.class}: #{error.message.strip.lines.first.to_s.strip}"
+ )
+ ]
+ )
+ end
+
+ def declaration_options(sideload)
+ options = sideload.non_default_options
+ options[:base_scope] = "..." if sideload.customized_base_scope?
+ options[:scope] = "..." if sideload.class.scope_proc
+ options[:params] = "..." if sideload.class.params_proc
+ options
+ end
+
+ def resource_ids_source(sideload)
+ return :none unless sideload.render_resource_ids?
+
+ sideload.resource_ids_blocker.nil? ? :key : :load
+ end
+
+ def resource_ids_source_if_always(resource_class, sideload)
+ original = resource_class.belongs_to_resource_ids_by_default
+ resource_class.belongs_to_resource_ids_by_default = :always
+ resource_ids_source(sideload)
+ ensure
+ resource_class.belongs_to_resource_ids_by_default = original
+ end
+
+ def target_name(sideload)
+ if sideload.type == :polymorphic_belongs_to
+ children = sideload.children.values.filter_map { |child| short_name(child) }
+ return children.empty? ? nil : children.join(", ")
+ end
+
+ sideload.resource.class.name
+ rescue
+ nil
+ end
+
+ def short_name(sideload)
+ sideload.resource.class.name&.split("::")&.last
+ rescue
+ nil
+ end
+
+ def inferred_model(resource_class)
+ model = resource_class.model
+ model.is_a?(Class) ? model : nil
+ rescue Errors::ModelNotFound
+ nil
+ end
+
+ def missing_association_method(sideload, model)
+ return unless model
+ return if sideload.type == :polymorphic_belongs_to
+ return if model.method_defined?(sideload.association_name)
+ return if model.private_method_defined?(sideload.association_name)
+
+ Finding.new(
+ severity: :error,
+ check: :missing_association_method,
+ message: "#{model.name} has no ##{sideload.association_name} method",
+ remedy: "define it, point the relationship at the real association with `as:`, or remove the relationship"
+ )
+ end
+
+ def missing_guard_method(resource_class, sideload)
+ guard = sideload.readable_guard_name
+ return unless guard
+ return if defines?(resource_class, guard)
+ return if defines?(sideload.resource.class, guard)
+
+ Finding.new(
+ severity: :error,
+ check: :missing_guard_method,
+ message: "##{guard} is defined on neither #{resource_class.name} nor its related resource",
+ remedy: "define the guard on either resource"
+ )
+ end
+
+ def defines?(resource_class, method_name)
+ resource_class.method_defined?(method_name) ||
+ resource_class.private_method_defined?(method_name)
+ end
+
+ def missing_sideload_filter(sideload)
+ return if builds_its_own_query?(sideload)
+
+ key = sideload_filter_key(sideload)
+ return unless key
+
+ related = sideload.resource.class
+ return if related.filters.key?(key)
+
+ Finding.new(
+ severity: :error,
+ check: :missing_sideload_filter,
+ message: "#{related.name} is missing `filter #{key.inspect}`",
+ remedy: "declare the filter on the related resource"
+ )
+ end
+
+ def builds_its_own_query?(sideload)
+ sideload.class.params_proc || sideload.class.scope_proc
+ end
+
+ def sideload_filter_key(sideload)
+ case sideload.type
+ when :has_many, :has_one then sideload.foreign_key
+ when :many_to_many then sideload.true_foreign_key
+ when :belongs_to then sideload.primary_key
+ end
+ end
+
+ def association_preloaded?(resource_class, sideload)
+ scope = resource_class.new.base_scope
+ return nil unless scope.respond_to?(:includes_values)
+
+ preloads = [scope.includes_values, scope.preload_values, scope.eager_load_values]
+ association_names(preloads).include?(sideload.association_name.to_sym)
+ rescue
+ nil
+ end
+
+ def association_names(preload_values)
+ Array(preload_values).flat_map do |value|
+ case value
+ when Hash then value.keys.map(&:to_sym)
+ when Array then association_names(value)
+ else [value.to_sym]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/audit/report.rb b/lib/graphiti/audit/report.rb
new file mode 100644
index 00000000..0de48ca4
--- /dev/null
+++ b/lib/graphiti/audit/report.rb
@@ -0,0 +1,226 @@
+module Graphiti
+ class Audit
+ class Report
+ ANSI = {red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, bold: 1, dim: 2}.freeze
+
+ ISSUE_HEADINGS = {
+ 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"
+ }.freeze
+
+ CHECKLIST = {
+ broken_relationship: ["all relationships inspectable", "relationship", "raised while being inspected"],
+ 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"],
+ loads_on_every_render: ["all id-rendering loads preloaded", "relationship", "loading ids without preloading"]
+ }.freeze
+
+ Cell = Struct.new(:raw, :painted) do
+ def pad_to(width)
+ painted + " " * (width - raw.length)
+ end
+ end
+
+ def initialize(rows, color: $stdout.tty?)
+ @rows = rows
+ @color = color
+ end
+
+ def to_s
+ return "graphiti: no relationships found.\n" if @rows.empty?
+
+ (body + [summary]).join("\n")
+ end
+
+ private
+
+ def body
+ [""] + issue_sections + loading_section + would_load_section + checks_section
+ end
+
+ def issue_sections
+ row_finding_pairs = @rows.flat_map do |row|
+ row.findings.map { |finding| [row, finding] }
+ end
+
+ row_finding_pairs
+ .group_by { |_, finding| finding.check }
+ .sort_by { |check, _| ISSUE_HEADINGS.keys.index(check) || ISSUE_HEADINGS.size }
+ .flat_map { |check, group| issue_section(check, group) }
+ end
+
+ def issue_section(check, group)
+ findings = group.map(&:last)
+ label = findings.first.error? ? "ERROR" : "WARNING"
+ color = findings.first.error? ? :red : :yellow
+ fixes = findings.filter_map(&:remedy).uniq
+
+ section_heading(label, color, ISSUE_HEADINGS.fetch(check, check.to_s.tr("_", " "))) +
+ lines_grouped_by_resource(group.map { |row, finding| [row, finding.message] }) +
+ ((fixes.size == 1) ? [fix_line(fixes.first), ""] : [])
+ end
+
+ def loading_section
+ rows = @rows.select { |row| loads_without_preload?(row) }
+ return [] if rows.empty?
+
+ section_heading("WARNING", :yellow, "rendering resource ids by loading an association the base_scope does not preload") +
+ lines_grouped_by_resource(rows.map { |row| [row, nil] }) +
+ [" #{paint("That is a query per record rendered.", :dim)}",
+ fix_line("preload the association in base_scope, or drop `resource_ids`"),
+ " #{paint("See graphiti.dev/concepts/relationships#customizing-relationships", :dim)}",
+ ""]
+ end
+
+ def loads_without_preload?(row)
+ row.resource_ids_source == :load && row.preloaded == false
+ end
+
+ def would_load_section
+ rows = @rows.select(&:would_start_loading?)
+ return [] if rows.empty?
+
+ verb = (rows.size == 1) ? "renders" : "render"
+
+ section_heading("FYI", :blue, "#{count(rows.size, "belongs_to relationship")} #{verb} no resource ids") +
+ lines_grouped_by_resource(rows.map { |row| [row, nil] }) +
+ [" #{paint("Nothing to fix. A request that includes the relationship still gets its ids.", :dim)}",
+ "",
+ " #{paint("To render ids on every response, opt in:", :dim)}",
+ ""] +
+ opt_in_lines +
+ ["",
+ " #{paint("Either would load the association on every render. That potential performance cost is why ids are opt-in.", :dim)}",
+ " #{paint("Preloading the association in base_scope keeps that load cheap.", :dim)}",
+ " #{paint("See graphiti.dev/concepts/relationships#belongs-to-resource-ids", :dim)}",
+ ""]
+ end
+
+ def checks_section
+ failure_counts = @rows.flat_map(&:findings).map(&:check).tally
+ loading = @rows.count { |row| loads_without_preload?(row) }
+ failure_counts[:loads_on_every_render] = loading if loading > 0
+
+ no_loads = @rows.none? { |row| row.resource_ids_source == :load }
+
+ lines = CHECKLIST.filter_map do |check, (passed, failure_noun, failure_suffix)|
+ if (failures = failure_counts[check])
+ " #{paint("✗", :red)} #{count(failures, failure_noun)} #{failure_suffix}"
+ elsif check == :loads_on_every_render && no_loads
+ nil
+ else
+ " #{paint("✓", :green)} #{passed}"
+ end
+ end
+
+ [paint("checks", :bold), ""] + lines + [""]
+ end
+
+ def section_heading(label, color, heading)
+ ["#{paint(label, color, :bold)} #{paint(heading, :bold)}", ""]
+ end
+
+ def fix_line(text)
+ " #{paint("fix:", :green)} #{text}"
+ end
+
+ def lines_grouped_by_resource(pairs)
+ width = pairs.map { |row, _| declaration_cell(row).raw.length }.max
+
+ pairs.group_by { |row, _| row.resource }.sort_by { |resource, _| resource }.flat_map do |resource, group|
+ [" #{paint(resource, :bold)}"] +
+ group.sort_by { |row, _| row.relationship.to_s }.map { |row, note|
+ declaration = declaration_cell(row)
+ note ? " #{declaration.pad_to(width)} #{note}" : " #{declaration.painted}"
+ } +
+ [""]
+ end
+ end
+
+ def opt_in_lines
+ options = [
+ [[["resource_ids: ", :cyan], ["true", :yellow]], "on one relationship"],
+ [[["self.belongs_to_resource_ids_by_default = ", nil], [":always", :cyan]], "across the whole API"]
+ ]
+
+ rows = options.map do |parts, scope|
+ [cell(parts), cell([[scope, :dim]])]
+ end
+
+ aligned(rows, " ")
+ end
+
+ def declaration_cell(row)
+ cell(declaration_parts(row))
+ end
+
+ def declaration_parts(row)
+ parts = [[row.type.to_s, :blue], [" ", nil], [":#{row.relationship}", :cyan]]
+
+ row.options.each do |key, value|
+ parts << [", ", nil] << ["#{key}: ", :cyan] << option_part(value)
+ end
+
+ parts
+ end
+
+ def option_part(value)
+ case value
+ when "..." then ["{ ... }", :dim]
+ when true, false then [value.inspect, :yellow]
+ when Symbol then [value.inspect, :cyan]
+ when String then [value.inspect, :green]
+ else [value.inspect, nil]
+ end
+ end
+
+ def cell(parts)
+ Cell.new(
+ parts.map { |text, _| text }.join,
+ parts.map { |text, style| paint(text, style) }.join
+ )
+ end
+
+ def aligned(rows, indent)
+ widths = rows.transpose.map { |column| column.map { |cell| cell.raw.length }.max }
+
+ rows.map do |cells|
+ line = cells.each_with_index.map { |cell, index|
+ (index == cells.size - 1) ? cell.painted : cell.pad_to(widths[index])
+ }.join(" ")
+ indent + line
+ end
+ end
+
+ def summary
+ errors = @rows.count(&:error?)
+ loading = @rows.count { |row| loads_without_preload?(row) }
+ no_ids = @rows.count(&:would_start_loading?)
+
+ counts = [
+ count(@rows.group_by(&:resource).size, "resource"),
+ count(@rows.size, "relationship"),
+ paint(count(errors, "error"), (errors > 0) ? :red : nil)
+ ]
+ counts << paint("#{loading} loading ids without preloading", :yellow) if loading > 0
+ counts << "#{no_ids} without resource ids" if no_ids > 0
+
+ "graphiti: #{counts.join(", ")}."
+ end
+
+ def count(number, noun)
+ "#{number} #{noun}#{"s" unless number == 1}"
+ end
+
+ def paint(text, *styles)
+ styles = styles.compact
+ return text unless @color && styles.any?
+
+ "\e[#{styles.map { |style| ANSI.fetch(style) }.join(";")}m#{text}\e[0m"
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/configuration.rb b/lib/graphiti/configuration.rb
index 2792eeb1..cfe0922b 100644
--- a/lib/graphiti/configuration.rb
+++ b/lib/graphiti/configuration.rb
@@ -24,10 +24,6 @@ class Configuration
attr_accessor :respond_to
attr_accessor :context_for_endpoint
- attr_accessor :links_on_demand
- attr_accessor :pagination_links_on_demand
- attr_accessor :pagination_links
- attr_accessor :typecast_reads
attr_accessor :raise_on_missing_sidepost
attr_accessor :before_sideload
@@ -44,32 +40,12 @@ def initialize
@concurrency = false
@concurrency_max_threads = 4
@respond_to = [:json, :jsonapi, :xml]
- @links_on_demand = false
- @pagination_links_on_demand = false
- @pagination_links = false
- @typecast_reads = true
@raise_on_missing_sidepost = true
@cache_rendering = false
self.debug = ENV.fetch("GRAPHITI_DEBUG", true)
self.debug_models = ENV.fetch("GRAPHITI_DEBUG_MODELS", false)
@uri_decoder = infer_uri_decoder
-
- # FIXME: Don't duplicate graphiti-rails efforts
- if defined?(::Rails.root) && (root = ::Rails.root)
- config_file = root.join(".graphiticfg.yml")
- if config_file.exist?
- cfg = YAML.load_file(config_file)
- @schema_path = root.join("public#{cfg["namespace"]}/schema.json")
- else
- @schema_path = root.join("public/schema.json")
- end
-
- if (logger = ::Rails.logger)
- self.debug = logger.debug? && debug
- Graphiti.logger = logger
- end
- end
end
def cache_rendering?
@@ -87,13 +63,20 @@ def schema_path
end
def debug=(val)
- @debug = val
- Debugger.enabled = val
+ @debug = coerce_flag(val)
+ Debugger.enabled = @debug
end
def debug_models=(val)
- @debug_models = val
- Debugger.debug_models = val
+ @debug_models = coerce_flag(val)
+ Debugger.debug_models = @debug_models
+ end
+
+ # every value comes back from ENV as a string and "false" is truthy
+ def coerce_flag(val)
+ return false if ["false", "0", ""].include?(val.to_s.strip.downcase)
+
+ !!val
end
def with_option(key, value)
@@ -104,6 +87,48 @@ def with_option(key, value)
send(:"#{key}=", original)
end
+ def typecast_reads
+ Resource.typecast_reads
+ end
+
+ def typecast_reads=(val)
+ Resource.typecast_reads = val
+ end
+
+ def links_on_demand
+ Resource.relationship_links == :on_demand
+ end
+
+ def links_on_demand=(val)
+ if val
+ Resource.relationship_links = :on_demand
+ elsif Resource.relationship_links == :on_demand
+ Resource.relationship_links = true
+ end
+ end
+
+ def pagination_links
+ Resource.page_links == true
+ end
+
+ def pagination_links=(val)
+ return if Resource.page_links == :on_demand
+
+ Resource.page_links = !!val
+ end
+
+ def pagination_links_on_demand
+ Resource.page_links == :on_demand
+ end
+
+ def pagination_links_on_demand=(val)
+ if val
+ Resource.page_links = :on_demand
+ elsif Resource.page_links == :on_demand
+ Resource.page_links = false
+ end
+ end
+
def uri_decoder=(decoder)
unless decoder.respond_to?(:call)
raise "uri_decoder must respond to `call`."
@@ -132,6 +157,19 @@ def infer_uri_decoder
end
end
- msg = "Use graphiti-rails's `config.graphiti.respond_to_formats`"
+ msg = "Use `config.graphiti.respond_to_formats`"
DEPRECATOR.deprecate_methods(Configuration, respond_to: msg, "respond_to=": msg)
+
+ relationship_msg = "Set `self.relationship_links` (true, false, or :on_demand) on your resource"
+ pagination_msg = "Set `self.page_links` (true, false, or :on_demand) on your resource"
+ typecast_msg = "Set `self.typecast_reads` on your resource"
+ DEPRECATOR.deprecate_methods(Configuration,
+ typecast_reads: typecast_msg,
+ "typecast_reads=": typecast_msg,
+ links_on_demand: relationship_msg,
+ "links_on_demand=": relationship_msg,
+ pagination_links: pagination_msg,
+ "pagination_links=": pagination_msg,
+ pagination_links_on_demand: pagination_msg,
+ "pagination_links_on_demand=": pagination_msg)
end
diff --git a/lib/graphiti/context_hash.rb b/lib/graphiti/context_hash.rb
new file mode 100644
index 00000000..dfe70826
--- /dev/null
+++ b/lib/graphiti/context_hash.rb
@@ -0,0 +1,21 @@
+module Graphiti
+ # graphiti-api/graphiti#134 recommended reading Graphiti.context[:namespace] from base_scope before current_action existed, so the old key keeps working for one major.
+ class ContextHash < Hash
+ def [](key)
+ super(deprecate_namespace(key))
+ end
+
+ def []=(key, value)
+ super(deprecate_namespace(key), value)
+ end
+
+ private
+
+ def deprecate_namespace(key)
+ return key unless key == :namespace
+
+ Graphiti::DEPRECATOR.deprecation_warning(:"context[:namespace]", "Use #current_action instead", caller_locations(2))
+ :action
+ end
+ end
+end
diff --git a/lib/graphiti/debugger.rb b/lib/graphiti/debugger.rb
index b800498a..40d11771 100644
--- a/lib/graphiti/debugger.rb
+++ b/lib/graphiti/debugger.rb
@@ -11,13 +11,13 @@ class << self
private_constant :CHUNKS
class << self
- # Pool threads inherit this through the thread-local copy Scope#future_with_context makes, so their chunks reach the right request.
+ # Pool threads inherit this through the fiber storage copy Scope#future_with_context makes, so their chunks reach the right request.
def chunks
- Thread.current[CHUNKS] ||= Concurrent::Array.new
+ Fiber[CHUNKS] ||= Concurrent::Array.new
end
def chunks=(value)
- Thread.current[CHUNKS] = value
+ Fiber[CHUNKS] = value
end
def on_data(name, start, stop, id, payload)
diff --git a/lib/graphiti/delegates/pagination.rb b/lib/graphiti/delegates/pagination.rb
index 4fb2dfee..b3d9ac8c 100644
--- a/lib/graphiti/delegates/pagination.rb
+++ b/lib/graphiti/delegates/pagination.rb
@@ -6,7 +6,7 @@ def initialize(proxy)
end
def links?
- @proxy.query.pagination_links? && @proxy.data.present?
+ @proxy.query.page_links? && @proxy.data.present?
end
def links
@@ -32,7 +32,7 @@ def has_previous_page?
private
def pagination_params
- @pagination_params ||= @proxy.query.params.reject { |key, _| [:action, :controller, :format].include?(key) }
+ @pagination_params ||= @proxy.query.params.except(:action, :controller, :format)
end
def pagination_link(page)
@@ -104,12 +104,12 @@ def offset
def page_size
@page_size ||= (page_param[:size] ||
- @proxy.resource.default_page_size ||
+ @proxy.resource.page_default_size ||
Graphiti::Scoping::Paginate::DEFAULT_PAGE_SIZE).to_i
end
def page_param
- @page_param ||= (@proxy.query.hash[:page] || {})
+ @page_param ||= @proxy.query.hash[:page] || {}
end
end
end
diff --git a/lib/graphiti/deprecated_tasks.rb b/lib/graphiti/deprecated_tasks.rb
deleted file mode 100644
index 8efcfab8..00000000
--- a/lib/graphiti/deprecated_tasks.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-namespace :graphiti do
- def session
- @session ||= ActionDispatch::Integration::Session.new(Rails.application)
- end
-
- def setup_rails!
- Rails.application.eager_load!
- Rails.application.config.cache_classes = true
- Rails.application.config.action_controller.perform_caching = false
- end
-
- def make_request(path, debug = false)
- if path.split("/").length == 2
- path = "#{ApplicationResource.endpoint_namespace}#{path}"
- end
- path << if path.include?("?")
- "&cache=bust"
- else
- "?cache=bust"
- end
- path = "#{path}&debug=true" if debug
- session.get(path.to_s)
- JSON.parse(session.response.body)
- end
-
- desc "Execute request without web server."
- task :request, [:path, :debug] => [:environment] do |_, args|
- setup_rails!
- Graphiti.logger = Graphiti.stdout_logger
- Graphiti::Debugger.preserve = true
- require "pp"
- path, debug = args[:path], args[:debug]
- puts "Graphiti Request: #{path}"
- json = make_request(path, debug)
- pp json
- Graphiti::Debugger.flush if debug
- end
-
- desc "Execute benchmark without web server."
- task :benchmark, [:path, :requests] => [:environment] do |_, args|
- setup_rails!
- took = Benchmark.ms {
- args[:requests].to_i.times do
- make_request(args[:path])
- end
- }
- puts "Took: #{(took / args[:requests].to_f).round(2)}ms"
- end
-end
diff --git a/lib/graphiti/deserializer.rb b/lib/graphiti/deserializer.rb
index dc7ce8c2..7c38002b 100644
--- a/lib/graphiti/deserializer.rb
+++ b/lib/graphiti/deserializer.rb
@@ -85,7 +85,7 @@ def attributes
def meta(action: :update)
{
type: data[:type],
- temp_id: data[:'temp-id'],
+ temp_id: data[:"temp-id"],
method: action,
payload_path: ["data"]
}
@@ -162,12 +162,12 @@ def process_relationship(relationship_data)
end
def process_relationship_datum(datum)
- temp_id = datum[:'temp-id']
+ temp_id = datum[:"temp-id"]
included_object = included.find { |i|
next unless i[:type] == datum[:type]
(i[:id] && i[:id] == datum[:id]) ||
- (i[:'temp-id'] && i[:'temp-id'] == temp_id)
+ (i[:"temp-id"] && i[:"temp-id"] == temp_id)
}
included_idx = included.index(included_object)
diff --git a/lib/graphiti/error_serializers/conflict_request.rb b/lib/graphiti/error_serializers/conflict_request.rb
new file mode 100644
index 00000000..3714f48f
--- /dev/null
+++ b/lib/graphiti/error_serializers/conflict_request.rb
@@ -0,0 +1,19 @@
+module Graphiti
+ module ErrorSerializers
+ # graphiti_errors overrode only the status here, and so reported a 409
+ # alongside code "bad_request".
+ class ConflictRequest < InvalidRequest
+ STATUS = 409
+
+ private
+
+ def code
+ "conflict"
+ end
+
+ def default_title
+ "Conflict Error"
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/error_serializers/deprecated_constants.rb b/lib/graphiti/error_serializers/deprecated_constants.rb
new file mode 100644
index 00000000..c541b02d
--- /dev/null
+++ b/lib/graphiti/error_serializers/deprecated_constants.rb
@@ -0,0 +1,61 @@
+# Only graphiti_errors' serializers survived the 2.0 merge. Remove in 3.0.
+module GraphitiErrors
+ # Including an empty module succeeds, so silence here means HTML error pages.
+ def self.included(klass)
+ raise <<~MSG
+ GraphitiErrors merged into graphiti in 2.0 and this include does nothing. Use:
+
+ include Graphiti::Rails::Controller
+
+ Your own exceptions register alongside Graphiti's with `register_exception`.
+ `registered_exception?` is now `RescueRegistry.handles_exception?`, and
+ `handle_exception` went with the rendering. See graphiti.dev/upgrading.
+ MSG
+ end
+
+ # The old global rendering toggle, most often flipped in specs.
+ class << self
+ def enable!
+ Graphiti::DEPRECATOR.deprecation_warning("GraphitiErrors.enable!", "wrap the request in Graphiti::Rails::TestHelpers#handle_request_exceptions instead")
+ test_helpers.handle_request_exceptions(true)
+ end
+
+ def disable!
+ Graphiti::DEPRECATOR.deprecation_warning("GraphitiErrors.disable!", "wrap the request in Graphiti::Rails::TestHelpers#handle_request_exceptions instead")
+ test_helpers.handle_request_exceptions(false)
+ end
+
+ def disabled?
+ !test_helpers.handle_request_exceptions?
+ end
+
+ private
+
+ def test_helpers
+ @test_helpers ||= Object.new.extend(Graphiti::Rails::TestHelpers)
+ end
+ end
+
+ module Validation; end
+
+ module InvalidRequest; end
+
+ module ConflictRequest; end
+
+ module Serializers; end
+end
+
+{
+ "GraphitiErrors::Validation::Serializer" => "Graphiti::ErrorSerializers::Validation",
+ "GraphitiErrors::InvalidRequest::Serializer" => "Graphiti::ErrorSerializers::InvalidRequest",
+ "GraphitiErrors::ConflictRequest::Serializer" => "Graphiti::ErrorSerializers::ConflictRequest",
+ # Graphiti 1.0.x referenced this spelling
+ "GraphitiErrors::Serializers::Validation" => "Graphiti::ErrorSerializers::Validation"
+}.each do |old_name, new_name|
+ namespace, _, constant = old_name.rpartition("::")
+
+ Object.const_get(namespace).const_set(
+ constant,
+ ActiveSupport::Deprecation::DeprecatedConstantProxy.new(old_name, new_name, Graphiti::DEPRECATOR)
+ )
+end
diff --git a/lib/graphiti/error_serializers/invalid_request.rb b/lib/graphiti/error_serializers/invalid_request.rb
new file mode 100644
index 00000000..25a0522c
--- /dev/null
+++ b/lib/graphiti/error_serializers/invalid_request.rb
@@ -0,0 +1,64 @@
+module Graphiti
+ module ErrorSerializers
+ class InvalidRequest
+ include TranslatedTitle
+
+ STATUS = 400
+
+ attr_reader :source
+
+ def initialize(source)
+ @source = source
+ end
+
+ def status
+ self.class::STATUS
+ end
+
+ def errors
+ [].tap do |payload|
+ source.details.each_pair do |attribute, attribute_errors|
+ attribute_errors.each_with_index do |error, index|
+ message = source.messages[attribute][index]
+
+ payload << {
+ code: code,
+ status: status.to_s,
+ title: title,
+ detail: source.full_message(attribute, message),
+ source: {pointer: pointer_for(attribute)},
+ meta: {
+ attribute: attribute,
+ message: message,
+ code: error[:error]
+ }
+ }
+ end
+ end
+ end
+ end
+
+ # graphiti_errors named the payload this. Remove in 3.0.
+ def rendered_errors
+ Graphiti::DEPRECATOR.deprecation_warning("#rendered_errors", "use #errors")
+ errors
+ end
+
+ private
+
+ def code
+ "bad_request"
+ end
+
+ def default_title
+ "Request Error"
+ end
+
+ # "filter.foo[0]" describes a path into the payload, not an attribute
+ # name, so it becomes a JSON pointer.
+ def pointer_for(attribute)
+ attribute.to_s.tr(".", "/").gsub(/\[(\d+)\]/, '/\1')
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/error_serializers/translated_title.rb b/lib/graphiti/error_serializers/translated_title.rb
new file mode 100644
index 00000000..1e1e7689
--- /dev/null
+++ b/lib/graphiti/error_serializers/translated_title.rb
@@ -0,0 +1,13 @@
+module Graphiti
+ module ErrorSerializers
+ module TranslatedTitle
+ private
+
+ def title
+ return default_title unless defined?(::I18n)
+
+ ::I18n.t :title, scope: [:graphiti, :errors, code], default: default_title
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/error_serializers/validation.rb b/lib/graphiti/error_serializers/validation.rb
new file mode 100644
index 00000000..1d71697b
--- /dev/null
+++ b/lib/graphiti/error_serializers/validation.rb
@@ -0,0 +1,153 @@
+module Graphiti
+ module ErrorSerializers
+ # Walks into related objects so nested writes report against the record
+ # that actually failed.
+ class Validation
+ include TranslatedTitle
+
+ attr_reader :object
+
+ def initialize(object, relationship_payloads = {}, relationship_meta = {})
+ @object = object
+ @relationship_payloads = relationship_payloads
+ @relationship_meta = relationship_meta
+ end
+
+ def attribute_errors
+ [].tap do |errors|
+ each_error do |attribute, message, validation_code|
+ errors << {
+ code: code,
+ status: "422",
+ title: title,
+ detail: detail_for(attribute, message),
+ source: {pointer: pointer_for(object, attribute)},
+ meta: meta_for(attribute, message, validation_code, @relationship_meta)
+ }
+ end
+ end
+ end
+
+ def errors
+ return [] unless object.respond_to?(:errors)
+
+ attribute_errors | relationship_errors(object, @relationship_payloads)
+ end
+
+ private
+
+ def code
+ "unprocessable_entity"
+ end
+
+ def default_title
+ "Validation Error"
+ end
+
+ def each_error
+ object.errors.messages.each_pair do |attribute, messages|
+ details = object.errors.details.find { |k, _| k == attribute }[1]
+
+ messages.each_with_index do |message, index|
+ yield attribute, message, details[index][:error]
+ end
+ end
+ end
+
+ def relationship?(name)
+ relationship_names = []
+ if activerecord?
+ relationship_names = object.class
+ .reflect_on_all_associations.map(&:name)
+ elsif object.respond_to?(:relationship_names)
+ relationship_names = object.relationship_names
+ end
+
+ relationship_names.include?(name)
+ end
+
+ def attribute?(name)
+ object.respond_to?(name)
+ end
+
+ def meta_for(attribute, message, code, relationship_meta)
+ meta = {
+ attribute: attribute,
+ message: message,
+ code: code
+ }
+
+ unless relationship_meta.empty?
+ meta = {relationship: meta.merge(relationship_meta)}
+ end
+
+ meta
+ end
+
+ def detail_for(attribute, message)
+ detail = object.errors.full_message(attribute, message)
+ detail = message if attribute.to_s.downcase == "base"
+ detail
+ end
+
+ # @richmolj: Keeping this to support ember-data, but I hate the concept.
+ def pointer_for(object, name)
+ if relationship?(name)
+ "/data/relationships/#{name}"
+ elsif attribute?(name)
+ "/data/attributes/#{name}"
+ elsif name == :base
+ nil
+ else
+ # Probably a nested relation, like post.comments
+ "/data/relationships/#{name}"
+ end
+ end
+
+ def activerecord?
+ object.class.respond_to?(:reflect_on_all_associations)
+ end
+
+ def traverse_relationships(model, relationship_params)
+ return unless relationship_params
+
+ relationship_params.each_pair do |name, payload|
+ relationship_objects = Array(model.send(name))
+
+ relationship_objects.each do |relationship_object|
+ related_payload = payload
+ if payload.is_a?(Array)
+ related_payload = payload.find { |p|
+ temp_id = relationship_object
+ .instance_variable_get(:@_jsonapi_temp_id)
+ p[:meta][:temp_id] === temp_id ||
+ p[:meta][:id] == relationship_object.id.to_s
+ }
+ end
+
+ yield name, relationship_object, related_payload
+ relationship_errors(relationship_object, related_payload[:relationships])
+ end
+ end
+ end
+
+ def relationship_errors(model, relationship_payloads)
+ errors = []
+ traverse_relationships(model, relationship_payloads) do |name, related, payload|
+ meta = {}.tap do |hash|
+ hash[:name] = name
+ hash[:type] = payload[:meta][:jsonapi_type]
+ if (temp_id = related.instance_variable_get(:@_jsonapi_temp_id))
+ hash[:"temp-id"] = temp_id
+ else
+ hash[:id] = related.id
+ end
+ end
+
+ errors |= self.class.new(related, payload[:relationships], meta).errors
+ end
+ errors
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/errors.rb b/lib/graphiti/errors.rb
index bae2fd50..6d54cc4e 100644
--- a/lib/graphiti/errors.rb
+++ b/lib/graphiti/errors.rb
@@ -221,7 +221,7 @@ def message
Make sure the endpoint "#{@sideload.resource.endpoint[:full_path]}" exists with action #{@action.inspect}, or customize the endpoint for #{@sideload.resource.class.name}.
- If you do not wish to generate a link, pass link: false or set self.autolink = false.
+ If you do not wish to generate a link, pass link: false or set self.relationship_links = false. To keep the link and stop checking it, set self.validate_links = false.
MSG
end
end
@@ -255,6 +255,8 @@ def message
Graphiti.config.context_for_endpoint must be set to enable link generation:
Graphiti.config.context_for_endpoint = ->(path, action) { ... }
+
+ Or set self.validate_links = false to render links without checking them.
MSG
end
end
@@ -270,7 +272,7 @@ def message
#{@resource_class.name}: error occurred while sideloading "#{@sideload_name}"!
The error was raised while attempting to build query parameters for the associated Resource.
- Read more about sideload scoping here: www.graphiti.dev/guides/concepts/resources#customizing-scope
+ Read more about sideload scoping here: graphiti.dev/concepts/relationships#customizing-scope
A good way to debug is to put a debugger within the 'params' block.
@@ -294,7 +296,7 @@ def message
The error was raised while attempting to build the scope for the associated Resource.
- Read more about sideload scoping here: www.graphiti.dev/guides/concepts/resources#customizing-scope
+ Read more about sideload scoping here: graphiti.dev/concepts/relationships#customizing-scope
Here's the original, underlying error:
@@ -315,7 +317,7 @@ def message
#{@resource_class.name}: error occurred while sideloading "#{@sideload_name}"!
The error was raised while attempting to assign relevant model instances. Read
- more about sideload assignment here: www.graphiti.dev/guides/concepts/resources#customizing-assignment
+ more about sideload assignment here: graphiti.dev/concepts/relationships#customizing-assignment
A good way to debug is to put a debugger within the 'assign' block.
@@ -445,11 +447,11 @@ def message
secondary_endpoint '/my_url', [:index, :update]
- Or disable endpoint validation for this resource:
+ Or disable request validation for this resource:
- self.validate_endpoints = false
+ self.validate_requests = false
- See https://www.graphiti.dev/guides/concepts/links for more information.
+ See https://graphiti.dev/concepts/links for more information.
The current endpoints allowed for this resource are: #{@resource_class.endpoints.inspect}
MSG
@@ -689,6 +691,99 @@ def message
end
end
+ class InvalidBelongsToResourceIds < Base
+ def initialize(resource_class, value)
+ @resource_class = resource_class
+ @value = value
+ end
+
+ def message
+ <<~MSG
+ #{@resource_class.name}: belongs_to_resource_ids_by_default must be one of :foreign_key, :always, or :never. Got #{@value.inspect}.
+
+ :foreign_key - render resource ids wherever the foreign key already holds the related id (default)
+ :always - render them for every belongs_to, loading the association when the foreign key cannot answer
+ :never - render none
+
+ To render resource ids for a collection, ask for it one relationship at a time with `resource_ids: true`.
+ MSG
+ end
+ end
+
+ class InvalidFilterBlanks < Base
+ def initialize(resource_class, attribute, value)
+ @resource_class = resource_class
+ @attribute = attribute
+ @value = value
+ end
+
+ def message
+ <<~MSG
+ #{@resource_class.name}: #{@attribute} must be one of :literal, :null, or :rejected. Got #{@value.inspect}.
+
+ :literal - "null" and "" reach the filter as strings (default)
+ :null - "null" becomes nil, so the filter can query for NULL
+ :rejected - a blank value raises InvalidFilterValue
+ MSG
+ end
+ end
+
+ class InvalidLinkRendering < Base
+ def initialize(resource_class, attribute, value)
+ @resource_class = resource_class
+ @attribute = attribute
+ @value = value
+ end
+
+ def message
+ "#{@resource_class.name}: #{@attribute} must be true, false, or :on_demand. Got #{@value.inspect}."
+ end
+ end
+
+ class UnselectedForeignKey < Base
+ def initialize(resource_class, sideload, model)
+ @resource_class = resource_class
+ @sideload = sideload
+ @model = model
+ end
+
+ def message
+ <<~MSG
+ #{@resource_class.name}: rendering resource linkage for relationship #{@sideload.name.inspect} reads ##{@sideload.foreign_key} off #{@model.class.name}, but that attribute is not loaded.
+
+ Keep #{@sideload.foreign_key} in the selected columns, or turn linkage off with `resource_ids: false` on the relationship or `self.belongs_to_resource_ids_by_default = :never` on the resource.
+ MSG
+ end
+ end
+
+ class MissingRelationshipMethod < Base
+ def initialize(resource_class, sideload, model)
+ @resource_class = resource_class
+ @sideload = sideload
+ @model = model
+ end
+
+ def message
+ <<~MSG
+ #{@resource_class.name}: relationship #{@sideload.name.inspect} is declared, but #{@model.class.name} has no ##{@sideload.association_name} method.
+
+ Rendering the relationship reads the association off the model. Define ##{@sideload.association_name} on #{@model.class.name}, point the relationship at the real association with `as:`, or remove the relationship.
+ #{resource_ids_note}
+ MSG
+ end
+
+ private
+
+ def resource_ids_note
+ return "" unless @sideload.render_resource_ids?
+
+ <<~MSG
+
+ resource_ids is set on this relationship, so every render reads the association, not just requests that include it. See graphiti.dev/concepts/relationships#customizing-relationships.
+ MSG
+ end
+ end
+
class MissingDependentFilter < Base
def initialize(resource, filters)
@resource = resource
@@ -862,7 +957,7 @@ class FilterGroupMissingRequiredFilters < Base
def initialize(resource, filter_names, required)
@resource = resource
@filter_names = filter_names
- @required_label = required == :all ? "All" : "One"
+ @required_label = (required == :all) ? "All" : "One"
end
def message
diff --git a/lib/graphiti/extensions/temp_id.rb b/lib/graphiti/extensions/temp_id.rb
index 39f003ae..b39d7985 100644
--- a/lib/graphiti/extensions/temp_id.rb
+++ b/lib/graphiti/extensions/temp_id.rb
@@ -14,7 +14,7 @@ module SerializableTempId
def as_jsonapi(*)
super.tap do |hash|
if (temp_id = @object.instance_variable_get(:@_jsonapi_temp_id))
- hash[:'temp-id'] = temp_id
+ hash[:"temp-id"] = temp_id
end
end
end
diff --git a/lib/graphiti/query.rb b/lib/graphiti/query.rb
index ba1916c0..0fbe00c8 100644
--- a/lib/graphiti/query.rb
+++ b/lib/graphiti/query.rb
@@ -2,9 +2,20 @@
module Graphiti
class Query
+ TRUTHY_LINKS = [true, "true"].freeze
+ LINKLESS_FORMATS = [:json, :xml, "json", "xml"].freeze
+
attr_reader :resource, :association_name, :params, :action
- def initialize(resource, params, association_name = nil, nested_include = nil, parents = [], action = nil)
+ def initialize(resource, params, *positional, association_name: nil, nested_include: nil, parents: [], action: nil)
+ if positional.any?
+ Graphiti::DEPRECATOR.warn("Passing Query.new trailing arguments positionally is deprecated. Use association_name:/nested_include:/parents:/action: keywords.")
+ association_name ||= positional[0]
+ nested_include ||= positional[1]
+ parents = positional[2] if positional.length > 2
+ action ||= positional[3]
+ end
+
@resource = resource
@association_name = association_name
@params = params
@@ -14,35 +25,91 @@ def initialize(resource, params, association_name = nil, nested_include = nil, p
@include_param = nested_include || @params[:include]
@parents = parents
@action = parse_action(action)
+ @entity_map = Concurrent::Map.new if parents.empty?
+ @association_owners = Concurrent::Map.new if parents.empty?
end
def association?
!!@association_name
end
+ # Concurrent::Map because sideload scopes resolve on pool threads
+ def entity_map
+ return root.entity_map unless root == self
+
+ @entity_map
+ end
+
+ def association_owners
+ return root.association_owners unless root == self
+
+ @association_owners
+ 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
+ return root.repeated_resource_classes unless root == self
+
+ @repeated_resource_classes ||= begin
+ counts = Hash.new(0)
+ walk = [self]
+ while (query = walk.pop)
+ counts[query.resource.class] += 1
+ query.sideloads.each_pair do |name, sideload_query|
+ walk << sideload_query
+ sideload = query.resource.class.sideload(name)
+ next unless sideload.respond_to?(:children)
+
+ # A polymorphic child resolves under a class no node names.
+ sideload.children.each_value { |child| counts[child.resource.class] += 2 }
+ end
+ end
+ counts.select { |_, count| count > 1 }.keys.to_set
+ end
+ end
+
def top_level?
!association?
end
- def links?
- return false if [:json, :xml, "json", "xml"].include?(params[:format])
- if Graphiti.config.links_on_demand
- [true, "true"].include?(@params[:links])
- else
- true
- end
+ def includes_requested?
+ @include_param.present?
+ end
+
+ def links_requested?
+ return @links_requested if defined?(@links_requested)
+
+ @links_requested = TRUTHY_LINKS.include?(@params[:links])
+ end
+
+ def suppress_links?
+ return @suppress_links if defined?(@suppress_links)
+
+ @suppress_links = LINKLESS_FORMATS.include?(params[:format])
end
- def pagination_links?
+ def render_link?(mode)
+ return false if suppress_links?
+
+ (mode == :on_demand) ? links_requested? : mode == true
+ end
+
+ def page_links?
if action == :find
false
- elsif Graphiti.config.pagination_links_on_demand
- [true, "true"].include?(@params[:pagination_links])
+ elsif @resource.page_links == :on_demand
+ page_links_requested?
else
- Graphiti.config.pagination_links
+ @resource.page_links
end
end
+ def page_links_requested?
+ [@params[:page_links], @params[:pagination_links]]
+ .any? { |value| [true, "true"].include?(value) }
+ end
+
def debug_requested?
!!@params[:debug]
end
@@ -109,9 +176,10 @@ def sideloads
relationship_name = sideload ? sideload.name : key
hash[relationship_name] = Query.new sl_resource,
@params,
- key,
- sub_hash,
- query_parents, :all
+ association_name: key,
+ nested_include: sub_hash,
+ parents: query_parents,
+ action: :all
else
handle_missing_sideload(key)
end
@@ -123,6 +191,10 @@ def parents
@parents ||= []
end
+ def root
+ parents.first || self
+ end
+
def fields
@fields ||= begin
hash = parse_fieldset(@params[:fields] || {})
@@ -208,14 +280,14 @@ def include_hash
allowlist = nil
if @resource.context&.respond_to?(:sideload_allowlist)
allowlist = @resource.context.sideload_allowlist
- allowlist = allowlist[@resource.context_namespace] if allowlist
+ allowlist = allowlist[@resource.current_action] if allowlist
end
scrubbed = allowlist ? Util::IncludeParams.scrub(requested, allowlist) : requested
scrubbed.filter do |key, value|
sideload = @resource.class.sideload(key)
- sideload.nil? ? true : sideload.readable?
+ sideload.nil? || sideload.readable?
end
end
end
@@ -246,8 +318,8 @@ def cache_key
def query_cache_key
attrs = {extra_fields: extra_fields,
fields: fields,
- links: links?,
- pagination_links: pagination_links?,
+ links: suppress_links? ? :none : links_requested?,
+ page_links: page_links?,
format: params[:format]}
Digest::SHA1.hexdigest(attrs.to_s)
@@ -291,7 +363,7 @@ def nested?(name)
return false unless association?
split = name.to_s.split(".")
- query_names = split[0..split.length - 2].map(&:to_sym)
+ query_names = split[0..-2].map(&:to_sym)
my_names = parents.map(&:association_name).compact + [association_name].compact
query_names == my_names
end
@@ -323,7 +395,7 @@ def handle_missing_sideload(name)
end
def sort_hash(attr)
- value = attr[0] == "-" ? :desc : :asc
+ value = (attr[0] == "-") ? :desc : :asc
key = attr.sub("-", "").to_sym
{key => value}
@@ -356,7 +428,7 @@ def sort_hashes
end
def parse_action(action)
- action ||= @params.fetch(:action, Graphiti.context[:namespace]).try(:to_sym)
+ action ||= @params.fetch(:action, Graphiti.context[:action]).try(:to_sym)
case action
when :index
:all
@@ -366,5 +438,9 @@ def parse_action(action)
action
end
end
+
+ alias_method :pagination_links?, :page_links?
+ DEPRECATOR.deprecate_methods(self,
+ pagination_links?: "Use `page_links?`")
end
end
diff --git a/lib/graphiti/rails.rb b/lib/graphiti/rails.rb
index cf5028eb..1d477730 100644
--- a/lib/graphiti/rails.rb
+++ b/lib/graphiti/rails.rb
@@ -1,38 +1,88 @@
+require "rescue_registry"
+require "graphiti"
+require "rails"
+
module Graphiti
- # Rails Integration. Mix this in to ApplicationController.
- #
- # * Mixes in Base
- # * Adds a global around_action (see Base#wrap_context)
- #
- # @see Base#render_jsonapi
- # @see Base#wrap_context
+ # Rails integration for Graphiti. See {file:README.md} for more details.
module Rails
+ # Graphiti::Rails was a controller mixin before it became this namespace.
+ # Including a namespace is not an error, it just does nothing, so honour
+ # the old spelling rather than leave a controller silently without its
+ # context, debugger and exception handlers.
def self.included(klass)
- backtrace = ::Rails::VERSION::MAJOR == 4 ? caller(2) : caller_locations(2)
- Graphiti::DEPRECATOR.deprecation_warning("Including Graphiti::Rails", "Use graphiti-rails instead. See https://www.graphiti.dev/guides/graphiti-rails-migration for details.", backtrace)
-
- klass.class_eval do
- include Graphiti::Context
- include GraphitiErrors
- around_action :wrap_context
- around_action :debug
- end
- end
+ DEPRECATOR.deprecation_warning(
+ "Including Graphiti::Rails",
+ "include Graphiti::Rails::Controller instead"
+ )
- def wrap_context
- Graphiti.with_context(jsonapi_context, action_name.to_sym) do
- yield
- end
+ klass.include(Controller)
end
- def debug
- Debugger.debug do
- yield
- end
- end
+ # graphiti-rails had its own, and apps that silenced it by name still resolve.
+ DEPRECATOR = Graphiti::DEPRECATOR
- def jsonapi_context
- self
- end
+ autoload :ConflictRequestHandler, "graphiti/rails/exception_handlers"
+ autoload :Context, "graphiti/rails/context"
+ autoload :Controller, "graphiti/rails/controller"
+ autoload :Debugging, "graphiti/rails/debugging"
+ autoload :ExceptionHandler, "graphiti/rails/exception_handlers"
+ autoload :FallbackHandler, "graphiti/rails/exception_handlers"
+ autoload :InvalidRequestHandler, "graphiti/rails/exception_handlers"
+ autoload :Responders, "graphiti/rails/responders"
+ autoload :TestHelpers, "graphiti/rails/test_helpers"
+
+ # Deprecated. Backed graphiti-rails' enable!/disable! spec toggles. Remove in 3.0.
+ GraphitiErrorsTesting = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
+ "Graphiti::Rails::GraphitiErrorsTesting",
+ "Graphiti::Rails::TestHelpers",
+ DEPRECATOR
+ )
+
+ # Rails reports an exception to the error reporter unless its rescue_responses names it.
+ CLIENT_ERROR_STATUSES = {
+ "Graphiti::Errors::InvalidRequest" => :bad_request,
+ "Graphiti::Errors::RemoteWrite" => :bad_request,
+ "Graphiti::Errors::SingularSideload" => :bad_request,
+ "Graphiti::Errors::InvalidInclude" => :bad_request,
+ "Graphiti::Errors::UnsupportedSort" => :bad_request,
+ "Graphiti::Errors::UnsupportedOperator" => :bad_request,
+ "Graphiti::Errors::InvalidFilterValue" => :bad_request,
+ "Graphiti::Errors::RequiredFilter" => :bad_request,
+ "Graphiti::Errors::UnsupportedPageSize" => :bad_request,
+ "Graphiti::Errors::UnsupportedBeforeCursor" => :bad_request,
+ "Graphiti::Errors::UnsupportedPagination" => :bad_request,
+ "Graphiti::Errors::FilterGroupMissingRequiredFilters" => :bad_request,
+ "Graphiti::Errors::RecordNotFound" => :not_found,
+ "Graphiti::Errors::ConflictRequest" => :conflict
+ }.freeze
+
+ # @!attribute self.handled_exception_formats
+ # A list of formats as symbols whose exceptions will be handled by Graphiti. See {Railtie}.
+ cattr_accessor :handled_exception_formats, default: []
+
+ # @!attribute self.respond_to_formats
+ # A list of formats as symbols which will be available for Graphiti::Rails::Responders. See {Railtie}.
+ cattr_accessor :respond_to_formats, default: []
end
+
+ # Deprecated. Was core's own responders mixin, superseded by the one that came
+ # in with graphiti-rails. Remove in 3.0.
+ Responders = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
+ "Graphiti::Responders",
+ "Graphiti::Rails::Responders",
+ DEPRECATOR
+ )
+
+ # Deprecated. Core's railtie merged into graphiti-rails'. Remove in 3.0.
+ Railtie = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
+ "Graphiti::Railtie",
+ "Graphiti::Rails::Railtie",
+ DEPRECATOR
+ )
end
+
+ActiveSupport.on_load(:active_record) do
+ require "graphiti/adapters/active_record"
+end
+
+require "graphiti/rails/railtie"
diff --git a/lib/graphiti/rails/context.rb b/lib/graphiti/rails/context.rb
new file mode 100644
index 00000000..ebba38b2
--- /dev/null
+++ b/lib/graphiti/rails/context.rb
@@ -0,0 +1,34 @@
+module Graphiti
+ module Rails
+ # Wraps controller actions in a [Graphiti Context](https://graphiti.dev/concepts/resources#context) which points to the
+ # controller instance by default.
+ module Context
+ def self.included(klass)
+ klass.class_eval do
+ include Graphiti::Context
+
+ around_action :wrap_graphiti_context
+ end
+ end
+
+ # Called by [`#around_action`](https://api.rubyonrails.org/classes/AbstractController/Callbacks/ClassMethods.html#method-i-around_action)
+ # to wrap the current action in a Graphiti context defined by {#graphiti_context}.
+ def wrap_graphiti_context
+ Graphiti.with_context(graphiti_context, action_name.to_sym) do
+ yield
+ end
+ end
+
+ # The context to use for Graphiti Resources. Defaults to the controller instance.
+ # Can be redefined for different behavior.
+ def graphiti_context
+ if respond_to?(:jsonapi_context)
+ DEPRECATOR.deprecation_warning("Overriding jsonapi_context", "Override #graphiti_context instead")
+ jsonapi_context
+ else
+ self
+ end
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/controller.rb b/lib/graphiti/rails/controller.rb
new file mode 100644
index 00000000..45b2ad96
--- /dev/null
+++ b/lib/graphiti/rails/controller.rb
@@ -0,0 +1,64 @@
+module Graphiti
+ module Rails
+ # Everything a controller needs to serve Graphiti resources. Include it in
+ # the controller your API inherits from:
+ #
+ # class ApplicationController < ActionController::Base
+ # include Graphiti::Rails::Controller
+ # end
+ #
+ # Graphiti applied all of this to every controller in the application until
+ # 2.0, which meant wrapping unrelated actions in a Graphiti context and
+ # registering a catch-all exception handler on controllers that never touch
+ # Graphiti. Scoping it is now up to you.
+ module Controller
+ extend ActiveSupport::Concern
+
+ included do
+ include Graphiti::Rails::Context
+ include Graphiti::Rails::Debugging
+ # ActionController::API strips this out, but Graphiti serves multiple
+ # formats (jsonapi/json/xml), so respond_to has to work everywhere.
+ include ActionController::MimeResponds
+
+ # Broad, but the handler only renders formats listed in
+ # config.graphiti.handled_exception_formats and hands everything else
+ # back to Rails.
+ register_exception Exception,
+ status: :passthrough,
+ handler: Graphiti::Rails::FallbackHandler
+
+ register_exception Graphiti::Errors::InvalidRequest,
+ status: 400, handler: Graphiti::Rails::InvalidRequestHandler
+ register_exception Graphiti::Errors::RemoteWrite,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::SingularSideload,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::InvalidInclude,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::UnsupportedSort,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::UnsupportedOperator,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::InvalidFilterValue,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::RequiredFilter,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::UnsupportedPageSize,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::UnsupportedBeforeCursor,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::UnsupportedPagination,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+ register_exception Graphiti::Errors::FilterGroupMissingRequiredFilters,
+ status: 400, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+
+ register_exception Graphiti::Errors::RecordNotFound,
+ status: 404, detail: :exception, handler: Graphiti::Rails::ExceptionHandler
+
+ register_exception Graphiti::Errors::ConflictRequest,
+ status: 409, handler: Graphiti::Rails::ConflictRequestHandler
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/debugging.rb b/lib/graphiti/rails/debugging.rb
new file mode 100644
index 00000000..693e2656
--- /dev/null
+++ b/lib/graphiti/rails/debugging.rb
@@ -0,0 +1,18 @@
+module Graphiti
+ module Rails
+ # Wraps controller actions in a [Graphiti Debugger](https://graphiti.dev/topics/debugging#debugger).
+ module Debugging
+ def self.included(klass)
+ klass.around_action :debug_graphiti
+ end
+
+ # Called by [`#around_action`](https://api.rubyonrails.org/classes/AbstractController/Callbacks/ClassMethods.html#method-i-around_action)
+ # to wrap the current action in a Graphiti Debugger.
+ def debug_graphiti
+ Debugger.debug do
+ yield
+ end
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/exception_handlers.rb b/lib/graphiti/rails/exception_handlers.rb
new file mode 100644
index 00000000..e4660215
--- /dev/null
+++ b/lib/graphiti/rails/exception_handlers.rb
@@ -0,0 +1,94 @@
+module Graphiti
+ module Rails
+ class ExceptionHandler < RescueRegistry::ExceptionHandler
+ # We've actually changed the signature here which is somewhat risky...
+ def build_payload(show_details: false, traces: nil, style: :rails)
+ case style
+ when :standard
+ super(show_details: show_details, traces: traces).tap do |payload|
+ if show_details
+ # For Vandal and Request Responses
+ payload[:__raw_error__] = {
+ message: exception.message,
+ debug: exception.instance_variable_get(:@__graphiti_debug),
+ backtrace: exception.backtrace
+ }
+ end
+ end
+ when :rails
+ # TODO: Find way to not duplicate RailsExceptionHandler
+ body = {
+ status: status_code,
+ error: title
+ }
+
+ if show_details
+ body[:exception] = exception.inspect
+ body[:traces] = traces if traces
+ end
+
+ body
+ else
+ raise ArgumentError, "unknown style #{style}"
+ end
+ end
+
+ def formatted_response(content_type, **options)
+ # We're relying on the fact that `formatted_response` passes through unknown options to `build_payload`
+ if Graphiti::Rails.handled_exception_formats.include?(content_type.to_sym)
+ options[:style] = :standard
+ end
+ super
+ end
+
+ private
+
+ def title_from_status
+ translate(:title) || super
+ end
+
+ # rescue_registry's default tells the client its engineers have been notified.
+ def default_detail_for_status
+ translate(:detail)
+ end
+
+ def translate(key)
+ return unless defined?(::I18n)
+
+ ::I18n.t key, scope: [:graphiti, :errors, error_code], default: nil
+ end
+ end
+
+ class FallbackHandler < ExceptionHandler
+ # A nil response is what hands the exception back to the default Rails
+ # handler, so formats Graphiti does not claim render as they always did.
+ def formatted_response(content_type, **options)
+ return unless Graphiti::Rails.handled_exception_formats.include?(content_type.to_sym)
+
+ super
+ end
+ end
+
+ class InvalidRequestHandler < ExceptionHandler
+ # NOTE: `style` is ignored. A rejected request always reports which
+ # parameters were rejected and why.
+ def build_payload(show_details: false, traces: nil, style: nil)
+ {errors: error_serializer.new(exception.errors).errors}
+ end
+
+ private
+
+ def error_serializer
+ Graphiti::ErrorSerializers::InvalidRequest
+ end
+ end
+
+ class ConflictRequestHandler < InvalidRequestHandler
+ private
+
+ def error_serializer
+ Graphiti::ErrorSerializers::ConflictRequest
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/railtie.rb b/lib/graphiti/rails/railtie.rb
new file mode 100644
index 00000000..f7592c19
--- /dev/null
+++ b/lib/graphiti/rails/railtie.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+module Graphiti
+ module Rails
+ # This Railtie exposes some configuration options:
+ # * `config.graphiti.handled_exception_formats`, defaulting to `[:jsonapi]`.
+ # Formats in this list will always have their exceptions handled by Graphiti.
+ # * `config.graphiti.respond_to_formats`, defaulting to `[:json, :jsonapi, :xml]`.
+ # When {Graphiti::Rails::Responders} is included in a controller it will respond
+ # to these mime types by default.
+ class Railtie < ::Rails::Railtie
+ config.graphiti = ActiveSupport::OrderedOptions.new
+
+ config.graphiti.handled_exception_formats = [:jsonapi]
+
+ old_respond_to_formats = Graphiti::DEPRECATOR.silence { Graphiti.config.try(:respond_to) }
+ config.graphiti.respond_to_formats = old_respond_to_formats || [:json, :jsonapi, :xml]
+
+ rake_tasks do
+ load File.expand_path("../../tasks/graphiti.rake", __dir__)
+ end
+
+ # ActionDispatch has already copied config.action_dispatch.rescue_responses into the wrapper by now.
+ initializer "graphiti-rails.rescue_responses", after: "action_dispatch.configure" do
+ responses = ActionDispatch::ExceptionWrapper.rescue_responses
+
+ Graphiti::Rails::CLIENT_ERROR_STATUSES.each do |exception, status|
+ # rescue_responses answers :internal_server_error for a missing key, so ||= never assigns.
+ responses[exception] = status unless responses.key?(exception)
+ end
+ end
+
+ initializer "graphiti-rails.config" do |app|
+ Graphiti::Rails.handled_exception_formats = app.config.graphiti.handled_exception_formats
+ Graphiti::Rails.respond_to_formats = app.config.graphiti.respond_to_formats
+
+ root = ::Rails.root
+
+ config_file = root.join(".graphiticfg.yml")
+ if config_file.exist?
+ cfg = YAML.load_file(config_file)
+ Graphiti.config.schema_path = root.join("public#{cfg["namespace"]}/schema.json")
+ else
+ Graphiti.config.schema_path = root.join("public/schema.json")
+ end
+ end
+
+ initializer "graphti-rails.logger" do
+ config.after_initialize do
+ Graphiti.config.debug = ::Rails.logger.debug? && Graphiti.config.debug
+ Graphiti.logger = ::Rails.logger
+ end
+ end
+
+ initializer "graphiti-rails.init" do
+ if ::Rails.application.config.eager_load
+ config.after_initialize do |app|
+ ::Rails.application.reload_routes!
+ Graphiti.setup!
+ end
+ end
+
+ register_mime_type
+ register_parameter_parser
+ register_renderers
+ establish_concurrency
+ configure_endpoint_lookup
+ end
+
+ # from jsonapi-rails
+ MEDIA_TYPE = "application/vnd.api+json"
+
+ PARSER = lambda do |body|
+ data = JSON.parse(body)
+ data[:format] = :jsonapi
+ data.with_indifferent_access
+ end
+
+ def register_mime_type
+ Mime::Type.register(MEDIA_TYPE, :jsonapi) if Mime[:jsonapi].nil?
+ end
+
+ def register_parameter_parser
+ ActionDispatch::Request.parameter_parsers[:jsonapi] ||= PARSER
+ end
+
+ def register_renderers
+ ActiveSupport.on_load(:action_controller) do
+ ::ActionController::Renderers.add(:jsonapi) do |proxy, options|
+ self.content_type ||= Mime[:jsonapi]
+
+ # opts = {}
+ # if respond_to?(:default_jsonapi_render_options)
+ # opts = default_jsonapi_render_options
+ # end
+
+ if proxy.is_a?(Hash) # for destroy
+ render(options.merge(json: proxy))
+ else
+ proxy.to_jsonapi(options)
+ end
+ end
+ end
+
+ ActiveSupport.on_load(:action_controller) do
+ ActionController::Renderers.add(:jsonapi_errors) do |proxy, options|
+ self.content_type ||= Mime[:jsonapi]
+
+ validation = Graphiti::ErrorSerializers::Validation.new \
+ proxy.data, proxy.payload.relationships
+
+ render \
+ json: {errors: validation.errors},
+ status: :unprocessable_entity
+ end
+ end
+ end
+
+ # Only run concurrently if our environment supports it
+ def establish_concurrency
+ Graphiti.config.concurrency = !::Rails.env.test? &&
+ ::Rails.application.config.cache_classes
+ end
+
+ def configure_endpoint_lookup
+ Graphiti.config.context_for_endpoint = ->(path, action) {
+ method = :GET
+ case action
+ when :show then path = "#{path}/1"
+ when :create then method = :POST
+ when :update
+ path = "#{path}/1"
+ method = :PUT
+ when :destroy
+ path = "#{path}/1"
+ method = :DELETE
+ end
+
+ route = begin
+ ::Rails.application.routes.recognize_path(path.to_s, method: method)
+ rescue
+ nil
+ end
+ "#{route[:controller]}_controller".classify.safe_constantize if route
+ }
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/rake_helpers.rb b/lib/graphiti/rails/rake_helpers.rb
new file mode 100644
index 00000000..586d52f2
--- /dev/null
+++ b/lib/graphiti/rails/rake_helpers.rb
@@ -0,0 +1,64 @@
+require "graphiti/rails/test_helpers"
+
+module Graphiti
+ module Rails
+ # Rake's `namespace` takes a block, and a block does not open a new definee,
+ # so helpers defined inside one are defined on Object and turn up in every
+ # request spec in the host app. They live here to stay off that namespace.
+ module RakeHelpers
+ extend TestHelpers
+
+ module_function
+
+ # ::Rails throughout, because bare Rails resolves to Graphiti::Rails here.
+ def session
+ @session ||= ActionDispatch::Integration::Session.new(::Rails.application)
+ end
+
+ # An engine runs the task from its own directory, not the host application's root.
+ def schema_path(path = nil)
+ path.nil? ? Graphiti.config.schema_path : File.expand_path(path, Dir.pwd)
+ end
+
+ def setup_rails!
+ ::Rails.application.eager_load!
+ ::Rails.application.config.cache_classes = true
+ ::Rails.application.config.action_controller.perform_caching = false
+ end
+
+ # The pool and RAILS_MAX_THREADS are read from this environment, and production may size both differently.
+ def connection_pool_advisory
+ return unless defined?(ActiveRecord::Base)
+
+ pool = ActiveRecord::Base.connection_db_config.pool
+ threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
+ sideload_threads = Graphiti.config.concurrency_max_threads
+ needed = threads + sideload_threads + 1
+ return if pool.nil? || pool >= needed
+
+ "WARNING database.yml pool is #{pool} in this environment. With concurrency on (the production default), " \
+ "each sideload holds its own connection, so pool should be at least " \
+ "web threads (#{threads}) + concurrency_max_threads (#{sideload_threads}) + 1 = #{needed}. " \
+ "If production sizes these differently, check the numbers there. " \
+ "See graphiti.dev/concepts/resources#concurrency-pool-sizing."
+ end
+
+ def make_request(path, debug = false)
+ if path.split("/").length == 2
+ path = "#{ApplicationResource.endpoint_namespace}#{path}"
+ end
+ path << if path.include?("?")
+ "&cache=bust"
+ else
+ "?cache=bust"
+ end
+ path = "#{path}&debug=true" if debug
+ handle_request_exceptions do
+ headers = {Authorization: ENV["AUTHORIZATION_HEADER"]}.compact
+ session.get(path.to_s, headers: headers)
+ end
+ JSON.parse(session.response.body)
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/responders.rb b/lib/graphiti/rails/responders.rb
new file mode 100644
index 00000000..31dfaed5
--- /dev/null
+++ b/lib/graphiti/rails/responders.rb
@@ -0,0 +1,22 @@
+# For use with the `responders` gem responders gem to get respond_with
+module Graphiti
+ module Rails
+ module Responders
+ extend ActiveSupport::Concern
+
+ included do
+ include ActionController::MimeResponds
+
+ respond_to(*Graphiti::Rails.respond_to_formats)
+ end
+
+ # Override to avoid location url generation (for now)
+ def respond_with(*args, &blk)
+ opts = args.extract_options!
+ opts[:location] = nil
+ args << opts
+ super
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/rails/test_helpers.rb b/lib/graphiti/rails/test_helpers.rb
new file mode 100644
index 00000000..966bea55
--- /dev/null
+++ b/lib/graphiti/rails/test_helpers.rb
@@ -0,0 +1,22 @@
+module Graphiti
+ module Rails
+ # Wrap a request in `handle_request_exceptions` to assert on the rendered
+ # error payload. Exceptions propagate untouched in tests otherwise.
+ module TestHelpers
+ include RescueRegistry::RailsTestHelpers
+
+ # rescue_registry sets action_dispatch.show_exceptions to true/false.
+ # Rails 7.1 replaced those with :all/:rescuable/:none, and Rails 8 dropped
+ # boolean handling altogether. ExceptionWrapper#show? now treats
+ # everything that is not :none as "show", so passing false would leave
+ # exception handling on and silently do nothing.
+ def handle_request_exceptions(handle = true, &block)
+ super(handle ? :all : :none, &block)
+ end
+
+ def handle_request_exceptions?
+ ::Rails.application.config.action_dispatch.show_exceptions != :none
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/railtie.rb b/lib/graphiti/railtie.rb
index 5bcd5fa5..5eccec59 100644
--- a/lib/graphiti/railtie.rb
+++ b/lib/graphiti/railtie.rb
@@ -1,121 +1,8 @@
-module Graphiti
- # @deprecated Use graphiti-rails's Graphiti::Rails::Railtie
- class Railtie < ::Rails::Railtie
- rake_tasks do
- load File.expand_path("deprecated_tasks.rb", __dir__)
- end
-
- generators do
- Dir[File.expand_path("../../deprecated_generators/**/*.rb", __dir__)].sort.each do |f|
- require f
- end
- end
-
- initializer "graphiti.require_activerecord_adapter" do
- config.after_initialize do |app|
- ActiveSupport.on_load(:active_record) do
- require "graphiti/adapters/active_record"
- end
- end
- end
-
- initializer "graphti.logger" do
- config.after_initialize do
- Graphiti.logger = ::Rails.logger
- end
- end
-
- initializer "graphiti.init" do
- if ::Rails.application.config.eager_load
- config.after_initialize do |app|
- ::Rails.application.reload_routes!
- Graphiti.setup!
- end
- end
-
- if Mime[:jsonapi].nil? # rails 4
- Mime::Type.register("application/vnd.api+json", :jsonapi)
- end
- register_parameter_parser
- register_renderers
- establish_concurrency
- configure_endpoint_lookup
- end
-
- # from jsonapi-rails
- PARSER = lambda do |body|
- data = JSON.parse(body)
- data[:format] = :jsonapi
- data.with_indifferent_access
- end
-
- def register_parameter_parser
- if ::Rails::VERSION::MAJOR >= 5
- ActionDispatch::Request.parameter_parsers[:jsonapi] = PARSER
- else
- ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime[:jsonapi]] = PARSER
- end
- end
-
- def register_renderers
- ActiveSupport.on_load(:action_controller) do
- ::ActionController::Renderers.add(:jsonapi) do |proxy, options|
- self.content_type ||= Mime[:jsonapi]
-
- # opts = {}
- # if respond_to?(:default_jsonapi_render_options)
- # opts = default_jsonapi_render_options
- # end
-
- if proxy.is_a?(Hash) # for destroy
- render(options.merge(json: proxy))
- else
- proxy.to_jsonapi(options)
- end
- end
- end
-
- ActiveSupport.on_load(:action_controller) do
- ::ActionController::Renderers.add(:jsonapi_errors) do |proxy, options|
- self.content_type ||= Mime[:jsonapi]
-
- validation = GraphitiErrors::Validation::Serializer.new \
- proxy.data, proxy.payload.relationships
-
- render \
- json: {errors: validation.errors},
- status: :unprocessable_entity
- end
- end
- end
-
- # Only run concurrently if our environment supports it
- def establish_concurrency
- Graphiti.config.concurrency = !::Rails.env.test? &&
- ::Rails.application.config.cache_classes
- end
-
- def configure_endpoint_lookup
- Graphiti.config.context_for_endpoint = ->(path, action) {
- method = :GET
- case action
- when :show then path = "#{path}/1"
- when :create then method = :POST
- when :update
- path = "#{path}/1"
- method = :PUT
- when :destroy
- path = "#{path}/1"
- method = :DELETE
- end
-
- route = begin
- ::Rails.application.routes.recognize_path(path, method: method)
- rescue
- nil
- end
- "#{route[:controller]}_controller".classify.safe_constantize if route
- }
- end
- end
-end
+# Deprecated require path, kept so a leftover 1.x require does not raise.
+# The railtie lives in Graphiti::Rails as of 2.0. Remove in 3.0.
+require "graphiti"
+
+Graphiti::DEPRECATOR.warn(
+ 'require "graphiti/railtie" is no longer needed. The railtie is part of the Rails ' \
+ "integration as of 2.0, and loads with graphiti when Rails is present."
+)
diff --git a/lib/graphiti/renderer.rb b/lib/graphiti/renderer.rb
index a8e3d6e6..b1f67dd1 100644
--- a/lib/graphiti/renderer.rb
+++ b/lib/graphiti/renderer.rb
@@ -13,8 +13,11 @@ def records
@records ||= @proxy.data
end
+ # JSON:API requires the key even when the include turns up nothing.
def to_jsonapi
- render(self.class.jsonapi_renderer).to_json
+ rendered = render(self.class.jsonapi_renderer)
+ rendered[:included] ||= [] if proxy.query.includes_requested?
+ rendered.to_json
end
def as_graphql
diff --git a/lib/graphiti/request_validators/update_validator.rb b/lib/graphiti/request_validators/update_validator.rb
index 684cd4e4..cb5a4ec9 100644
--- a/lib/graphiti/request_validators/update_validator.rb
+++ b/lib/graphiti/request_validators/update_validator.rb
@@ -2,7 +2,7 @@ module Graphiti
module RequestValidators
class UpdateValidator < Validator
def validate
- if required_payload? && payload_matches_endpoint?
+ if validate_data_shape && required_payload? && payload_matches_endpoint?
super
else
false
@@ -13,11 +13,7 @@ def validate
def attribute_mismatch(attr_path)
@error_class = Graphiti::Errors::ConflictRequest
- @errors.add(
- attr_path.join("."),
- :attribute_mismatch,
- message: "does not match the server endpoint"
- )
+ @errors.add(attr_path.join("."), :attribute_mismatch)
end
def required_payload?
@@ -32,7 +28,7 @@ def required_payload?
end
def payload_matches_endpoint?
- unless @params.dig(:data, :id) == @params.dig(:filter, :id)
+ unless @params.dig(:data, :id).to_s == @params.dig(:filter, :id).to_s
attribute_mismatch([:data, :id])
end
diff --git a/lib/graphiti/request_validators/validator.rb b/lib/graphiti/request_validators/validator.rb
index 9076d96d..3026cd25 100644
--- a/lib/graphiti/request_validators/validator.rb
+++ b/lib/graphiti/request_validators/validator.rb
@@ -17,9 +17,9 @@ def validate
# In the future these should have their own validation logic, but
# for now we can just bypass
return errors.blank? unless @params.has_key?(:data)
+ return false unless validate_data_shape
resource = @root_resource
-
if @params[:data].has_key?(:type)
if (meta_type = deserialized_payload.meta[:type].try(:to_sym))
if @root_resource.type != meta_type && @root_resource.polymorphic?
@@ -54,14 +54,22 @@ def validate_page
page = @params[:page]
return if page.nil? || page.respond_to?(:each_pair)
- @errors.add(:page, :invalid, message: "must be an object")
+ @errors.add(:page, :invalid)
+ end
+
+ def validate_data_shape
+ data = @params[:data]
+ return true if data.nil? || data.respond_to?(:each_pair)
+
+ @errors.add(:data, :invalid)
+ false
end
def process_relationships(resource, relationships, payload_path)
relationships.each_key do |name|
unless resource.class.sideload(name.to_sym)
full_key = fully_qualified_key(name, payload_path, :relationships)
- @errors.add(full_key, :invalid_relationship, message: "is not a valid relationship")
+ @errors.add(full_key, :invalid_relationship)
end
end
@@ -102,11 +110,11 @@ def typecast_attributes(resource, attributes, action, payload_path)
begin
attributes[key] = resource.typecast(key, value, :writable)
rescue Graphiti::Errors::UnknownAttribute
- @errors.add(fully_qualified_key(key, payload_path), :unknown_attribute, message: "is an unknown attribute")
+ @errors.add(fully_qualified_key(key, payload_path), :unknown_attribute)
rescue Graphiti::Errors::InvalidAttributeAccess
- @errors.add(fully_qualified_key(key, payload_path), :unwritable_attribute, message: "cannot be written")
+ @errors.add(fully_qualified_key(key, payload_path), :unwritable_attribute)
rescue Graphiti::Errors::TypecastFailed => e
- @errors.add(fully_qualified_key(key, payload_path), :type_error, message: "should be type #{e.type_name}")
+ @errors.add(fully_qualified_key(key, payload_path), :type_error, type: e.type_name)
end
end
end
diff --git a/lib/graphiti/resource.rb b/lib/graphiti/resource.rb
index 75a3289d..c882ad55 100644
--- a/lib/graphiti/resource.rb
+++ b/lib/graphiti/resource.rb
@@ -37,8 +37,8 @@ def decorate_record(record, index = nil)
end
end
- def with_context(object, namespace = nil)
- Graphiti.with_context(object, namespace) do
+ def with_context(object, action = nil)
+ Graphiti.with_context(object, action) do
yield
end
end
@@ -51,8 +51,20 @@ def context
self.class.context
end
+ # Rails sets this from action_name, so it is whatever the controller action
+ # is called, not a fixed list. Persistence overrides it with :create/:update
+ # while saving, and :show while resolving sideloads afterwards.
+ def self.current_action
+ Graphiti.context[:action]
+ end
+
+ def current_action
+ self.class.current_action
+ end
+
def self.context_namespace
- Graphiti.context[:namespace]
+ Graphiti::DEPRECATOR.deprecation_warning(:context_namespace, "Use #current_action instead")
+ current_action
end
def context_namespace
@@ -123,9 +135,18 @@ def disassociate(parent, child, association_name, type)
adapter.disassociate(parent, child, association_name, type)
end
- def persist_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil)
- persistence = Graphiti::Util::Persistence \
- .new(self, meta, attributes, relationships, caller_model, foreign_key)
+ # TODO: make foreign_key a keyword once the satellite gems are rolled in - they call these positionally
+ def assign_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil, model_instance: nil)
+ persistence = Graphiti::Util::Persistence
+ .new(self, meta, attributes, relationships, caller_model, foreign_key,
+ assigned_model: model_instance)
+ persistence.assign
+ end
+
+ def persist_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil, assigned_model: nil)
+ persistence = Graphiti::Util::Persistence
+ .new(self, meta, attributes, relationships, caller_model, foreign_key,
+ assigned_model: assigned_model)
persistence.run
end
diff --git a/lib/graphiti/resource/configuration.rb b/lib/graphiti/resource/configuration.rb
index 7080d524..bab99308 100644
--- a/lib/graphiti/resource/configuration.rb
+++ b/lib/graphiti/resource/configuration.rb
@@ -4,8 +4,78 @@ module Configuration
extend ActiveSupport::Concern
DEFAULT_MAX_PAGE_SIZE = 1_000
+ LINK_MODES = [true, false, :on_demand].freeze
+ BELONGS_TO_RESOURCE_IDS_MODES = [:foreign_key, :always, :never].freeze
+ BLANK_MODES = [:literal, :null, :rejected].freeze
+
+ # Grouped for the ApplicationResource the install generator writes.
+ SETTING_GROUPS = { # :nodoc:
+ attributes: {
+ attributes_readable_by_default: {default: true},
+ attributes_writable_by_default: {default: true},
+ attributes_sortable_by_default: {default: true},
+ attributes_filterable_by_default: {default: true},
+ attributes_schema_by_default: {default: true},
+ typecast_reads: {default: true}
+ },
+ relationships: {
+ relationships_readable_by_default: {default: true},
+ relationships_writable_by_default: {default: true},
+ relationship_placeholders: {default: false, note: "render placeholders for relationships with no ids and no link"},
+ belongs_to_resource_ids_by_default: {
+ default: :foreign_key,
+ values: BELONGS_TO_RESOURCE_IDS_MODES,
+ invalid: ->(klass, value) { Errors::InvalidBelongsToResourceIds.new(klass, value) }
+ }
+ },
+ filters: {
+ filter_blanks_treated_as: {
+ default: :literal,
+ values: BLANK_MODES,
+ invalid: ->(klass, value) { Errors::InvalidFilterBlanks.new(klass, :filter_blanks_treated_as, value) }
+ }
+ },
+ sorting: {
+ default_sort: {default: nil, note: "per resource, e.g. [{id: :desc}]"}
+ },
+ pagination: {
+ page_default_size: {default: nil, note: "unset falls back to 20"},
+ page_max_size: {default: DEFAULT_MAX_PAGE_SIZE, format: "1_000"},
+ page_cursors: {default: false, note: "render cursors for pagination"},
+ page_links: {
+ default: false,
+ values: LINK_MODES,
+ invalid: ->(klass, value) { Errors::InvalidLinkRendering.new(klass, :page_links, value) }
+ }
+ },
+ endpoints: {
+ validate_requests: {default: true, note: "refuse requests to undeclared endpoints"}
+ },
+ links: {
+ relationship_links: {
+ default: true,
+ values: LINK_MODES,
+ invalid: ->(klass, value) { Errors::InvalidLinkRendering.new(klass, :relationship_links, value) }
+ },
+ validate_links: {default: true, note: "refuse to render links to unroutable endpoints"}
+ }
+ }.freeze
+
+ SETTINGS = SETTING_GROUPS.values.reduce(:merge).freeze # :nodoc:
module Overrides
+ SETTINGS.each_pair do |name, setting|
+ next unless setting[:values]
+
+ define_method(:"#{name}=") do |val|
+ unless setting[:values].include?(val)
+ raise setting[:invalid].call(self, val)
+ end
+
+ super(val)
+ end
+ end
+
def serializer=(val)
if val
if super(Class.new(val))
@@ -50,6 +120,7 @@ def adapter=(val)
def remote=(val)
super
include ::Graphiti::Resource::Remote
+
self.endpoint = {
path: val,
full_path: val,
@@ -84,49 +155,37 @@ class << self
:polymorphic,
:polymorphic_child,
:serializer,
- :default_page_size,
- :default_sort,
- :max_page_size,
- :attributes_readable_by_default,
- :attributes_writable_by_default,
- :attributes_sortable_by_default,
- :attributes_filterable_by_default,
- :attributes_schema_by_default,
- :relationships_readable_by_default,
- :relationships_writable_by_default,
- :filters_accept_nil_by_default,
- :filters_deny_empty_by_default,
:graphql_entrypoint,
- :cursor_paginatable
+ *SETTINGS.keys
class << self
prepend Overrides
end
+ SETTINGS.each_pair do |name, setting|
+ public_send(:"#{name}=", setting[:default])
+ end
+
def self.inherited(klass)
super
klass.config = Util::Hash.deep_dup(config)
klass.adapter ||= Adapters::Abstract
- klass.max_page_size ||= DEFAULT_MAX_PAGE_SIZE
# re-assigning causes a new Class.new
klass.serializer = (klass.serializer || klass.infer_serializer_superclass)
klass.type ||= klass.infer_type
klass.graphql_entrypoint = klass.type.to_s.pluralize.to_sym
- default(klass, :attributes_readable_by_default, true)
- default(klass, :attributes_writable_by_default, true)
- default(klass, :attributes_sortable_by_default, true)
- default(klass, :attributes_filterable_by_default, true)
- default(klass, :attributes_schema_by_default, true)
- default(klass, :relationships_readable_by_default, true)
- default(klass, :relationships_writable_by_default, true)
- default(klass, :filters_accept_nil_by_default, false)
- default(klass, :filters_deny_empty_by_default, false)
-
unless klass.config[:attributes][:id]
klass.attribute :id, :integer_id
end
klass.stat total: [:count]
+ # An abstract parent has no serializer for its sideloads, so the subclass applies them here.
+ if abstract_class?
+ klass.config[:sideloads].each_pair do |name, sideload|
+ klass.apply_sideload_to_serializer(name) if klass.eagerly_apply_sideload?(sideload)
+ end
+ end
+
if defined?(::Rails) && ::Rails.env.development?
# Avoid adding dupe resources when re-autoloading
Graphiti.resources.reject! { |r| r.name == klass.name }
@@ -136,6 +195,52 @@ def self.inherited(klass)
end
class_methods do
+ # Deprecated. Both folded into filter_blanks_treated_as. Remove in 3.0.
+ def filters_accept_nil_by_default
+ filter_blanks_treated_as == :null
+ end
+
+ def filters_accept_nil_by_default=(val)
+ self.filter_blanks_treated_as = val ? :null : :literal
+ end
+
+ def filters_deny_empty_by_default
+ filter_blanks_treated_as == :rejected
+ end
+
+ def filters_deny_empty_by_default=(val)
+ self.filter_blanks_treated_as = val ? :rejected : :literal
+ end
+
+ # Deprecated. Renamed to the page_ family. Remove in 3.0.
+ def default_page_size
+ page_default_size
+ end
+
+ def default_page_size=(val)
+ self.page_default_size = val
+ end
+
+ def max_page_size
+ page_max_size
+ end
+
+ def max_page_size=(val)
+ self.page_max_size = val
+ end
+
+ def cursor_paginatable
+ page_cursors
+ end
+
+ def cursor_paginatable=(val)
+ self.page_cursors = val
+ end
+
+ def cursor_paginatable?
+ !!page_cursors
+ end
+
def get_attr!(name, flag, opts = {})
opts[:raise_error] = true
get_attr(name, flag, opts)
@@ -192,14 +297,6 @@ def infer_serializer_superclass
serializer_class
end
- def default(object, attr, value)
- prior = object.send(attr)
- unless prior || prior == false
- object.send(:"#{attr}=", value)
- end
- end
- private :default
-
def config
@config ||=
{
@@ -319,5 +416,20 @@ def default_filters
self.class.default_filters
end
end
+
+ blanks_msg = "Use `self.filter_blanks_treated_as` (:literal, :null, or :rejected)"
+ page_msg = "Use `self.page_default_size`, `self.page_max_size` and `self.page_cursors`"
+ DEPRECATOR.deprecate_methods(Configuration::ClassMethods,
+ filters_accept_nil_by_default: blanks_msg,
+ "filters_accept_nil_by_default=": blanks_msg,
+ filters_deny_empty_by_default: blanks_msg,
+ "filters_deny_empty_by_default=": blanks_msg,
+ default_page_size: page_msg,
+ "default_page_size=": page_msg,
+ max_page_size: page_msg,
+ "max_page_size=": page_msg,
+ cursor_paginatable: page_msg,
+ "cursor_paginatable=": page_msg,
+ cursor_paginatable?: page_msg)
end
end
diff --git a/lib/graphiti/resource/dsl.rb b/lib/graphiti/resource/dsl.rb
index 8d073b8f..48c0bb56 100644
--- a/lib/graphiti/resource/dsl.rb
+++ b/lib/graphiti/resource/dsl.rb
@@ -9,7 +9,7 @@ def filter(name, *args, &blk)
opts = args.extract_options!
type_override = args[0]
- if (att = (attributes[name] || extra_attributes[name]))
+ if (att = attributes[name] || extra_attributes[name])
# We're opting in to filtering, so force this
# UNLESS the filter is guarded at the attribute level
att[:filterable] = true if att[:filterable] == false
@@ -27,7 +27,7 @@ def filter(name, *args, &blk)
end
required = att[:filterable] == :required || !!opts[:required]
- schema = !!opts[:via_attribute_dsl] ? att[:schema] : opts[:schema] != false
+ schema = (!!opts[:via_attribute_dsl]) ? att[:schema] : opts[:schema] != false
config[:filters][name.to_sym] = {
aliases: aliases,
@@ -40,8 +40,7 @@ def filter(name, *args, &blk)
required: required,
schema: schema,
operators: operators.to_hash,
- allow_nil: opts.fetch(:allow_nil, filters_accept_nil_by_default),
- deny_empty: opts.fetch(:deny_empty, filters_deny_empty_by_default)
+ blanks: blanks_for(name, opts)
}
elsif (type = args[0])
attribute name, type, only: [:filterable], allow: opts[:allow]
@@ -206,6 +205,26 @@ def relationship_option(options, name)
end
end
private :relationship_option
+
+ def blanks_for(name, opts)
+ if opts.key?(:deny_empty)
+ DEPRECATOR.warn("The deny_empty: filter option is deprecated. Use blanks: :rejected.")
+ return :rejected if opts[:deny_empty]
+ end
+
+ if opts.key?(:allow_nil)
+ DEPRECATOR.warn("The allow_nil: filter option is deprecated. Use blanks: :null.")
+ return opts[:allow_nil] ? :null : :literal
+ end
+
+ blanks = opts.fetch(:blanks, filter_blanks_treated_as)
+ unless Resource::BLANK_MODES.include?(blanks)
+ raise Errors::InvalidFilterBlanks.new(self, name, blanks)
+ end
+
+ blanks
+ end
+ private :blanks_for
end
end
end
diff --git a/lib/graphiti/resource/interface.rb b/lib/graphiti/resource/interface.rb
index 58ba96a2..efb580ad 100644
--- a/lib/graphiti/resource/interface.rb
+++ b/lib/graphiti/resource/interface.rb
@@ -17,7 +17,7 @@ def all(params = {}, base_scope = nil)
# @api private
def _all(params, opts, base_scope)
- runner = Runner.new(self, params, opts.delete(:query), :all)
+ runner = Runner.new(self, params, query: opts.delete(:query), action: :all)
opts[:params] = params
runner.proxy(base_scope, opts.merge(caching_options))
end
@@ -36,7 +36,7 @@ def _find(params = {}, base_scope = nil)
params[:filter] ||= {}
params[:filter][:id] = id if id
- runner = Runner.new(self, params, nil, :find)
+ runner = Runner.new(self, params, action: :find)
find_options = {
single: true,
@@ -50,13 +50,13 @@ def _find(params = {}, base_scope = nil)
def build(params, base_scope = nil)
validate_request!(params)
runner = Runner.new(self, params)
- runner.proxy(base_scope, single: true, raise_on_missing: true)
+ runner.proxy(base_scope, single: true, raise_on_missing: true, assign_action: :create)
end
# Wrap models fetched outside graphiti so they render like any other proxy
def wrap(models, base_scope = nil)
validate_wrap_models!(models)
- runner = Runner.new(self, {}, nil, :find)
+ runner = Runner.new(self, {}, action: :find)
runner.proxy(base_scope, bypass_required_filters: true).tap do |proxy|
proxy.data = models
end
@@ -80,12 +80,12 @@ def caching_options
end
def validate_request!(params)
- return if Graphiti.context[:graphql] || !validate_endpoints?
+ return if Graphiti.context[:graphql] || !validate_requests?
if context&.respond_to?(:request)
path = context.request.env["PATH_INFO"]
- unless allow_request?(path, params, context_namespace)
- raise Errors::InvalidEndpoint.new(self, path, context_namespace)
+ unless allow_request?(path, params, current_action)
+ raise Errors::InvalidEndpoint.new(self, path, current_action)
end
end
end
diff --git a/lib/graphiti/resource/links.rb b/lib/graphiti/resource/links.rb
index f8221fa6..7754172b 100644
--- a/lib/graphiti/resource/links.rb
+++ b/lib/graphiti/resource/links.rb
@@ -25,12 +25,8 @@ def endpoint=(value)
class_attribute :endpoint,
:base_url,
:endpoint_namespace,
- :secondary_endpoints,
- :autolink,
- :validate_endpoints
+ :secondary_endpoints
self.secondary_endpoints = []
- self.autolink = true
- self.validate_endpoints = true
class << self
prepend Overrides
@@ -38,10 +34,40 @@ class << self
end
class_methods do
+ # Deprecated. Folded into relationship_links. Remove in 3.0.
+ def autolink
+ relationship_links != false
+ end
+
+ # true cannot express :on_demand, so it leaves an inherited on-demand mode alone.
+ def autolink=(val)
+ return if val && relationship_links == :on_demand
+
+ self.relationship_links = val ? true : false
+ end
+
+ def autolink?
+ autolink
+ end
+
+ # Deprecated. Split into validate_requests and validate_links. Remove in 3.0.
+ def validate_endpoints
+ validate_requests && validate_links
+ end
+
+ def validate_endpoints=(val)
+ self.validate_requests = val
+ self.validate_links = val
+ end
+
+ def validate_endpoints?
+ validate_endpoints
+ end
+
def infer_endpoint
return unless name
- path = "/#{name.gsub("Resource", "").pluralize.underscore}".to_sym
+ path = :"/#{name.gsub("Resource", "").pluralize.underscore}"
{
path: path,
full_path: full_path_for(path),
@@ -101,4 +127,12 @@ def url_for(path)
end
end
end
+
+ msg = "Use `self.relationship_links` (true, false, or :on_demand)"
+ endpoints_msg = "Use `self.validate_requests` for inbound requests, `self.validate_links` for rendered links"
+ DEPRECATOR.deprecate_methods(Links::ClassMethods,
+ autolink: msg, "autolink=": msg, autolink?: msg,
+ validate_endpoints: endpoints_msg,
+ "validate_endpoints=": endpoints_msg,
+ validate_endpoints?: endpoints_msg)
end
diff --git a/lib/graphiti/resource/persistence.rb b/lib/graphiti/resource/persistence.rb
index 39d3736d..48d0d22f 100644
--- a/lib/graphiti/resource/persistence.rb
+++ b/lib/graphiti/resource/persistence.rb
@@ -65,50 +65,73 @@ def around_destroy(method = nil, &blk)
def add_callback(kind, lifecycle, method, only, &blk)
config[:callbacks][kind] ||= {}
config[:callbacks][kind][lifecycle] ||= []
- config[:callbacks][kind][lifecycle] << {callback: (method || blk), only: Array(only)}
+ config[:callbacks][kind][lifecycle] << {callback: method || blk, only: Array(only)}
end
end
- def create(create_params, meta = nil)
- model_instance = nil
- snapshot = attributes_snapshot(:create, create_params)
-
- run_callbacks :persistence, :create, create_params, meta do
- warn_attributes_mutated_in_around_persistence(:create, snapshot, create_params)
+ # +model_instance+ is the already-built model from a previous #assign
+ # (see ResourceProxy#assign_attributes). When given, attributes are
+ # applied to it rather than to a freshly built/found model.
+ def assign(assign_params, meta = nil, action_name = nil, model_instance: nil)
+ # Only update strips :id (it identifies the record to find) - a create
+ # payload may legitimately carry a client-supplied id to assign.
+ if action_name == :update
+ id = assign_params[:id]
+ assign_params = assign_params.except(:id)
+ end
- run_callbacks :attributes, :create, create_params, meta do |params|
- model_instance = call_with_meta(:build, model, meta)
- call_with_meta(:assign_attributes, model_instance, params, meta)
- model_instance
+ run_callbacks :attributes, action_name, assign_params, meta do |params|
+ model_instance ||= if action_name == :update
+ self.class._find(id: id).data
+ else
+ call_with_meta(:build, model, meta)
end
+ call_with_meta(:assign_attributes, model_instance, params, meta)
+ model_instance
+ end
+
+ model_instance
+ end
+
+ # The model built by a prior ResourceProxy#assign_attributes, present
+ # for the duration of the save that persists it
+ attr_reader :assigned_model
+
+ # @api private
+ def with_assigned_model(model)
+ @assigned_model = model
+ yield
+ ensure
+ @assigned_model = nil
+ end
+ # Attributes are assigned before the persistence callbacks fire, so
+ # around_persistence receives the assigned model - its pre-yield
+ # position is the last chance to touch the model before save, inside
+ # the transaction. Modify attributes in before_attributes instead.
+ def create(create_params, meta = nil)
+ model_instance = assigned_model || assign(create_params, meta, :create)
+
+ run_callbacks :persistence, :create, model_instance, meta do
run_callbacks :save, :create, model_instance, meta do
model_instance = call_with_meta(:save, model_instance, meta)
end
model_instance
end
+
+ model_instance
end
def update(update_params, meta = nil)
- model_instance = nil
- id = update_params[:id]
- update_params = update_params.except(:id)
-
- snapshot = attributes_snapshot(:update, update_params)
-
- run_callbacks :persistence, :update, update_params, meta do
- warn_attributes_mutated_in_around_persistence(:update, snapshot, update_params)
-
- run_callbacks :attributes, :update, update_params, meta do |params|
- model_instance = self.class._find(id: id).data
- call_with_meta(:assign_attributes, model_instance, params, meta)
- model_instance
- end
+ model_instance = assigned_model || assign(update_params, meta, :update)
+ run_callbacks :persistence, :update, model_instance, meta do
run_callbacks :save, :update, model_instance, meta do
model_instance = call_with_meta(:save, model_instance, meta)
end
+
+ model_instance
end
model_instance
@@ -140,37 +163,6 @@ def delete(model_instance, meta = nil)
private
- # In Graphiti 2.0, attributes are assigned to the model before the
- # persistence hooks fire, so hash modifications made by an
- # around_persistence hook before its yield will no longer be applied.
- # Snapshot-and-compare detects exactly those hooks: the comparison
- # happens before any attributes-phase callback has run, so hooks that
- # only wrap their yield never trigger the warning.
- def attributes_snapshot(action, params)
- return unless around_persistence_hooks?(action)
- Util::Hash.deep_dup(params)
- end
-
- def around_persistence_hooks?(action)
- hooks = self.class.config[:callbacks][:persistence].try(:[], :around) || []
- hooks.any? { |hook| hook[:only].include?(action) }
- end
-
- def warn_attributes_mutated_in_around_persistence(action, snapshot, params)
- return if snapshot.nil? || snapshot == params
-
- changed_keys = (snapshot.keys | params.keys).reject { |key| snapshot[key] == params[key] }
- Graphiti::DEPRECATOR.warn(<<~MSG)
- #{self.class}'s around_persistence hook modified the attributes hash before yield (changed keys: #{changed_keys.map(&:inspect).join(", ")}). In Graphiti 2.0, attributes are assigned to the model before persistence hooks run, and these modifications will be silently ignored. Move the modification to a hook that runs before assignment:
-
- before_attributes do |attributes|
- attributes[#{changed_keys.first.inspect}] = ...
- end
-
- or set the value on the model itself in a before_save hook. See UPGRADING.md in the Graphiti 2.0 release.
- MSG
- end
-
def run_callbacks(kind, action, *args)
fire_around_callbacks(kind, action, *args) do |*yieldargs|
fire_callbacks(kind, :before, action, *yieldargs)
diff --git a/lib/graphiti/resource/polymorphism.rb b/lib/graphiti/resource/polymorphism.rb
index e5d73e63..b038f8d6 100644
--- a/lib/graphiti/resource/polymorphism.rb
+++ b/lib/graphiti/resource/polymorphism.rb
@@ -56,7 +56,7 @@ def sideload(name)
on_type, name = split_on[1].split("--").map(&:to_sym)
end
- sl = super(name)
+ sl = super
if !polymorphic_child? && sl.nil?
children.each do |c|
next if on_type && c.type != on_type
diff --git a/lib/graphiti/resource/remote.rb b/lib/graphiti/resource/remote.rb
index d4d81e29..c8c500d1 100644
--- a/lib/graphiti/resource/remote.rb
+++ b/lib/graphiti/resource/remote.rb
@@ -6,7 +6,8 @@ module Remote
included do
self.adapter = Graphiti::Adapters::GraphitiAPI
self.model = OpenStruct
- self.validate_endpoints = false
+ self.validate_requests = false
+ self.validate_links = false
class_attribute :timeout,
:open_timeout
@@ -38,8 +39,8 @@ def before_resolve(scope, query)
# Forward all headers
def request_headers
{}.tap do |headers|
- # TODO: Maybe handle this in graphiti-rails
- if defined?(Rails) && context
+ # TODO: Maybe handle this in the Rails integration
+ if defined?(::Rails) && context
raw = context.request.headers.to_h
if (auth = raw["HTTP_AUTHORIZATION"])
headers["Authorization"] = auth
diff --git a/lib/graphiti/resource/sideloading.rb b/lib/graphiti/resource/sideloading.rb
index 5398562b..47e11357 100644
--- a/lib/graphiti/resource/sideloading.rb
+++ b/lib/graphiti/resource/sideloading.rb
@@ -126,11 +126,11 @@ def association_types(memo = [])
end
# If eager loading, ensure routes are loaded first, then apply
- # This happens in Railtie
+ # This happens in Railtie. Setup runs once, so classes redefined by a reload apply at definition.
def eagerly_apply_sideload?(sideload)
- # TODO: Maybe handle this in graphiti-rails
+ # TODO: Maybe handle this in the Rails integration
if defined?(::Rails) && (app = ::Rails.application)
- app.config.eager_load ? false : true
+ app.config.eager_load ? Graphiti.setup? : true
else
sideload.resource_class_loaded?
end
diff --git a/lib/graphiti/resource_proxy.rb b/lib/graphiti/resource_proxy.rb
index f94be876..0a5effdb 100644
--- a/lib/graphiti/resource_proxy.rb
+++ b/lib/graphiti/resource_proxy.rb
@@ -11,17 +11,18 @@ def initialize(
payload: nil,
single: false,
raise_on_missing: false,
+ assign_action: nil,
cache: nil,
cache_expires_in: nil,
cache_tag: nil
)
-
@resource = resource
@scope = scope
@query = query
@payload = payload
@single = single
@raise_on_missing = raise_on_missing
+ @assign_action = assign_action
@cache = cache
@cache_expires_in = cache_expires_in
@cache_tag = cache_tag
@@ -87,7 +88,10 @@ def data=(models)
end
def data
- @data ||= begin
+ return @data unless @data.nil?
+ return assign_attributes(@payload.params) if @assign_action
+
+ @data = begin
records = @scope.resolve
raise Graphiti::Errors::RecordNotFound if records.empty? && raise_on_missing?
@@ -95,6 +99,7 @@ def data
records
end
end
+
alias_method :to_a, :data
alias_method :resolve_data, :data
@@ -137,26 +142,68 @@ def pagination
@pagination ||= Delegates::Pagination.new(self)
end
+ # Apply request params to the underlying model without saving it,
+ # Rails-style: the params are always passed explicitly, in the same
+ # request-params shape find/build accept. They are validated,
+ # deserialized, and become the payload #save will persist.
+ #
+ # Idempotent per params - calling again with params that normalize to
+ # the same payload is a no-op, so the attributes callbacks fire once.
+ # Different params re-assign onto the same model instance.
+ #
+ # Note the attributes callbacks fire here, outside any transaction
+ # opened during #save - the persistence hooks wrap only the save phase,
+ # receiving this assigned model.
+ def assign_attributes(params)
+ action = @assign_action || :update
+ params = normalized_params_copy(params)
+ add_endpoint_filter(params, action)
+ validator = ::Graphiti::RequestValidator.new(@resource, params, action)
+ with_context_action(action) { validator.validate! }
+
+ if @assigned_model && same_write_payload?(validator.deserialized_payload)
+ return @assigned_model
+ end
+
+ @payload = validator.deserialized_payload
+ @assigned_model = @data = @resource.assign_with_relationships(
+ @payload.meta(action: action),
+ @payload.attributes,
+ @payload.relationships,
+ model_instance: @assigned_model || (data if action == :update)
+ )
+ end
+
+ # TODO: remove this. Only used for persisting many-to-many with AR
+ # (see activerecord adapter)
+ def with_context_action(action)
+ original = Graphiti.context[:action]
+ Graphiti.context[:action] = action
+ yield
+ ensure
+ Graphiti.context[:action] = original
+ end
+
def save(action: :create)
- # TODO: remove this. Only used for persisting many-to-many with AR
- # (see activerecord adapter)
- original = Graphiti.context[:namespace]
- begin
- Graphiti.context[:namespace] = action
- ::Graphiti::RequestValidator.new(@resource, @payload.params, action).validate!
- validator = persist {
+ validator = with_context_action(action) do
+ # An assigned model can only come from #assign_attributes, which
+ # validated the payload it stored - re-validating here would run the
+ # writable guards (and their guard_model lookups) a redundant time.
+ unless @assigned_model
+ ::Graphiti::RequestValidator.new(@resource, @payload.params, action).validate!
+ end
+ persist {
@resource.persist_with_relationships \
@payload.meta(action: action),
@payload.attributes,
- @payload.relationships
+ @payload.relationships,
+ assigned_model: @assigned_model
}
- ensure
- Graphiti.context[:namespace] = original
end
@data, success = validator.to_a
if success
- # If the context namespace is `update` or `create`, certain
+ # If the context action is `update` or `create`, certain
# adapters will cause N+1 validation calls, so lets explicitly
# switch to a lookup context.
Graphiti.with_context(Graphiti.context[:object], :show) do
@@ -185,12 +232,16 @@ def destroy
success
end
- def update
+ # Rails-style: pass params to assign and save in one call, or call with
+ # no arguments to save a payload assigned earlier (via find or
+ # #assign_attributes).
+ def update(params = nil)
+ assign_attributes(params) if params
resolve_data
save(action: :update)
end
- alias update_attributes update # standard:disable Style/Alias
+ alias_method :update_attributes, :update
def include_hash
@include_hash ||= begin
@@ -247,6 +298,39 @@ def cache_key_with_version
private
+ # Validation typecasts values and injects ids into the params it is
+ # given - work on a deep copy so the caller's hash stays untouched.
+ def normalized_params_copy(params)
+ if params.respond_to?(:to_unsafe_h)
+ params.to_unsafe_h.deep_symbolize_keys
+ else
+ ::Graphiti::Util::Hash.deep_dup(params)
+ end
+ end
+
+ # UpdateValidator enforces that data.id matches the endpoint's filter id.
+ # Params that came through find already carry that filter; params passed
+ # directly to #assign_attributes usually don't, so merge in the id this
+ # proxy was found with. A payload whose data.id names a different record
+ # still fails validation with ConflictRequest. Mutates the copy made by
+ # #normalized_params_copy, never caller state.
+ def add_endpoint_filter(params, action)
+ return unless action == :update
+
+ endpoint_id = @query.filters[:id]
+ return if endpoint_id.nil? || params[:filter].try(:[], :id)
+
+ params[:filter] ||= {}
+ params[:filter][:id] = endpoint_id
+ end
+
+ # Compare only the write payload (data + included) - a repeat call whose
+ # params differ in read-side keys like sort or page is still a no-op.
+ def same_write_payload?(deserialized_payload)
+ deserialized_payload.params.values_at(:data, :included) ==
+ @payload.params.values_at(:data, :included)
+ end
+
def persist
transaction_response = @resource.transaction do
::Graphiti::Util::TransactionHooksRecorder.record do
diff --git a/lib/graphiti/responders.rb b/lib/graphiti/responders.rb
index 2025e5e0..e7e6789f 100644
--- a/lib/graphiti/responders.rb
+++ b/lib/graphiti/responders.rb
@@ -1,21 +1,8 @@
-# If you're using Rails + responders gem to get respond_with
-module Graphiti
- module Responders
- extend ActiveSupport::Concern
+# Deprecated entry point. graphiti required this path itself in 1.x, so an
+# explicit require left behind would otherwise raise LoadError. Remove in 3.0.
+require "graphiti/rails"
- included do
- backtrace = ::Rails::VERSION::MAJOR == 4 ? caller(2) : caller_locations(2)
- DEPRECATOR.deprecation_warning("Including Graphiti::Responders", "Use graphiti-rails instead. See https://www.graphiti.dev/guides/graphiti-rails-migration for details.", backtrace)
- include ActionController::MimeResponds
- respond_to(*Graphiti.config.respond_to)
- end
-
- # Override to avoid location url generation (for now)
- def respond_with(*args, &blk)
- opts = args.extract_options!
- opts[:location] = nil
- args << opts
- super(*args, &blk)
- end
- end
-end
+Graphiti::DEPRECATOR.warn(
+ 'require "graphiti/responders" is deprecated. Use "graphiti/rails" and include ' \
+ "Graphiti::Rails::Responders."
+)
diff --git a/lib/graphiti/runner.rb b/lib/graphiti/runner.rb
index bce0c26b..8bad38ea 100644
--- a/lib/graphiti/runner.rb
+++ b/lib/graphiti/runner.rb
@@ -3,12 +3,17 @@ class Runner
attr_reader :params
attr_reader :deserialized_payload
- def initialize(resource_class, params, query = nil, action = nil)
+ def initialize(resource_class, params, *positional, query: nil, action: nil)
+ if positional.any?
+ Graphiti::DEPRECATOR.warn("Passing query/action to Runner.new positionally is deprecated. Use query:/action: keywords.")
+ query ||= positional[0]
+ action ||= positional[1]
+ end
+
@resource_class = resource_class
@params = params
@query = query
@action = action
-
validator = RequestValidator.new(jsonapi_resource, params, action)
validator.validate!
@@ -30,7 +35,7 @@ def jsonapi_context
end
def query
- @query ||= Query.new(jsonapi_resource, params, nil, nil, [], @action)
+ @query ||= Query.new(jsonapi_resource, params, action: @action)
end
def query_hash
@@ -77,6 +82,7 @@ def proxy(base = nil, opts = {})
payload: deserialized_payload,
single: opts[:single],
raise_on_missing: opts[:raise_on_missing],
+ assign_action: opts[:assign_action],
cache: opts[:cache],
cache_expires_in: opts[:cache_expires_in],
cache_tag: opts[:cache_tag]
diff --git a/lib/graphiti/schema.rb b/lib/graphiti/schema.rb
index 70330f6c..305e3175 100644
--- a/lib/graphiti/schema.rb
+++ b/lib/graphiti/schema.rb
@@ -3,7 +3,7 @@ class Schema
attr_reader :resources
def self.generate(resources = nil)
- # TODO: Maybe handle this in graphiti-rails
+ # TODO: Maybe handle this in the Rails integration
::Rails.application.eager_load! if defined?(::Rails)
resources ||= Graphiti.resources.reject(&:abstract_class?)
resources.reject! { |r| r.name.nil? }
@@ -11,19 +11,18 @@ def self.generate(resources = nil)
new(resources).generate
end
- def self.generate!(resources = nil)
- schema = generate(resources)
+ def self.generate!(resources = nil, path: Graphiti.config.schema_path, force: ENV["FORCE_SCHEMA"] == "true")
+ result = check(resources, path: path)
+ return result.errors if !force && !result.compatible?
- if ENV["FORCE_SCHEMA"] != "true" && File.exist?(Graphiti.config.schema_path)
- old = JSON.parse(File.read(Graphiti.config.schema_path))
- errors = Graphiti::SchemaDiff.new(old, schema).compare
- return errors if errors.any?
- end
- FileUtils.mkdir_p(Graphiti.config.schema_path.to_s.gsub("/schema.json", ""))
- File.write(Graphiti.config.schema_path, JSON.pretty_generate(schema))
+ result.write!
[]
end
+ def self.check(resources = nil, path: Graphiti.config.schema_path)
+ Check.new(generate(resources), path)
+ end
+
def initialize(resources)
@resources = resources.sort_by(&:name)
@remote_resources = @resources.select(&:remote?)
@@ -111,8 +110,8 @@ def generate_resources
config[:default_sort] = default_sort
end
- if r.default_page_size
- config[:default_page_size] = r.default_page_size
+ if r.page_default_size
+ config[:default_page_size] = r.page_default_size
end
if r.polymorphic? && !r.polymorphic_child?
@@ -251,6 +250,10 @@ def relationships(resource)
schema[:guard] = true
end
+ if config.render_resource_ids?
+ schema[:linkage] = true
+ end
+
r[name] = schema
end
end
diff --git a/lib/graphiti/schema/check.rb b/lib/graphiti/schema/check.rb
new file mode 100644
index 00000000..933da45d
--- /dev/null
+++ b/lib/graphiti/schema/check.rb
@@ -0,0 +1,69 @@
+module Graphiti
+ class Schema
+ class Check
+ attr_reader :path, :errors
+
+ def initialize(schema, path)
+ @path = path
+ @schema = schema
+ @generated = normalize(schema)
+ @committed = normalize(JSON.parse(File.read(path))) if File.exist?(path)
+ @errors = @committed ? SchemaDiff.new(@committed, @generated).compare : []
+ end
+
+ def missing?
+ @committed.nil?
+ end
+
+ def stale?
+ !missing? && @committed != @generated
+ end
+
+ def compatible?
+ errors.empty?
+ end
+
+ def ok?
+ !missing? && !stale? && compatible?
+ end
+
+ def write!
+ FileUtils.mkdir_p(File.dirname(path))
+ File.write(path, JSON.pretty_generate(@schema))
+ path
+ end
+
+ def message
+ return "Schema is up to date: #{path}" if ok?
+ return "#{missing_message}\n\n#{regenerate}" if missing?
+ return "#{incompatible_message}\n\n#{errors.join("\n")}" unless compatible?
+
+ "Schema file is outdated: #{path}\n\n#{regenerate}"
+ end
+
+ private
+
+ def normalize(schema)
+ JSON.parse(JSON.generate(schema))
+ end
+
+ def missing_message
+ "Schema file not found: #{path}"
+ end
+
+ def incompatible_message
+ <<~MSG.chomp
+ Found backwards-incompatibilities in schema: #{path}
+
+ Re-run with FORCE_SCHEMA=true to accept them and overwrite the file.
+
+ Incompatibilities:
+ MSG
+ end
+
+ def regenerate
+ "Run `rake graphiti:schema:generate` and commit the file."
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/schema_diff.rb b/lib/graphiti/schema_diff.rb
index 525b619a..fa12df0a 100644
--- a/lib/graphiti/schema_diff.rb
+++ b/lib/graphiti/schema_diff.rb
@@ -106,6 +106,10 @@ def compare_relationships(old_resource, new_resource)
@errors << "#{old_resource[:name]}: relationship #{name.inspect} became guarded."
end
+ if old_rel[:linkage] && !new_rel[:linkage]
+ @errors << "#{old_resource[:name]}: relationship #{name.inspect} no longer includes resource linkage."
+ end
+
if new_rel[:resource] != old_rel[:resource]
@errors << "#{old_resource[:name]}: relationship #{name.inspect} changed resource from #{old_rel[:resource]} to #{new_rel[:resource]}."
end
diff --git a/lib/graphiti/scope.rb b/lib/graphiti/scope.rb
index 65a98f81..b104252a 100644
--- a/lib/graphiti/scope.rb
+++ b/lib/graphiti/scope.rb
@@ -39,18 +39,17 @@ def self.resolve_synchronously?
!Graphiti.config.concurrency || on_pool_thread?
end
- # TODO: move to Fiber[] once the floor is Ruby 3.2
def self.on_pool_thread?
- Thread.current[POOL_THREAD] == true
+ Fiber[POOL_THREAD] == true
end
# Restores rather than clears because :caller_runs may have run the task on a request thread.
def self.marking_pool_thread
- previous = Thread.current[POOL_THREAD]
- Thread.current[POOL_THREAD] = true
+ previous = Fiber[POOL_THREAD]
+ Fiber[POOL_THREAD] = true
yield
ensure
- Thread.current[POOL_THREAD] = previous
+ Fiber[POOL_THREAD] = previous
end
def initialize(object, resource, query, opts = {})
@@ -59,18 +58,18 @@ def initialize(object, resource, query, opts = {})
@query = query
@opts = opts
+ @resolved_sideload_proxies = {}
+ @resolved_sideload_proxies_lock = Mutex.new
+
@object = @resource.around_scoping(@object, @query.hash) { |scope|
apply_scoping(scope, opts)
}
end
def resolve(&blk)
- # When concurrency is disabled, take a synchronous path that mirrors the
- # pre-1.8 semantics. This avoids allocating Concurrent::Promises futures,
- # Thread/Fiber storage snapshots, and Rails executor wrappers on every
- # request purely to drive a thread pool that is intentionally synchronous.
+ # The caller blocks on .value! either way, so concurrency only benefits parallel sideloads
# See https://github.com/graphiti-api/graphiti/issues/505
- if self.class.resolve_synchronously?
+ if self.class.resolve_synchronously? || !overlapping_sideloads?
sync_resolve(&blk)
else
future_resolve(&blk).value!
@@ -83,6 +82,9 @@ def resolve_sideloads(results)
else
future_resolve_sideloads(results).value!
end
+
+ # Never return the sideloads hash, a caller mutating it would mess up the cache key
+ nil
end
def future_resolve(&blk)
@@ -151,17 +153,18 @@ def sync_resolve(&blk)
def sync_resolve_sideloads(results)
return if results == []
- each_applicable_sideload do |sideload, sideload_query|
+ reset_captured_sideload_proxies
+ each_applicable_sideload do |name, sideload, sideload_query|
Graphiti.config.before_sideload&.call(Graphiti.context)
- sideload.resolve(results, sideload_query, @resource)
+ sideload.sync_resolve(results, sideload_query, @resource) do |proxy|
+ capture_sideload_proxy(name, proxy)
+ end
end
-
- # resolve_sideloads is public, and without this it would return @query.sideloads itself,
- # where a delete would silently drop that sideload from the cache key.
- nil
end
- # Runs inline on the calling thread in both the sync and future paths.
+ # Resolve this scope's own data: run hooks, resolve the resource, and
+ # decorate the results. Shared by the sync and future paths — everything
+ # here runs inline on the calling thread in both modes.
def resolve_primary_data
resolved = broadcast_data { |payload|
@object = @resource.before_resolve(@object, @query)
@@ -169,10 +172,53 @@ def resolve_primary_data
payload[:results]
}
resolved.compact!
+ deduplicate_entities!(resolved)
assign_serializer(resolved)
yield resolved if block_given?
@opts[:after_resolve]&.call(resolved)
- resolved
+ @resolved_records = resolved
+ end
+
+ # Must run before sideloads assign, so every include path populates the
+ # canonical instance. The resource class in the key keeps two resources
+ # serving the same model from sharing an instance and a serializer.
+ def deduplicate_entities!(resolved)
+ return unless deduplicable?
+
+ # Nested maps are built once, a composite key meant a new array for every record
+ by_model = @query.entity_map.compute_if_absent(@resource.class) { Concurrent::Map.new }
+
+ resolved.map! do |record|
+ next record unless record.respond_to?(:id) && !record.id.nil?
+
+ by_id = by_model.compute_if_absent(record.class) { Concurrent::Map.new }
+ by_id.compute_if_absent(record.id) { record }
+ end
+ end
+
+ # A customized sideload can load a record another path would not, so it keeps its own instances.
+ def deduplicable?
+ return false unless @query.repeated_resource_classes.include?(@resource.class)
+
+ sideload = @opts[:sideload]
+ return true unless sideload
+
+ sideload.scope_proc.nil? &&
+ sideload.params_proc.nil? &&
+ !sideload.customized_base_scope? &&
+ sideload.primary_key == :id
+ end
+
+ # One sideload has nothing to run beside it, so the pool would hand the work
+ # to another thread and wait for it. A chain is one at every level. A
+ # polymorphic sideload counts as its children, which do run beside each other.
+ def overlapping_sideloads?
+ found = 0
+ each_applicable_sideload do |_, sideload, _|
+ found += sideload.respond_to?(:children) ? sideload.children.size : 1
+ return true if found > 1
+ end
+ false
end
def each_applicable_sideload
@@ -180,22 +226,40 @@ def each_applicable_sideload
sideload = @resource.class.sideload(name)
next if sideload.nil? || sideload.shared_remote?
- yield sideload, sideload_query
+ yield name, sideload, sideload_query
+ end
+ end
+
+ # The write path resolves sideloads twice on one scope, which would double every proxy in the cache key.
+ def reset_captured_sideload_proxies
+ @resolved_sideload_proxies_lock.synchronize { @resolved_sideload_proxies.clear }
+ end
+
+ # A proxy built while resolving is already resolved all the way down.
+ def capture_sideload_proxy(name, proxy)
+ @resolved_sideload_proxies_lock.synchronize do
+ captured = (@resolved_sideload_proxies[name] ||= [])
+ captured << proxy unless proxy.nil? || proxy == []
end
end
def future_resolve_sideloads(results)
return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == []
+ reset_captured_sideload_proxies
sideload_promises = []
- each_applicable_sideload do |sideload, sideload_query|
+ each_applicable_sideload do |name, sideload, sideload_query|
promise = future_with_context(results, sideload_query, @resource) do |parent_results, future_query, parent_resource|
Graphiti.config.before_sideload&.call(Graphiti.context)
- sideload.future_resolve(parent_results, future_query, parent_resource)
+ sideload.future_resolve(parent_results, future_query, parent_resource) do |proxy|
+ capture_sideload_proxy(name, proxy)
+ end
end
sideload_promises << promise.flat
end
+ return sideload_promises.first if sideload_promises.one?
+
Concurrent::Promises.zip_futures_on(self.class.global_thread_pool_executor, *sideload_promises)
.rescue_on(self.class.global_thread_pool_executor) do |*reasons|
first_error = reasons.find { |r| r.is_a?(Exception) }
@@ -217,15 +281,21 @@ def future_with_context(*args)
end
end
+ current_attributes = current_attributes_snapshot
+
Concurrent::Promises.future_on(
- self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, *args
- ) do |thread_id, thread_storage, fiber_storage, *args|
+ self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, current_attributes, *args
+ ) do |thread_id, thread_storage, fiber_storage, current_attributes, *args|
self.class.marking_pool_thread do
wrap_in_rails_executor do
with_thread_locals(thread_storage) do
with_fiber_locals(fiber_storage) do
- Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
- yield(*args)
+ with_current_attributes(current_attributes) do
+ with_connection_pool_hint do
+ Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
+ yield(*args)
+ end
+ end
end
end
end
@@ -234,6 +304,45 @@ def future_with_context(*args)
end
end
+ def current_attributes_snapshot
+ return unless defined?(ActiveSupport::CurrentAttributes)
+
+ snapshot = {}
+ klasses = ActiveSupport::CurrentAttributes.subclasses
+ while (klass = klasses.shift)
+ klasses.concat(klass.subclasses)
+ attributes = klass.attributes
+ snapshot[klass] = attributes.dup if attributes.any?
+ end
+ snapshot
+ end
+
+ # Restored inside the Rails executor, whose entry hands the pool thread a
+ # fresh Current. Restoring through set scopes the values to the block.
+ def with_current_attributes(snapshot, &block)
+ return yield if snapshot.nil? || snapshot.empty?
+
+ klass, attributes = snapshot.first
+ rest = snapshot.except(klass)
+ klass.set(attributes) { with_current_attributes(rest, &block) }
+ end
+
+ # A timeout inside a sideload task nearly always means database.yml's pool
+ # was not sized for the sideload threads, and the bare error does not say so.
+ def with_connection_pool_hint
+ yield
+ rescue => error
+ raise unless defined?(ActiveRecord::ConnectionTimeoutError) && error.is_a?(ActiveRecord::ConnectionTimeoutError)
+
+ hinted = error.exception(<<~MSG.strip)
+ #{error.message}
+
+ Raised while resolving a sideload concurrently. Each concurrent sideload holds its own database connection, so `pool` in database.yml must be at least web threads + concurrency_max_threads (#{Graphiti.config.concurrency_max_threads}) + 1. See graphiti.dev/concepts/resources#concurrency-pool-sizing.
+ MSG
+ hinted.set_backtrace(error.backtrace)
+ raise hinted
+ end
+
def with_thread_locals(thread_locals)
new_thread_locals = []
thread_locals.each do |key, value|
@@ -276,15 +385,19 @@ def wrap_in_rails_executor(&block)
def sideload_resource_proxies
@sideload_resource_proxies ||= begin
- @object = @resource.before_resolve(@object, @query)
- results = @resource.resolve(@object)
+ # Reached after the response resolved, where resolving again re-applies before_resolve to a mutated scope.
+ results = @resolved_records
+ if results.nil?
+ @object = @resource.before_resolve(@object, @query)
+ results = @resource.resolve(@object)
+ end
[].tap do |proxies|
- unless @query.sideloads.empty?
- @query.sideloads.each_pair do |name, q|
- sideload = @resource.class.sideload(name)
- next if sideload.nil? || sideload.shared_remote?
-
+ each_applicable_sideload do |name, sideload, q|
+ captured = @resolved_sideload_proxies[name]
+ if captured
+ proxies.concat(captured)
+ else
proxies << sideload.build_resource_proxy(results, q, parent_resource)
end
end
diff --git a/lib/graphiti/scoping/filter.rb b/lib/graphiti/scoping/filter.rb
index d86f897b..bb7bead1 100644
--- a/lib/graphiti/scoping/filter.rb
+++ b/lib/graphiti/scoping/filter.rb
@@ -62,7 +62,7 @@ def each_filter
value = parse_string_value(filter.values[0], value)
end
- check_deny_empty_filters!(resource, filter, value)
+ 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)
@@ -82,7 +82,7 @@ def coerce_types(filter, name, value)
if is_array
@resource.typecast(name, value, :filterable)
else
- value = value.nil? || value.is_a?(Hash) ? [value] : Array(value)
+ value = (value.nil? || value.is_a?(Hash)) ? [value] : Array(value)
value.map { |v| @resource.typecast(name, v, :filterable) }
end
end
@@ -100,7 +100,7 @@ def normalize_param(filter, param_value)
param_value.map do |operator, value|
if type == :hash
- value, operator = \
+ value, operator =
parse_hash_value(filter, param_value, value, operator)
end
@@ -209,15 +209,15 @@ def parse_string_arrays(value, singular_filter)
end
def parse_string_null(filter, value)
- return value unless filter[:allow_nil]
- return value.map { |item| item == "null" ? nil : item } if value.is_a?(Array)
+ return value unless filter[:blanks] == :null
+ return value.map { |item| (item == "null") ? nil : item } if value.is_a?(Array)
return if value == "null"
value
end
- def check_deny_empty_filters!(resource, filter, value)
- return unless filter.values[0][:deny_empty]
+ def check_blank_filters!(resource, filter, value)
+ return unless filter.values[0][:blanks] == :rejected
if value.nil? || value.empty? || value == "null"
raise Errors::InvalidFilterValue.new(resource, filter, "(empty)")
diff --git a/lib/graphiti/scoping/paginate.rb b/lib/graphiti/scoping/paginate.rb
index a0c2eab0..c378e8ad 100644
--- a/lib/graphiti/scoping/paginate.rb
+++ b/lib/graphiti/scoping/paginate.rb
@@ -4,9 +4,9 @@ class Scoping::Paginate < Scoping::Base
PARAMS = [:number, :size, :offset, :before, :after]
def apply
- if size > resource.max_page_size
+ if size > resource.page_max_size
raise Graphiti::Errors::UnsupportedPageSize
- .new(size, resource.max_page_size)
+ .new(size, resource.page_max_size)
elsif requested? && @opts[:sideload_parent_length].to_i > 1
raise Graphiti::Errors::UnsupportedPagination
else
@@ -61,7 +61,7 @@ def requested?
end
def page_param
- @page_param ||= (query_hash[:page] || {})
+ @page_param ||= query_hash[:page] || {}
end
def offset
@@ -100,7 +100,7 @@ def number
end
def size
- (page_param[:size] || resource.default_page_size || DEFAULT_PAGE_SIZE).to_i
+ (page_param[:size] || resource.page_default_size || DEFAULT_PAGE_SIZE).to_i
end
end
end
diff --git a/lib/graphiti/scoping/sort.rb b/lib/graphiti/scoping/sort.rb
index 61e4c10d..7eb98364 100644
--- a/lib/graphiti/scoping/sort.rb
+++ b/lib/graphiti/scoping/sort.rb
@@ -75,7 +75,7 @@ def normalize(sort)
end
def sort_hash(attr)
- value = attr[0] == "-" ? :desc : :asc
+ value = (attr[0] == "-") ? :desc : :asc
key = attr.sub("-", "").to_sym
{key => value}
diff --git a/lib/graphiti/serializer.rb b/lib/graphiti/serializer.rb
index de8d89ec..9611579c 100644
--- a/lib/graphiti/serializer.rb
+++ b/lib/graphiti/serializer.rb
@@ -13,10 +13,26 @@ class Serializer < JSONAPI::Serializable::Resource
class_attribute :attributes_applied_via_resource
class_attribute :extra_attributes_applied_via_resource
class_attribute :relationship_condition_blocks
+ # Which sideload each generated relationship block was built for. A
+ # subclass inherits its parent's blocks, so this is how a redeclared
+ # relationship is told apart from one already applied, and from one the
+ # application wrote by hand.
+ class_attribute :relationship_sideloads
self.attributes_applied_via_resource = []
self.extra_attributes_applied_via_resource = []
# See #requested_relationships
self.relationship_condition_blocks ||= {}
+ self.relationship_sideloads ||= {}
+
+ # Keyed on the sideloads hash itself, which is reassigned whenever a
+ # relationship is applied, so the answer is recomputed exactly then.
+ def self.on_demand_links?
+ sideloads = relationship_sideloads
+ return @on_demand_links.last if @on_demand_links&.first.equal?(sideloads)
+
+ @on_demand_links = [sideloads, sideloads.each_value.any? { |sideload| sideload.link_mode == :on_demand }]
+ @on_demand_links.last
+ end
def self.inherited(klass)
super
@@ -53,7 +69,7 @@ def cursor
starting_offset = 0
page_param = @proxy.query.pagination
if (page_number = page_param[:number])
- page_size = page_param[:size] || @resource.default_page_size
+ page_size = page_param[:size] || @resource.page_default_size
starting_offset = (page_number - 1) * page_size
end
@@ -68,7 +84,7 @@ def cursor
def as_jsonapi(kwargs = {})
super(**kwargs).tap do |hash|
- strip_relationships!(hash) if strip_relationships?
+ strip_relationships!(hash)
add_links!(hash)
end
end
@@ -94,16 +110,29 @@ def add_links!(hash)
hash[:links] = @resource.links(@object) if @resource.links?
end
+ # The meta: {included: false} stub is jsonapi-serializable's filler, not JSON:API.
def strip_relationships!(hash)
- hash[:relationships]&.select! do |name, payload|
- payload.key?(:data)
+ placeholders = relationship_placeholders?
+ return if placeholders && !strip_on_demand_relationships?
+
+ hash[:relationships]&.reject! do |name, payload|
+ next false if payload.key?(:data) || payload.key?(:links)
+ next true unless placeholders
+
+ self.class.relationship_sideloads[name]&.link_mode == :on_demand
end
end
- def strip_relationships?
- return false unless Graphiti.config.links_on_demand
- params = Graphiti.context[:object]&.params || {}
- [false, nil, "false"].include?(params[:links])
+ # A remote resource exposes a stand-in object rather than a Resource.
+ def relationship_placeholders?
+ resource_class = @resource.class
+ return Resource.relationship_placeholders unless resource_class.respond_to?(:relationship_placeholders)
+
+ resource_class.relationship_placeholders
+ end
+
+ def strip_on_demand_relationships?
+ self.class.on_demand_links? && !@proxy&.query&.render_link?(:on_demand)
end
end
end
diff --git a/lib/graphiti/sideload.rb b/lib/graphiti/sideload.rb
index c1beb73f..ab3d9d6b 100644
--- a/lib/graphiti/sideload.rb
+++ b/lib/graphiti/sideload.rb
@@ -20,6 +20,7 @@ class Sideload
def initialize(name, opts)
@name = name
validate_options!(opts)
+ translate_deprecated_options!(opts)
@parent_resource_class = opts[:parent_resource]
@resource_class_name = opts[:resource]
@primary_key = opts[:primary_key]
@@ -30,6 +31,9 @@ def initialize(name, opts)
@writable = opts[:writable]
@as = opts[:as]
@link = opts[:link]
+ unless @link.nil? || Resource::LINK_MODES.include?(@link)
+ raise Errors::InvalidLinkRendering.new(@parent_resource_class, :"#{name} link", @link)
+ end
@single = opts[:single]
@remote = opts[:remote]
apply_belongs_to_many_filter if type == :many_to_many
@@ -42,7 +46,7 @@ def initialize(name, opts)
@group_name = opts[:group_name]
@polymorphic_child = opts[:polymorphic_child]
@parent = opts[:parent]
- @always_include_resource_ids = opts[:always_include_resource_ids]
+ @render_resource_ids = opts[:resource_ids]
if polymorphic_child?
parent.resource.polymorphic << resource_class
@@ -57,6 +61,28 @@ def self.scope(&blk)
self.scope_proc = blk
end
+ ASSIGNING_QUERY = :__graphiti_assigning_query
+ private_constant :ASSIGNING_QUERY
+
+ def self.assigning_node(query)
+ return yield if query.nil?
+
+ previous = Fiber[ASSIGNING_QUERY]
+ Fiber[ASSIGNING_QUERY] = query
+ yield
+ ensure
+ Fiber[ASSIGNING_QUERY] = previous unless query.nil?
+ end
+
+ def self.current_assigning_query
+ Fiber[ASSIGNING_QUERY]
+ end
+
+ def self.assigned?(query, record, association_name)
+ owners = query.association_owners[association_name]
+ !owners.nil? && owners.key?(record.object_id)
+ end
+
def self.assign(&blk)
self.assign_proc = blk
end
@@ -83,7 +109,8 @@ def create_remote_resource
self.adapter = Graphiti::Adapters::GraphitiAPI
self.model = OpenStruct
self.remote = remote_url
- self.validate_endpoints = false
+ self.validate_requests = false
+ self.validate_links = false
}
name = "#{parent_resource_class.name}.#{@name}.remote"
klass.class_eval("def self.name;'#{name}';end", __FILE__, __LINE__)
@@ -110,6 +137,31 @@ def guarded?
dynamic_flag?(@readable) || dynamic_flag?(@writable)
end
+ def readable_guarded?
+ dynamic_flag?(@readable)
+ end
+
+ def readable_guard_name
+ @readable.to_sym if @readable.is_a?(Symbol) || @readable.is_a?(String)
+ end
+
+ def non_default_options
+ options = {}
+ options[:as] = association_name if @as
+ options[:primary_key] = primary_key unless primary_key == :id
+ options[:single] = true if single?
+ options[:remote] = @remote if remote?
+ options[:link] = @link unless @link.nil?
+ options[:readable] = @readable unless @readable.nil? || @readable == true
+ options[:writable] = @writable unless @writable.nil? || @writable == true
+ options[:resource_ids] = @render_resource_ids unless @render_resource_ids.nil?
+ options
+ end
+
+ def customized_base_scope?
+ !!@base_scope
+ end
+
def single?
!!@single
end
@@ -122,18 +174,38 @@ def polymorphic_has_many?
!!@polymorphic_as
end
- def always_include_resource_ids?
- !!@always_include_resource_ids
+ # Every check behind the blocker is static sideload configuration, and this
+ # is asked once per rendered record per relationship.
+ def resource_ids_from_foreign_key?
+ return @resource_ids_from_foreign_key unless @resource_ids_from_foreign_key.nil?
+
+ @resource_ids_from_foreign_key = resource_ids_blocker.nil?
end
- def link?
- return true if link_proc
+ def resource_ids_blocker
+ :no_foreign_key_on_parent
+ end
- if @link.nil?
- !!@parent_resource_class.autolink
- else
- !!@link
- end
+ def render_resource_ids?
+ return !!@render_resource_ids unless @render_resource_ids.nil?
+
+ default_render_resource_ids?
+ end
+
+ def default_render_resource_ids?
+ false
+ end
+
+ # 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?
+
+ default = @parent_resource_class.relationship_links
+ (link_proc && default == false) ? true : default
+ end
+
+ def link?
+ link_mode != false
end
def link_filter(parents)
@@ -239,14 +311,20 @@ def build_resource_proxy(parents, query, graph_parent)
proxy
end
- def load(parents, query, graph_parent)
+ def load(parents, query, graph_parent, &proxy_block)
if Scope.resolve_synchronously?
- build_resource_proxy(parents, query, graph_parent).to_a
+ sync_load(parents, query, graph_parent, &proxy_block)
else
- future_load(parents, query, graph_parent).value!
+ future_load(parents, query, graph_parent, &proxy_block).value!
end
end
+ def sync_load(parents, query, graph_parent, &proxy_block)
+ proxy = build_resource_proxy(parents, query, graph_parent)
+ proxy_block&.call(proxy)
+ proxy.to_a
+ end
+
# Override in subclass
def infer_foreign_key
model = parent_resource_class.model
@@ -294,23 +372,38 @@ def assign(parents, children)
children.replace(associated) if track_associated
end
- def resolve(parents, query, graph_parent)
+ def resolve(parents, query, graph_parent, &proxy_block)
if Scope.resolve_synchronously?
- sync_resolve(parents, query, graph_parent)
+ sync_resolve(parents, query, graph_parent, &proxy_block)
+ else
+ future_resolve(parents, query, graph_parent, &proxy_block).value!
+ end
+ end
+
+ # Called by a scope that already decided to stay inline, so it must not consult the pool again.
+ # A scope_proc builds a Scope rather than a proxy, and that nested scope decides for itself.
+ def sync_resolve(parents, query, graph_parent, &proxy_block)
+ assert_singular!(parents)
+
+ if self.class.scope_proc
+ build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
+ fire_assign(parents, sideload_results, query)
+ end
else
- future_resolve(parents, query, graph_parent).value!
+ sync_load(parents, query, graph_parent, &proxy_block)
end
end
- def future_resolve(parents, query, graph_parent)
+ # A scope_proc builds a Scope rather than a proxy, and a Scope's cache key omits what a proxy's carries.
+ def future_resolve(parents, query, graph_parent, &proxy_block)
assert_singular!(parents)
if self.class.scope_proc
build_sideload_scope(parents, query, graph_parent).future_resolve do |sideload_results|
- fire_assign(parents, sideload_results)
+ fire_assign(parents, sideload_results, query)
end
else
- future_load(parents, query, graph_parent)
+ future_load(parents, query, graph_parent, &proxy_block)
end
end
@@ -342,10 +435,14 @@ def fire_hooks!(parent, objects, method)
end
def associate_all(parent, children)
+ return unless claim_association(parent)
+
parent_resource.associate_all(parent, children, association_name, type)
end
def associate(parent, child)
+ return unless claim_association(parent)
+
parent_resource.associate(parent, child, association_name, type)
end
@@ -374,18 +471,6 @@ def resource_class_loaded?
private
- def sync_resolve(parents, query, graph_parent)
- assert_singular!(parents)
-
- if self.class.scope_proc
- build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
- fire_assign(parents, sideload_results)
- end
- else
- load(parents, query, graph_parent)
- end
- end
-
def assert_singular!(parents)
if single? && parents.length > 1
raise Errors::SingularSideload.new(self, parents.length)
@@ -402,8 +487,9 @@ def build_sideload_scope(parents, query, graph_parent)
default_paginate: false
end
- def future_load(parents, query, graph_parent)
+ def future_load(parents, query, graph_parent, &proxy_block)
proxy = build_resource_proxy(parents, query, graph_parent)
+ proxy_block&.call(proxy)
proxy.respond_to?(:future_resolve_data) ? proxy.future_resolve_data : Concurrent::Promises.fulfilled_future(proxy)
end
@@ -416,6 +502,18 @@ def blank_query?(params)
false
end
+ def translate_deprecated_options!(opts)
+ return unless opts.key?(:always_include_resource_ids)
+
+ Graphiti::DEPRECATOR.deprecation_warning(
+ :always_include_resource_ids,
+ "Use :resource_ids instead (#{opts[:parent_resource]&.name}##{@name})"
+ )
+
+ value = opts.delete(:always_include_resource_ids)
+ opts[:resource_ids] = value unless opts.key?(:resource_ids)
+ end
+
def validate_options!(opts)
if opts[:remote]
if opts[:resource]
@@ -434,7 +532,7 @@ def load_options(parents, query)
opts[:sideload_parent_length] = parents.length
opts[:query] = query
opts[:after_resolve] = ->(results) {
- fire_assign(parents, results)
+ fire_assign(parents, results, query)
}
end
end
@@ -447,16 +545,28 @@ def fire_assign_each(parent, children)
end
end
- def fire_assign(parents, children)
- with_error_handling Errors::SideloadAssignError do
- if self.class.assign_proc
- instance_exec(parents, children, &self.class.assign_proc)
- else
- assign(parents, children)
+ def fire_assign(parents, children, query = nil)
+ self.class.assigning_node(query) do
+ with_error_handling Errors::SideloadAssignError do
+ if self.class.assign_proc
+ instance_exec(parents, children, &self.class.assign_proc)
+ else
+ assign(parents, children)
+ end
end
end
end
+ # A record can be shared by two nodes of the include tree, and a node that narrows
+ # differently returns different rows, so the first node to populate an association owns it.
+ def claim_association(parent)
+ query = self.class.current_assigning_query
+ return true if query.nil?
+
+ owners = query.association_owners.compute_if_absent(association_name) { Concurrent::Map.new }
+ owners.compute_if_absent(parent.object_id) { query.hash } == query.hash
+ end
+
def with_error_handling(error_class)
begin
result = yield
diff --git a/lib/graphiti/sideload/belongs_to.rb b/lib/graphiti/sideload/belongs_to.rb
index 8eb4e458..5332f7e5 100644
--- a/lib/graphiti/sideload/belongs_to.rb
+++ b/lib/graphiti/sideload/belongs_to.rb
@@ -1,13 +1,39 @@
class Graphiti::Sideload::BelongsTo < Graphiti::Sideload
- def initialize(name, opts)
- opts = {always_include_resource_ids: false}.merge(opts)
- super(name, opts)
- end
-
def type
:belongs_to
end
+ def default_render_resource_ids?
+ case parent_resource_class&.belongs_to_resource_ids_by_default
+ when :always then renderable_at_all?
+ when :never then false
+ else resource_ids_from_foreign_key?
+ end
+ end
+
+ def renderable_at_all?
+ readable_guarded? || readable?
+ end
+
+ def resource_ids_blocker
+ return :unreadable unless renderable_at_all?
+ return :custom_primary_key unless foreign_key_is_related_id?
+ return :polymorphic_child if polymorphic_child?
+ return :scope_block if self.class.scope_proc
+ return :params_block if self.class.params_proc
+ return :base_scope if @base_scope
+ return :remote if remote?
+ return :polymorphic_resource if resource.class.polymorphic.present?
+
+ 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] ||= {}
diff --git a/lib/graphiti/sideload/has_many.rb b/lib/graphiti/sideload/has_many.rb
index 0a460297..1b485bc7 100644
--- a/lib/graphiti/sideload/has_many.rb
+++ b/lib/graphiti/sideload/has_many.rb
@@ -2,7 +2,7 @@ class Graphiti::Sideload::HasMany < Graphiti::Sideload
def initialize(name, opts)
@inverse_filter = opts[:inverse_filter]
- super(name, opts)
+ super
end
def type
diff --git a/lib/graphiti/sideload/polymorphic_belongs_to.rb b/lib/graphiti/sideload/polymorphic_belongs_to.rb
index bb1e0412..00db5e24 100644
--- a/lib/graphiti/sideload/polymorphic_belongs_to.rb
+++ b/lib/graphiti/sideload/polymorphic_belongs_to.rb
@@ -107,30 +107,39 @@ def child_for_type!(type)
end
end
- def resolve(parents, query, graph_parent)
+ def resolve(parents, query, graph_parent, &proxy_block)
if ::Graphiti::Scope.resolve_synchronously?
- sync_resolve(parents, query, graph_parent)
+ sync_resolve(parents, query, graph_parent, &proxy_block)
else
- future_resolve(parents, query, graph_parent).value!
+ future_resolve(parents, query, graph_parent, &proxy_block).value!
end
end
- def future_resolve(parents, query, graph_parent)
+ def future_resolve(parents, query, graph_parent, &proxy_block)
promises = []
each_resolvable_group(parents, query) do |child, group, child_query|
- promises << child.future_resolve(group, child_query, graph_parent)
+ promises << child.future_resolve(group, child_query, graph_parent, &proxy_block)
end
- Concurrent::Promises.zip(*promises)
- end
+ return promises.first if promises.one?
- private
+ executor = ::Graphiti::Scope.global_thread_pool_executor
+ Concurrent::Promises.zip_futures_on(executor, *promises)
+ .rescue_on(executor) do |*reasons|
+ first_error = reasons.find { |reason| reason.is_a?(Exception) }
+ raise first_error
+ end
+ end
- def sync_resolve(parents, query, graph_parent)
+ def sync_resolve(parents, query, graph_parent, &proxy_block)
each_resolvable_group(parents, query) do |child, group, child_query|
- child.resolve(group, child_query, graph_parent)
+ child.sync_resolve(group, child_query, graph_parent, &proxy_block)
end
end
+ private
+
+ # Group parents by their polymorphic type and yield each group's child
+ # sideload alongside a query pruned to the sideloads that child supports.
def each_resolvable_group(parents, query)
parents.group_by(&grouper.field_name).each_pair do |group_name, group|
next if group_name.nil? || grouper.ignore?(group_name)
diff --git a/lib/graphiti/spec_helpers.rb b/lib/graphiti/spec_helpers.rb
new file mode 100644
index 00000000..56e2a79d
--- /dev/null
+++ b/lib/graphiti/spec_helpers.rb
@@ -0,0 +1,59 @@
+# Merged in from the standalone graphiti_spec_helpers gem, retired as of
+# Graphiti 2.0. The GraphitiSpecHelpers namespace and the
+# "graphiti_spec_helpers/rspec" require path are kept verbatim so existing spec
+# suites need no changes beyond dropping the gem from their Gemfile.
+require "json"
+require "active_support/core_ext/string"
+require "active_support/core_ext/hash"
+require "graphiti"
+
+require "graphiti/spec_helpers/helpers"
+require "graphiti/spec_helpers/node"
+require "graphiti/spec_helpers/errors_proxy"
+require "graphiti/spec_helpers/errors"
+require "graphiti/spec_helpers/matchers"
+
+module Graphiti
+ module SpecHelpers
+ def self.included(klass)
+ klass.send(:include, Helpers)
+ end
+
+ class TestRunner < ::Graphiti::Runner
+ def current_user
+ nil
+ end
+ end
+
+ module Sugar
+ def self.included(base)
+ Graphiti::DEPRECATOR.warn("Graphiti::SpecHelpers::Sugar is deprecated. Call jsonapi_data, jsonapi_included, jsonapi_errors, json_date and json_datetime directly.")
+ super
+ end
+
+ def d
+ jsonapi_data
+ end
+
+ def included(type = nil)
+ jsonapi_included(type)
+ end
+
+ def errors
+ jsonapi_errors
+ end
+
+ def dt(*args)
+ json_datetime(*args)
+ end
+
+ def datetime(*args)
+ json_datetime(*args)
+ end
+
+ def date(*args)
+ json_date(*args)
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/spec_helpers/errors.rb b/lib/graphiti/spec_helpers/errors.rb
new file mode 100644
index 00000000..e428117c
--- /dev/null
+++ b/lib/graphiti/spec_helpers/errors.rb
@@ -0,0 +1,73 @@
+module Graphiti
+ module SpecHelpers
+ module Errors
+ class Base < StandardError; end
+
+ class LinksNotFound < Base
+ def initialize(name)
+ @name = name
+ end
+
+ def message
+ "Relationship with name '#{@name}' has no links!"
+ end
+ end
+
+ class NoAttribute < Base
+ def initialize(name)
+ @name = name
+ end
+
+ def message
+ "No attribute '#{@name}' in JSON response node!"
+ end
+ end
+
+ class SideloadNotFound < Base
+ def initialize(name)
+ @name = name
+ end
+
+ def message
+ "Relationship with name '#{@name}' not found!"
+ end
+ end
+
+ class NoResponse < Base
+ def message
+ "Cannot parse response - missing #response.body!"
+ end
+ end
+
+ class NoData < Base
+ def initialize(payload)
+ @payload = payload
+ end
+
+ def message
+ "Payload did not contain 'data'! Payload was:\n\n#{JSON.pretty_generate(@payload)}"
+ end
+ end
+
+ class NoSideloads < Base
+ def initialize(payload)
+ @payload = payload
+ end
+
+ def message
+ "Tried to find sideload, but the payload did not contain 'included'! Payload was:\n\n#{JSON.pretty_generate(@payload)}"
+ end
+ end
+
+ class NoMeta < Base
+ def initialize(payload)
+ @payload = payload
+ end
+
+ def message
+ "Tried to find metadata, but the payload did not contain 'meta'! Payload was:\n\n#{JSON.pretty_generate(@payload)}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/spec_helpers/errors_proxy.rb b/lib/graphiti/spec_helpers/errors_proxy.rb
new file mode 100644
index 00000000..f56e95b7
--- /dev/null
+++ b/lib/graphiti/spec_helpers/errors_proxy.rb
@@ -0,0 +1,75 @@
+module Graphiti
+ module SpecHelpers
+ class ErrorsProxy
+ class Error
+ attr_reader :json
+
+ def initialize(json)
+ @json = json
+ end
+
+ def attribute
+ @json[:meta][:attribute]
+ end
+
+ # TODO: move to top-level code in errorable
+ def code
+ @json[:meta][:code]
+ end
+
+ def message
+ @json[:meta][:message]
+ end
+
+ def title
+ @json[:title]
+ end
+
+ def detail
+ @json[:detail]
+ end
+
+ def status
+ @json[:status]
+ end
+ end
+
+ include Enumerable
+
+ def initialize(array)
+ @errors = array.map { |e| Error.new(e) }
+ end
+
+ def [](key)
+ @errors[key]
+ end
+
+ def each(&blk)
+ @errors.each(&blk)
+ end
+
+ def length
+ count
+ end
+
+ def to_h
+ {}.tap do |hash|
+ @errors.each do |e|
+ hash[e.attribute] = e.message
+ end
+ end
+ end
+
+ def method_missing(id, *args, &blk)
+ matches = @errors.select { |e| e.attribute.to_sym == id }
+ (matches.length == 1) ? matches[0] : matches
+ end
+
+ # Any name is answered: an attribute with no matching error yields an empty
+ # array rather than raising NoMethodError.
+ def respond_to_missing?(id, include_private = false)
+ true
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/spec_helpers/helpers.rb b/lib/graphiti/spec_helpers/helpers.rb
new file mode 100644
index 00000000..6e9de6ab
--- /dev/null
+++ b/lib/graphiti/spec_helpers/helpers.rb
@@ -0,0 +1,107 @@
+module Graphiti
+ module SpecHelpers
+ module Helpers
+ extend ActiveSupport::Concern
+
+ def json
+ if response&.body
+ JSON.parse(response.body).with_indifferent_access
+ else
+ raise Errors::NoResponse.new
+ end
+ end
+
+ def jsonapi_data
+ @jsonapi_data ||= if _jsonapi_data.is_a?(Hash)
+ node(from: _jsonapi_data)
+ else
+ _jsonapi_data.map { |datum| node(from: datum) }
+ end
+ end
+
+ def jsonapi_included(type = nil)
+ variable = :"@jsonapi_included#{type}"
+ memo = instance_variable_get(variable)
+ return memo if memo
+
+ nodes = _jsonapi_included.map { |i| node(from: i) }
+ if type
+ nodes.select! { |n| n.jsonapi_type == type.to_s }
+ end
+ instance_variable_set(variable, nodes)
+ nodes
+ end
+
+ def jsonapi_errors
+ @jsonapi_errors ||= ErrorsProxy.new(json["errors"] || [])
+ end
+
+ def jsonapi_meta
+ @jsonapi_errors = json["meta"] || raise(Errors::NoMeta.new(json))
+ end
+
+ def jsonapi_headers
+ media_type = "application/vnd.api+json"
+ {
+ "CONTENT_TYPE" => media_type,
+ "HTTP_ACCEPT" => media_type
+ }
+ end
+
+ def jsonapi_get(url, params: {}, headers: {})
+ get url_for(url), params: params, headers: jsonapi_headers.merge(headers)
+ end
+
+ def jsonapi_post(url, payload, headers: {})
+ post url_for(url), params: payload.to_json, headers: jsonapi_headers.merge(headers)
+ end
+
+ def jsonapi_put(url, payload, headers: {})
+ put url_for(url), params: payload.to_json, headers: jsonapi_headers.merge(headers)
+ end
+
+ def jsonapi_patch(url, payload, headers: {})
+ patch url_for(url), params: payload.to_json, headers: jsonapi_headers.merge(headers)
+ end
+
+ def jsonapi_delete(url, headers: {})
+ delete url_for(url), headers: jsonapi_headers.merge(headers)
+ end
+
+ def json_datetime(value)
+ Graphiti::Types[:datetime][:read][value].as_json
+ end
+
+ def json_date(value)
+ Graphiti::Types[:date][:read][value].as_json
+ end
+
+ # @api private
+ def node(from: nil)
+ from = json if from.nil?
+ data = from.has_key?("data") ? from["data"] : from
+ hash = {}
+ hash["id"] = data["id"]
+ hash["jsonapi_type"] = data["type"]
+ hash.merge!(data["attributes"]) if data.has_key?("attributes")
+ Node.new(hash, data["relationships"], self)
+ end
+
+ private
+
+ # @api private
+ def _jsonapi_data
+ json["data"] || raise(Errors::NoData.new(json))
+ end
+
+ # @api private
+ def _jsonapi_included
+ if json.has_key?("included")
+ json["included"]
+ else
+ raise Errors::NoSideloads.new(json)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/spec_helpers/matchers.rb b/lib/graphiti/spec_helpers/matchers.rb
new file mode 100644
index 00000000..4061c1e8
--- /dev/null
+++ b/lib/graphiti/spec_helpers/matchers.rb
@@ -0,0 +1,197 @@
+module Graphiti
+ module SpecHelpers
+ # Assertions against a resource's DSL. The schema specs cannot cover
+ # adapter-dependent options (primary key, foreign key, and so on) because
+ # the schema does not carry them; these read the resource config directly.
+ module Matchers
+ class BaseMatcher
+ GRAPHITI_OPTS = [].freeze
+ GRAPHITI_CONFIG_KEY = ""
+ EXPECTED_ACTION = ""
+
+ def description
+ "#{self.class::EXPECTED_ACTION} #{target}"
+ end
+
+ def failure_message
+ "expected that #{resource.class} would #{self.class::EXPECTED_ACTION} #{target}\n#{@opt_failures.join("\n")}"
+ end
+
+ def failure_message_when_negated
+ "expected that #{resource.class} would not #{self.class::EXPECTED_ACTION} #{target}"
+ end
+
+ def opt_failure_message(opt, expected, actual)
+ "expected that #{opt} would be #{expected}, was #{actual}"
+ end
+
+ def does_not_match?(resource)
+ !matches?(resource)
+ end
+
+ def matches?(resource)
+ @resource = resource
+
+ expected? && expected_opts?
+ end
+
+ def with_options(opts)
+ @opts = opts
+ self
+ end
+
+ private
+
+ def actual_opts
+ self.class::GRAPHITI_OPTS & opts.keys
+ end
+
+ def config
+ @config ||= resource.class.config[self.class::GRAPHITI_CONFIG_KEY][target]
+ end
+
+ def expected_opts?
+ return false unless config
+
+ actual_opts.map { |opt| assert_opt(opt) }.all?(true)
+ end
+ end
+
+ class RelationMatcher < BaseMatcher
+ GRAPHITI_OPTS = %i[primary_key foreign_key resource readable writable link single].freeze
+ SIDELOAD_METHODS = {resource: :resource_class, readable: :readable?, writable: :writable?, single: :single?}.freeze
+ GRAPHITI_CONFIG_KEY = :sideloads
+ EXPECTED_ACTION = ""
+
+ def initialize(target)
+ @target = target
+ @opts = {}
+ @opt_failures = []
+ end
+
+ private
+
+ attr_reader :target, :opts, :resource
+
+ def assert_opt(opt)
+ asserted_opt = SIDELOAD_METHODS.fetch(opt, opt)
+ return true if config.send(asserted_opt) == opts[opt]
+
+ @opt_failures << opt_failure_message(opt, opts[opt], config.send(asserted_opt))
+ false
+ end
+
+ def expected?
+ config && config.type == relation_name
+ end
+
+ # BelongsToMatcher -> :belongs_to, matching Sideload#type.
+ def relation_name
+ self.class.name.demodulize.gsub("Matcher", "").underscore.to_sym
+ end
+ end
+
+ class BelongsToMatcher < RelationMatcher
+ EXPECTED_ACTION = "belong to"
+ end
+
+ class HasManyMatcher < RelationMatcher
+ EXPECTED_ACTION = "have many"
+ end
+
+ class HasOneMatcher < RelationMatcher
+ EXPECTED_ACTION = "have one"
+ end
+
+ class ResourceDSLMatcher < BaseMatcher
+ def initialize(target, type)
+ @target = target
+ @type = type
+ @opts = {}
+ @opt_failures = []
+ end
+
+ private
+
+ attr_reader :target, :type, :opts, :resource
+
+ def expected?
+ config && assert_type
+ end
+
+ def assert_type
+ return true if config[:type] == type
+
+ @opt_failures << opt_failure_message("type", type, config[:type])
+ false
+ end
+
+ def assert_opt(opt)
+ return true if config[opt] == opts[opt]
+
+ @opt_failures << opt_failure_message(opt, opts[opt], config[opt])
+ false
+ end
+ end
+
+ class ExposeAttributeMatcher < ResourceDSLMatcher
+ GRAPHITI_OPTS = %i[writable readable sortable filterable].freeze
+ GRAPHITI_CONFIG_KEY = :attributes
+ EXPECTED_ACTION = "expose"
+ end
+
+ class FilterAttributeMatcher < ResourceDSLMatcher
+ GRAPHITI_OPTS = %i[allow deny single required blanks].freeze
+ GRAPHITI_CONFIG_KEY = :filters
+ EXPECTED_ACTION = "filter"
+ end
+
+ # @param [Symbol] relation
+ #
+ # @example expect(subject).to belong_to_resource(:user)
+ # @example expect(subject).to belong_to_resource(:user).with_options(foreign_key: :user_id, resource: UserResource)
+ # @example expect(subject).not_to belong_to_resource(:user)
+ def belong_to_resource(relation)
+ BelongsToMatcher.new(relation)
+ end
+
+ # @param [Symbol] relation
+ #
+ # @example expect(subject).to have_many_resources(:posts)
+ # @example expect(subject).to have_many_resources(:posts).with_options(foreign_key: :post_id, resource: PostResource)
+ # @example expect(subject).not_to have_many_resources(:posts)
+ def have_many_resources(relation)
+ HasManyMatcher.new(relation)
+ end
+
+ # @param [Symbol] relation
+ #
+ # @example expect(subject).to have_one_resource(:post)
+ # @example expect(subject).to have_one_resource(:post).with_options(foreign_key: :post_id, resource: PostResource)
+ # @example expect(subject).not_to have_one_resource(:post)
+ def have_one_resource(relation)
+ HasOneMatcher.new(relation)
+ end
+
+ # @param [Symbol] attribute
+ # @param [Symbol] type
+ #
+ # @example expect(subject).to expose_attribute(:name, :string)
+ # @example expect(subject).to expose_attribute(:name, :string).with_options(writable: false)
+ # @example expect(subject).not_to expose_attribute(:name, :string)
+ def expose_attribute(attribute, type)
+ ExposeAttributeMatcher.new(attribute, type)
+ end
+
+ # @param [Symbol] attribute
+ # @param [Symbol] type
+ #
+ # @example expect(subject).to filter_attribute(:name, :string)
+ # @example expect(subject).to filter_attribute(:name, :string).with_options(blanks: :null)
+ # @example expect(subject).not_to filter_attribute(:name, :string)
+ def filter_attribute(attribute, type)
+ FilterAttributeMatcher.new(attribute, type)
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/spec_helpers/node.rb b/lib/graphiti/spec_helpers/node.rb
new file mode 100644
index 00000000..5abaedd7
--- /dev/null
+++ b/lib/graphiti/spec_helpers/node.rb
@@ -0,0 +1,88 @@
+module Graphiti
+ module SpecHelpers
+ class Node
+ attr_reader :attributes, :relationships
+
+ def initialize(attributes, relationships, context)
+ @attributes = attributes.with_indifferent_access
+ @relationships = relationships.with_indifferent_access if relationships
+ @context = context
+ end
+
+ def id
+ Integer(rawid) # Only convert if using integer ids
+ rescue ArgumentError
+ rawid
+ end
+
+ def rawid
+ @attributes["id"]
+ end
+
+ def jsonapi_type
+ @attributes["jsonapi_type"]
+ end
+
+ def has_key?(key)
+ @attributes.has_key?(key)
+ end
+ alias_method :key?, :has_key?
+
+ def [](key)
+ @attributes[key] || @attributes[key.to_s]
+ end
+
+ def []=(key, val)
+ @attributes[key] = val
+ end
+
+ alias_method :to_hash, :attributes
+
+ def method_missing(id, *args, &blk)
+ if @attributes.has_key?(id)
+ @attributes[id]
+ else
+ raise Errors::NoAttribute.new(id)
+ end
+ end
+
+ def respond_to_missing?(id, include_private = false)
+ @attributes.has_key?(id) || super
+ end
+
+ def link(relationship_name, name)
+ if @relationships.has_key?(relationship_name)
+ links = @relationships[relationship_name][:links]
+ raise Errors::LinksNotFound.new(relationship_name) unless links
+ links[name]
+ else
+ raise Errors::SideloadNotFound.new(relationship_name)
+ end
+ end
+
+ def sideload(relationship_name)
+ unless @relationships.has_key?(relationship_name)
+ raise Errors::SideloadNotFound.new(relationship_name)
+ end
+ rel = @relationships[relationship_name]
+ rel = rel[:data]
+ return if rel.nil?
+ if rel.is_a?(Hash)
+ include_for(rel[:type], rel[:id])
+ else
+ rel.map { |r| include_for(r[:type], r[:id]) }
+ end
+ end
+ alias_method :sideloads, :sideload
+
+ private
+
+ def include_for(type, id)
+ data = @context.json["included"].find do |i|
+ i["type"] == type && i["id"] == id
+ end
+ @context.node(from: data)
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/spec_helpers/rspec.rb b/lib/graphiti/spec_helpers/rspec.rb
new file mode 100644
index 00000000..36047eda
--- /dev/null
+++ b/lib/graphiti/spec_helpers/rspec.rb
@@ -0,0 +1,143 @@
+require "rspec/core"
+require "graphiti/spec_helpers"
+
+module Graphiti
+ module SpecHelpers
+ class ContextProxy < OpenStruct
+ def initialize(proxied, *args)
+ @__proxied = proxied
+ super(*args)
+ end
+
+ # variables defined in the rspec context should remain lazy
+ def current_user
+ super || __proxied_current_user
+ end
+
+ def params
+ super || __proxied_params
+ end
+
+ private
+
+ def __proxied_current_user
+ @__proxied.current_user if @__proxied.respond_to?(:current_user)
+ end
+
+ def __proxied_params
+ @__proxied.params if @__proxied.respond_to?(:params)
+ end
+ end
+ end
+end
+
+# Registered under both names. The graphiti-prefixed name is canonical. The bare
+# one is kept for suites that include it explicitly, and carries no `type:`
+# metadata so a `type: :resource` group does not pick the context up twice.
+resource_testing = proc do
+ let(:resource) { described_class }
+ let(:params) { {} }
+
+ around do |e|
+ original_requests = Graphiti::Resource.validate_requests
+ original_links = Graphiti::Resource.validate_links
+ Graphiti::Resource.validate_requests = false
+ Graphiti::Resource.validate_links = false
+
+ Graphiti.with_context graphiti_context do
+ e.run
+ end
+ ensure
+ Graphiti::Resource.validate_requests = original_requests
+ Graphiti::Resource.validate_links = original_links
+ end
+
+ def graphiti_context
+ @graphiti_context ||= Graphiti::SpecHelpers::ContextProxy.new(self)
+ end
+
+ # If you need to set context:
+ #
+ # Graphiti.with_context my_context, {} do
+ # render
+ # end
+ def render(runtime_options = {})
+ json = proxy.to_jsonapi(runtime_options)
+ response.body = json
+ json
+ end
+
+ def proxy
+ @proxy ||= begin
+ args = [params]
+ args << base_scope if defined?(base_scope)
+ resource.all(*args)
+ end
+ end
+
+ def records
+ proxy.data
+ end
+
+ def response
+ @response ||= OpenStruct.new
+ end
+end
+
+remote_api = proc do
+ # Fake request headers
+ around do |e|
+ ctx = OpenStruct.new \
+ request: OpenStruct.new(headers: OpenStruct.new)
+ Graphiti.with_context(ctx) { e.run }
+ end
+
+ def mock_api(url, json, call_count = 1)
+ api_response = double(body: json.to_json)
+ expect(Faraday).to receive(:get)
+ .with(url, anything, anything)
+ .exactly(call_count).times
+ .and_return(api_response)
+ end
+end
+
+# Was a top-level constant, and reopening it to add context methods was a way
+# suites customised graphiti_context. Remove in 3.0.
+GraphitiContextProxy = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
+ "GraphitiContextProxy",
+ "Graphiti::SpecHelpers::ContextProxy",
+ Graphiti::DEPRECATOR
+)
+
+::RSpec.shared_context("graphiti resource testing", type: :resource, &resource_testing)
+::RSpec.shared_context("resource testing", &resource_testing)
+
+::RSpec.shared_context("graphiti remote api", &remote_api)
+::RSpec.shared_context("remote api", &remote_api)
+
+module Graphiti
+ module SpecHelpers
+ module RSpec
+ def self.included(klass)
+ klass.send(:include, Graphiti::SpecHelpers)
+
+ ::RSpec.configure do |rspec|
+ rspec.include_context "graphiti resource testing", type: :resource
+ rspec.include Graphiti::SpecHelpers::Matchers, type: :resource
+ end
+ end
+
+ def self.schema!(resources = nil, path: nil)
+ ::RSpec.describe "Graphiti Schema" do
+ it "generates a backwards-compatible schema" do
+ check = Graphiti::Schema.check(resources, path: path || Graphiti.config.schema_path)
+ forced = ENV["FORCE_SCHEMA"] == "true"
+ check.write! if check.compatible? || forced
+
+ expect(check.compatible? || forced).to eq(true), check.message
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/graphiti/util/cache_debug.rb b/lib/graphiti/util/cache_debug.rb
index bedbd2e1..f5c77e2a 100644
--- a/lib/graphiti/util/cache_debug.rb
+++ b/lib/graphiti/util/cache_debug.rb
@@ -28,7 +28,7 @@ def current_version
expires_in: proxy.cache_expires_in,
etag: proxy.etag,
miss_count: last_version[:miss_count].to_i + (changed_key? ? 1 : 0),
- hit_count: last_version[:hit_count].to_i + (!changed_key? && !new_key? ? 1 : 0),
+ hit_count: last_version[:hit_count].to_i + ((!changed_key? && !new_key?) ? 1 : 0),
request_count: last_version[:request_count].to_i + (last_version.present? ? 1 : 0)
}
end
@@ -76,8 +76,8 @@ def added_segments
end
def changes
- sub_keys_old = last_version[:cache_key]&.scan(/\w+\/query-[a-z0-9-]+\/args-[a-z0-9-]+/).to_a || []
- sub_keys_new = current_version[:cache_key]&.scan(/\w+\/query-[a-z0-9-]+\/args-[a-z0-9-]+/).to_a || []
+ sub_keys_old = last_version[:cache_key]&.scan(/\w+\/query-[a-z0-9-]+\/args-[a-z0-9-]+/).to_a
+ sub_keys_new = current_version[:cache_key]&.scan(/\w+\/query-[a-z0-9-]+\/args-[a-z0-9-]+/).to_a
[sub_keys_old, sub_keys_new]
end
diff --git a/lib/graphiti/util/hash.rb b/lib/graphiti/util/hash.rb
index 38d1a09b..addef5dd 100644
--- a/lib/graphiti/util/hash.rb
+++ b/lib/graphiti/util/hash.rb
@@ -43,7 +43,7 @@ def self.include_removed?(new, old)
# @return [Hash] the merged hash
# @api private
def self.deep_merge!(hash, other)
- merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
+ merger = proc { |key, v1, v2| (Hash === v1 && Hash === v2) ? v1.merge(v2, &merger) : v2 }
hash.merge!(other, &merger)
end
@@ -55,8 +55,15 @@ def self.deep_dup(hash)
else
{}.tap do |duped|
hash.each_pair do |key, value|
- value = deep_dup(value) if value.is_a?(Hash)
- value = value.dup if value&.respond_to?(:dup) && ![Symbol, Integer].include?(value.class)
+ value = if value.is_a?(Hash)
+ deep_dup(value)
+ elsif value.is_a?(Array)
+ value.map { |element| element.is_a?(Hash) ? deep_dup(element) : element }
+ elsif value&.respond_to?(:dup) && ![Symbol, Integer].include?(value.class)
+ value.dup
+ else
+ value
+ end
duped[key] = value
end
end
diff --git a/lib/graphiti/util/include_params.rb b/lib/graphiti/util/include_params.rb
index 88b56415..836b7ff3 100644
--- a/lib/graphiti/util/include_params.rb
+++ b/lib/graphiti/util/include_params.rb
@@ -7,9 +7,9 @@ class << self
#
# GET /posts?include=comments.author
#
- # But our resource had this code:
+ # But the controller had this code:
#
- # sideload_allowlist({ index: [:comments] })
+ # self.sideload_allowlist = {index: [:comments]}
#
# We should drop the 'author' sideload from the request.
#
diff --git a/lib/graphiti/util/link.rb b/lib/graphiti/util/link.rb
index 9390a2eb..c2f53e0c 100644
--- a/lib/graphiti/util/link.rb
+++ b/lib/graphiti/util/link.rb
@@ -47,7 +47,7 @@ def raw_url
end
def on_demand_links(url)
- return url unless Graphiti.config.links_on_demand
+ return url unless @sideload.resource.relationship_links == :on_demand
return unless url
url << if url.include?("?")
diff --git a/lib/graphiti/util/persistence.rb b/lib/graphiti/util/persistence.rb
index 05e63723..a2d72f52 100644
--- a/lib/graphiti/util/persistence.rb
+++ b/lib/graphiti/util/persistence.rb
@@ -7,8 +7,12 @@ class Graphiti::Util::Persistence
# @param [Hash] relationships see (Deserializer#relationships)
# @param [Model] caller_model The persisted parent object in the request graph
# @param [Symbol] foreign_key Attribute assigned by parent object in graph
- def initialize(resource, meta, attributes, relationships, caller_model, foreign_key = nil)
+ # @param [Model] assigned_model a model already built by #assign, to be
+ # assigned onto rather than rebuilt
+ # TODO: make foreign_key a keyword once the satellite gems are rolled in
+ def initialize(resource, meta, attributes, relationships, caller_model, foreign_key = nil, assigned_model: nil)
@resource = resource
+ @assigned_model = assigned_model
@meta = meta
@attributes = attributes
@relationships = relationships
@@ -24,6 +28,14 @@ def initialize(resource, meta, attributes, relationships, caller_model, foreign_
end
end
+ def assign
+ attributes = @adapter.persistence_attributes(self, @attributes)
+ assigned = @resource.assign(attributes, metadata, @meta[:method], model_instance: @assigned_model)
+ @resource.decorate_record(assigned)
+
+ assigned
+ end
+
# Perform the actual save logic.
#
# belongs_to must be processed before/separately from has_many -
@@ -46,11 +58,13 @@ def initialize(resource, meta, attributes, relationships, caller_model, foreign_
def run
attributes = @adapter.persistence_attributes(self, @attributes)
+ payload_attributes = attributes.dup
parents = @adapter.process_belongs_to(self, attributes)
+ apply_derived_attributes(attributes, payload_attributes)
persisted = persist_object(@meta[:method], attributes)
@resource.decorate_record(persisted)
- assign_temp_id(persisted, @meta[:temp_id])
+ assign_temp_id(persisted, @meta[:temp_id])
associate_parents(persisted, parents)
children = @adapter.process_has_many(self, persisted)
@@ -81,6 +95,19 @@ def iterate(only: [], except: [])
private
+ # process_belongs_to persists the parents and writes their primary keys into
+ # +attributes+. A model that was already assigned (see ResourceProxy#assign_attributes)
+ # predates those keys, so apply just them - the payload attributes are already on it,
+ # and re-applying them would clobber any changes made since.
+ def apply_derived_attributes(attributes, payload_attributes)
+ return unless @assigned_model
+
+ derived = attributes.reject { |key, value| payload_attributes[key] == value }
+ return if derived.empty?
+
+ @resource.assign_attributes(@assigned_model, derived, metadata)
+ end
+
def add_hook(prc, lifecycle_event)
::Graphiti::Util::TransactionHooksRecorder.add(prc, lifecycle_event)
end
@@ -129,12 +156,12 @@ def associate_children(object, children)
def persist_object(method, attributes)
case method
- when :destroy
- call_resource_method(:destroy, attributes[:id], @caller_model)
- when :update, nil, :disassociate
- call_resource_method(:update, attributes, @caller_model)
- else
- call_resource_method(:create, attributes, @caller_model)
+ when :destroy
+ call_resource_method(:destroy, attributes[:id], @caller_model)
+ when :update, nil, :disassociate
+ call_resource_method(:update, attributes, @caller_model)
+ else
+ call_resource_method(:create, attributes, @caller_model)
end
end
@@ -163,13 +190,21 @@ def metadata
}
end
+ # The assigned model rides on the resource instance rather than through
+ # the method signature, so any override of #create/#update - whatever its
+ # arity - passes it through untouched.
def call_resource_method(method_name, attributes, caller_model)
method = @resource.method(method_name)
+ call = if method.arity == 1
+ -> { method.call(attributes) }
+ else
+ -> { method.call(attributes, metadata) }
+ end
- if method.arity == 1
- method.call(attributes)
+ if @assigned_model && [:create, :update].include?(method_name)
+ @resource.with_assigned_model(@assigned_model) { call.call }
else
- method.call(attributes, metadata)
+ call.call
end
end
end
diff --git a/lib/graphiti/util/serializer_attributes.rb b/lib/graphiti/util/serializer_attributes.rb
index c192d48c..4b07a571 100644
--- a/lib/graphiti/util/serializer_attributes.rb
+++ b/lib/graphiti/util/serializer_attributes.rb
@@ -30,7 +30,7 @@ def apply
@serializer.send(:"#{applied_method}=", [@name] | existing)
@serializer.meta do
- if !!@resource.try(:cursor_paginatable?) && !Graphiti.context[:graphql]
+ if !!@resource.try(:page_cursors?) && !Graphiti.context[:graphql]
{cursor: cursor}
end
end
@@ -121,10 +121,11 @@ def typecast(type)
def default_proc
name_ref = @name
+ resource_ref = @resource
typecast_ref = typecast(Graphiti::Types[@attr[:type]][:read])
->(_) {
val = @object.send(name_ref)
- if Graphiti.config.typecast_reads
+ if resource_ref.typecast_reads
typecast_ref.call(val)
else
val
@@ -133,10 +134,11 @@ def default_proc
end
def wrap_proc(inner)
+ resource_ref = @resource
typecast_ref = typecast(Graphiti::Types[@attr[:type]][:read])
->(serializer_instance = nil) {
val = serializer_instance.instance_eval(&inner)
- if Graphiti.config.typecast_reads
+ if resource_ref.typecast_reads
typecast_ref.call(val)
else
val
diff --git a/lib/graphiti/util/serializer_relationships.rb b/lib/graphiti/util/serializer_relationships.rb
index a75720d3..92b4c5a4 100644
--- a/lib/graphiti/util/serializer_relationships.rb
+++ b/lib/graphiti/util/serializer_relationships.rb
@@ -8,6 +8,8 @@ def initialize(resource_class, sideloads)
end
def apply
+ return unless @serializer
+
@sideloads.each_pair do |name, sideload|
if apply?(sideload)
SerializerRelationship
@@ -19,7 +21,14 @@ def apply
private
def apply?(sideload)
- @serializer.relationship_blocks[sideload.name].nil?
+ return true if @serializer.relationship_blocks[sideload.name].nil?
+
+ # A subclass inherits its parent's relationship blocks, each closed
+ # over the parent's sideload. Redeclaring the relationship has to
+ # replace that block or the override never reaches the payload.
+ # Anything not generated here was written by hand, so leave it.
+ applied = @serializer.relationship_sideloads[sideload.name]
+ !applied.nil? && !applied.equal?(sideload)
end
end
@@ -32,6 +41,10 @@ def initialize(resource_class, serializer, sideload)
def apply
sideload = @sideload
+ # Reassign rather than mutate: ancestors share the hash by reference
+ # until a subclass writes to it.
+ @serializer.relationship_sideloads =
+ @serializer.relationship_sideloads.merge(@sideload.name => @sideload)
@serializer.relationship(@sideload.name, if: -> { sideload.readable? }, &block)
end
@@ -44,8 +57,8 @@ def self.validated_link_cache
private
def block
- link_ref = link?
sideload_ref = @sideload
+ resource_class_ref = @resource_class
data_proc_ref = data_proc
self_ref = self
validate_link! if eagerly_validate_links?
@@ -53,26 +66,71 @@ def block
proc do
data { instance_eval(&data_proc_ref) }
- # include relationship links for belongs_to relationships
- # https://github.com/graphiti-api/graphiti/issues/167
- linkage always: sideload_ref.always_include_resource_ids?
-
- if link_ref
- if @proxy.query.links?
- self_ref.send(:validate_link!) unless self_ref.send(:eagerly_validate_links?)
+ # An included relationship is already loaded, and a customized
+ # sideload can resolve it to something the foreign key alone would
+ # not predict, so the loaded records win. Only the un-included case
+ # is worth short-circuiting.
+ if sideload_ref.resource_ids_from_foreign_key? &&
+ !::Graphiti::Sideload.assigned?(@proxy.query, @object, sideload_ref.association_name)
+ linkage always: sideload_ref.render_resource_ids? do
+ foreign_key = begin
+ @object.public_send(sideload_ref.foreign_key)
+ rescue NoMethodError => error
+ raise unless defined?(ActiveModel::MissingAttributeError) &&
+ error.is_a?(ActiveModel::MissingAttributeError)
+
+ raise Errors::UnselectedForeignKey
+ .new(resource_class_ref, sideload_ref, @object)
+ end
- link(:related) do
- ::Graphiti::Util::Link.new(sideload_ref, @object).generate
+ unless foreign_key.nil?
+ {
+ type: sideload_ref.resource.type,
+ id: foreign_key.to_s
+ }
end
end
+ else
+ linkage always: sideload_ref.render_resource_ids?
+ end
+
+ if @proxy.query.render_link?(sideload_ref.link_mode) && self_ref.send(:linkable?)
+ self_ref.send(:validate_link!) unless self_ref.send(:eagerly_validate_links?)
+
+ link(:related) do
+ ::Graphiti::Util::Link.new(sideload_ref, @object).generate
+ end
end
end
end
def data_proc
sideload_ref = @sideload
+ resource_class_ref = @resource_class
->(_) {
- if (records = @object.public_send(sideload_ref.association_name))
+ begin
+ records = @object.public_send(sideload_ref.association_name)
+ rescue NoMethodError => error
+ # #receiver raises ArgumentError when the error was built by hand
+ # rather than raised by a failed call, and a hand-built one can
+ # still carry a matching #name.
+ receiver = begin
+ error.receiver
+ rescue ArgumentError
+ nil
+ end
+
+ raise unless error.name == sideload_ref.association_name &&
+ receiver.equal?(@object)
+
+ # A private method exists, so "has no such method" would be a lie.
+ raise if @object.respond_to?(sideload_ref.association_name, true)
+
+ raise Errors::MissingRelationshipMethod
+ .new(resource_class_ref, sideload_ref, @object)
+ end
+
+ if records
if records.respond_to?(:to_ary)
records.each { |r| sideload_ref.resource.decorate_record(r) }
else
@@ -85,7 +143,7 @@ def data_proc
end
def eagerly_validate_links?
- # TODO: Maybe handle this in graphiti-rails
+ # TODO: Maybe handle this in the Rails integration
if defined?(::Rails) && (app = ::Rails.application)
app.config.eager_load
else
@@ -94,8 +152,8 @@ def eagerly_validate_links?
end
def validate_link!
- return unless link?
- return unless @resource_class.validate_endpoints?
+ return unless @sideload.link? && linkable?
+ return unless @resource_class.validate_links?
return if @sideload.link_proc
unless Graphiti.config.context_for_endpoint
@@ -114,7 +172,7 @@ def validate_link!
def validate_link_for_sideload!(sideload)
return if sideload.resource.remote?
- action = sideload.type == :belongs_to ? :show : :index
+ action = (sideload.type == :belongs_to) ? :show : :index
cache_key = :"#{@sideload.object_id}-#{action}"
return if self.class.validated_link_cache.include?(cache_key)
prc = Graphiti.config.context_for_endpoint
@@ -124,14 +182,16 @@ def validate_link_for_sideload!(sideload)
self.class.validated_link_cache << cache_key
end
- def link?
- return true if @sideload.link_proc
+ # Checked lazily so a sideload with no endpoint only raises when a link is actually wanted.
+ def linkable?
+ return @linkable if defined?(@linkable)
- if @sideload.respond_to?(:children)
- @sideload.link? &&
- @sideload.children.values.all? { |c| !c.resource.endpoint.nil? }
+ @linkable = if @sideload.link_proc
+ true
+ elsif @sideload.respond_to?(:children)
+ @sideload.children.values.all? { |c| !c.resource.endpoint.nil? }
else
- !!(@sideload.link? && @sideload.resource.endpoint)
+ !@sideload.resource.endpoint.nil?
end
end
end
diff --git a/lib/graphiti/util/simple_errors.rb b/lib/graphiti/util/simple_errors.rb
index 4191a23c..41778997 100644
--- a/lib/graphiti/util/simple_errors.rb
+++ b/lib/graphiti/util/simple_errors.rb
@@ -6,6 +6,21 @@ module Util
class SimpleErrors
include Enumerable
+ # Overridable under graphiti.errors.messages.
+ DEFAULT_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"
+ }.freeze
+
+ # Joins an attribute to its message, like Rails' own errors.format.
+ DEFAULT_FORMAT = "%{attribute} %{message}"
+
attr_reader :messages, :details
def initialize(validation_target)
@@ -50,11 +65,9 @@ def empty?
end
alias_method :blank?, :empty?
- def add(attribute, code, message: nil)
- message ||= "is #{code.to_s.humanize.downcase}"
-
+ def add(attribute, code, message: nil, **interpolations)
details[attribute.to_sym] << {error: code}
- messages[attribute.to_sym] << message
+ messages[attribute.to_sym] << translate(code, message, **interpolations, attribute: attribute)
end
def added?(attribute, code)
@@ -73,11 +86,19 @@ def full_messages_for(attribute)
def full_message(attribute, message)
return message if attribute == :base
- "#{attribute} #{message}"
+
+ translate(:format, DEFAULT_FORMAT, [:graphiti, :errors], attribute: attribute, message: message)
end
private
+ def translate(key, fallback, scope = [:graphiti, :errors, :messages], **interpolations)
+ fallback ||= DEFAULT_MESSAGES.fetch(key) { "is #{key.to_s.humanize.downcase}" }
+ return fallback % interpolations unless defined?(::I18n)
+
+ ::I18n.t(key, scope: scope, default: fallback, **interpolations)
+ end
+
def apply_default_array(hash)
hash.default_proc = proc { |h, key| h[key] = [] }
hash
diff --git a/lib/graphiti/util/transaction_hooks_recorder.rb b/lib/graphiti/util/transaction_hooks_recorder.rb
index 31c95224..26e30728 100644
--- a/lib/graphiti/util/transaction_hooks_recorder.rb
+++ b/lib/graphiti/util/transaction_hooks_recorder.rb
@@ -58,11 +58,11 @@ def run(lifecycle_event)
private
def _hooks
- Thread.current[:_graphiti_hooks]
+ Fiber[:_graphiti_hooks]
end
def reset_hooks
- Thread.current[:_graphiti_hooks] = {
+ Fiber[:_graphiti_hooks] = {
after_graph_persist: [],
before_commit: [],
after_commit: []
diff --git a/lib/graphiti/version.rb b/lib/graphiti/version.rb
index 44e13c6c..894f71bf 100644
--- a/lib/graphiti/version.rb
+++ b/lib/graphiti/version.rb
@@ -1,3 +1,3 @@
module Graphiti
- VERSION = "1.13.4"
+ VERSION = "2.0.0.beta.13"
end
diff --git a/lib/graphiti_errors.rb b/lib/graphiti_errors.rb
new file mode 100644
index 00000000..2d45791c
--- /dev/null
+++ b/lib/graphiti_errors.rb
@@ -0,0 +1,11 @@
+# Deprecated entry point. graphiti_errors was absorbed into graphiti in 2.0 and
+# graphiti required it for you in 1.x, so an explicit require left in an
+# initializer or spec_helper would otherwise raise LoadError. Only the
+# serializers survived. See graphiti/error_serializers/deprecated_constants.rb.
+# Remove in 3.0.
+require "graphiti"
+
+Graphiti::DEPRECATOR.warn(
+ 'require "graphiti_errors" is no longer needed. graphiti_errors is part of graphiti as of 2.0, ' \
+ "and its exception handling is replaced by rescue_registry."
+)
diff --git a/lib/graphiti_spec_helpers.rb b/lib/graphiti_spec_helpers.rb
new file mode 100644
index 00000000..2c988035
--- /dev/null
+++ b/lib/graphiti_spec_helpers.rb
@@ -0,0 +1,11 @@
+# Deprecated entry point. The spec helpers moved under Graphiti::SpecHelpers
+# when the graphiti_spec_helpers gem was absorbed into graphiti in 2.0. This
+# keeps `require "graphiti_spec_helpers"` and the old constant working.
+# Remove in 3.0.
+require "graphiti/spec_helpers"
+
+GraphitiSpecHelpers = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
+ "GraphitiSpecHelpers",
+ "Graphiti::SpecHelpers",
+ Graphiti::DEPRECATOR
+)
diff --git a/lib/graphiti_spec_helpers/rspec.rb b/lib/graphiti_spec_helpers/rspec.rb
new file mode 100644
index 00000000..3ecba3f4
--- /dev/null
+++ b/lib/graphiti_spec_helpers/rspec.rb
@@ -0,0 +1,3 @@
+# Deprecated entry point. See graphiti_spec_helpers.rb. Remove in 3.0.
+require "graphiti/spec_helpers/rspec"
+require "graphiti_spec_helpers"
diff --git a/lib/tasks/graphiti.rake b/lib/tasks/graphiti.rake
new file mode 100644
index 00000000..6c541008
--- /dev/null
+++ b/lib/tasks/graphiti.rake
@@ -0,0 +1,60 @@
+require "graphiti/rails/rake_helpers"
+
+namespace :graphiti do
+ helpers = Graphiti::Rails::RakeHelpers
+
+ desc "Execute request without web server."
+ task :request, [:path, :debug] => [:environment] do |_, args|
+ helpers.setup_rails!
+ Graphiti.logger = Graphiti.stdout_logger
+ Graphiti::Debugger.preserve = true
+ require "pp"
+ path, debug = args[:path], args[:debug]
+ puts "Graphiti Request: #{path}"
+ json = helpers.make_request(path, debug)
+ pp json
+ Graphiti::Debugger.flush if debug
+ end
+
+ namespace :schema do
+ desc "Write the schema file. Refuses backwards-incompatible changes unless FORCE_SCHEMA=true. Takes an optional path, defaulting to Graphiti.config.schema_path."
+ task :generate, [:path] => [:environment] do |_, args|
+ check = Graphiti::Schema.check(path: helpers.schema_path(args[:path]))
+ abort check.message unless check.compatible? || ENV["FORCE_SCHEMA"] == "true"
+
+ puts "Schema written: #{check.write!}"
+ end
+
+ desc "Fail unless the committed schema file exists, is up to date, and is backwards-compatible. Takes an optional path, defaulting to Graphiti.config.schema_path."
+ task :check, [:path] => [:environment] do |_, args|
+ check = Graphiti::Schema.check(path: helpers.schema_path(args[:path]))
+ abort check.message unless check.ok?
+
+ puts check.message
+ end
+ end
+
+ desc "Audit every relationship: what will raise, what loads to render ids, which render no ids, and which checks passed."
+ task audit: [:environment] do
+ helpers.setup_rails!
+ rows = Graphiti::Audit.run
+ puts Graphiti::Audit::Report.new(rows)
+
+ if (advisory = helpers.connection_pool_advisory)
+ puts advisory
+ end
+
+ exit 1 if rows.any?(&:error?)
+ end
+
+ desc "Execute benchmark without web server."
+ task :benchmark, [:path, :requests] => [:environment] do |_, args|
+ helpers.setup_rails!
+ took = Benchmark.ms {
+ args[:requests].to_i.times do
+ helpers.make_request(args[:path])
+ end
+ }
+ puts "Took: #{(took / args[:requests].to_f).round(2)}ms"
+ end
+end
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..8cbcb11b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6313 @@
+{
+ "name": "graphiti",
+ "version": "0.0.0-development",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "graphiti",
+ "version": "0.0.0-development",
+ "license": "MIT",
+ "devDependencies": {
+ "@semantic-release/changelog": "^7.0.0",
+ "@semantic-release/exec": "^7.1.0",
+ "@semantic-release/git": "^11.0.1",
+ "semantic-release": "^25.0.8",
+ "semantic-release-rubygem": "^1.2.0"
+ }
+ },
+ "node_modules/@actions/core": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
+ "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/exec": "^3.0.0",
+ "@actions/http-client": "^4.0.0"
+ }
+ },
+ "node_modules/@actions/exec": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
+ "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/io": "^3.0.2"
+ }
+ },
+ "node_modules/@actions/http-client": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz",
+ "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tunnel": "^0.0.6",
+ "undici": "^6.23.0"
+ }
+ },
+ "node_modules/@actions/http-client/node_modules/undici": {
+ "version": "6.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
+ "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/@actions/io": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
+ "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@octokit/auth-token": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
+ "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/core": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz",
+ "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/auth-token": "^6.0.0",
+ "@octokit/graphql": "^9.0.4",
+ "@octokit/request": "^10.0.13",
+ "@octokit/request-error": "^7.1.1",
+ "@octokit/types": "^17.0.0",
+ "before-after-hook": "^4.0.0",
+ "universal-user-agent": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/endpoint": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz",
+ "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^16.0.0",
+ "universal-user-agent": "^7.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": {
+ "version": "27.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
+ "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@octokit/endpoint/node_modules/@octokit/types": {
+ "version": "16.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
+ "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/openapi-types": "^27.0.0"
+ }
+ },
+ "node_modules/@octokit/graphql": {
+ "version": "9.0.4",
+ "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.4.tgz",
+ "integrity": "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/request": "^10.0.13",
+ "@octokit/types": "^17.0.0",
+ "universal-user-agent": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/openapi-types": {
+ "version": "28.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz",
+ "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@octokit/plugin-paginate-rest": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz",
+ "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^16.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=6"
+ }
+ },
+ "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": {
+ "version": "27.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
+ "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": {
+ "version": "16.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
+ "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/openapi-types": "^27.0.0"
+ }
+ },
+ "node_modules/@octokit/plugin-retry": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.1.tgz",
+ "integrity": "sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/request-error": "^7.1.1",
+ "@octokit/types": "^17.0.0",
+ "bottleneck": "^2.15.3"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": ">=7"
+ }
+ },
+ "node_modules/@octokit/plugin-throttling": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.5.tgz",
+ "integrity": "sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^17.0.0",
+ "bottleneck": "^2.15.3"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@octokit/core": "^7.0.0"
+ }
+ },
+ "node_modules/@octokit/request": {
+ "version": "10.0.13",
+ "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.13.tgz",
+ "integrity": "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/endpoint": "^11.0.3",
+ "@octokit/request-error": "^7.1.1",
+ "@octokit/types": "^17.0.0",
+ "content-type": "^2.0.0",
+ "json-with-bigint": "^3.5.3",
+ "universal-user-agent": "^7.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/request-error": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.1.tgz",
+ "integrity": "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/types": "^17.0.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@octokit/types": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz",
+ "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/openapi-types": "^28.0.0"
+ }
+ },
+ "node_modules/@pnpm/config.env-replace": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
+ "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.22.0"
+ }
+ },
+ "node_modules/@pnpm/network.ca-file": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz",
+ "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "4.2.10"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ }
+ },
+ "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": {
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@pnpm/npm-conf": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz",
+ "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pnpm/config.env-replace": "^1.1.0",
+ "@pnpm/network.ca-file": "^1.0.1",
+ "config-chain": "^1.1.11"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@sec-ant/readable-stream": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@semantic-release/changelog": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-7.0.0.tgz",
+ "integrity": "sha512-TNPyag5db24o7jWjre7UwKB4EcL8oJxbRhnDQ7hmZRAYqzreAc6PgdxQuU3pppp5xQinYtiumL0iG8SSKvnlzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@semantic-release/error": "^4.0.0",
+ "aggregate-error": "^5.0.0",
+ "lodash-es": "^4.17.21"
+ },
+ "engines": {
+ "node": "^22.22.2 || >=24.15"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=20.1.0"
+ }
+ },
+ "node_modules/@semantic-release/commit-analyzer": {
+ "version": "13.0.1",
+ "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz",
+ "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "conventional-changelog-angular": "^8.0.0",
+ "conventional-changelog-writer": "^8.0.0",
+ "conventional-commits-filter": "^5.0.0",
+ "conventional-commits-parser": "^6.0.0",
+ "debug": "^4.0.0",
+ "import-from-esm": "^2.0.0",
+ "lodash-es": "^4.17.21",
+ "micromatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=20.8.1"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=20.1.0"
+ }
+ },
+ "node_modules/@semantic-release/error": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz",
+ "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@semantic-release/exec": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-7.1.0.tgz",
+ "integrity": "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@semantic-release/error": "^4.0.0",
+ "aggregate-error": "^3.0.0",
+ "debug": "^4.0.0",
+ "execa": "^9.0.0",
+ "lodash-es": "^4.17.21",
+ "parse-json": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.8.1"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=24.1.0"
+ }
+ },
+ "node_modules/@semantic-release/exec/node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@semantic-release/exec/node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@semantic-release/exec/node_modules/execa": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+ "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "figures": "^6.1.0",
+ "get-stream": "^9.0.0",
+ "human-signals": "^8.0.1",
+ "is-plain-obj": "^4.1.0",
+ "is-stream": "^4.0.1",
+ "npm-run-path": "^6.0.0",
+ "pretty-ms": "^9.2.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^4.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.5.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/@semantic-release/exec/node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@semantic-release/exec/node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@semantic-release/exec/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@semantic-release/git": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-11.0.1.tgz",
+ "integrity": "sha512-Zr8BUYCTZMc8V6wDKN2dpR7nJgewd9I6THL3ydLTnp3OEdTo1/4RBLNYaeRucYMsjMv+BXoCNfXA0NADj1kwhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@semantic-release/error": "^4.0.0",
+ "aggregate-error": "^5.0.0",
+ "debug": "^4.0.0",
+ "dir-glob": "^3.0.0",
+ "execa": "^10.0.0",
+ "lodash-es": "^4.17.21",
+ "micromatch": "^4.0.0",
+ "p-reduce": "^3.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || >=24.15"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=20.1.0"
+ }
+ },
+ "node_modules/@semantic-release/github": {
+ "version": "12.0.9",
+ "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.9.tgz",
+ "integrity": "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@octokit/core": "^7.0.0",
+ "@octokit/plugin-paginate-rest": "^14.0.0",
+ "@octokit/plugin-retry": "^8.0.0",
+ "@octokit/plugin-throttling": "^11.0.0",
+ "@semantic-release/error": "^4.0.0",
+ "aggregate-error": "^5.0.0",
+ "debug": "^4.3.4",
+ "dir-glob": "^3.0.1",
+ "http-proxy-agent": "^9.0.0",
+ "https-proxy-agent": "^9.0.0",
+ "issue-parser": "^7.0.0",
+ "lodash-es": "^4.17.21",
+ "mime": "^4.0.0",
+ "p-filter": "^4.0.0",
+ "tinyglobby": "^0.2.14",
+ "undici": "^7.0.0",
+ "url-join": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.14.0 || >= 24.10.0"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=24.1.0"
+ }
+ },
+ "node_modules/@semantic-release/npm": {
+ "version": "13.1.5",
+ "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz",
+ "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/core": "^3.0.0",
+ "@semantic-release/error": "^4.0.0",
+ "aggregate-error": "^5.0.0",
+ "env-ci": "^11.2.0",
+ "execa": "^9.0.0",
+ "fs-extra": "^11.0.0",
+ "lodash-es": "^4.17.21",
+ "nerf-dart": "^1.0.0",
+ "normalize-url": "^9.0.0",
+ "npm": "^11.6.2",
+ "rc": "^1.2.8",
+ "read-pkg": "^10.0.0",
+ "registry-auth-token": "^5.0.0",
+ "semver": "^7.1.2",
+ "tempy": "^3.0.0"
+ },
+ "engines": {
+ "node": "^22.14.0 || >= 24.10.0"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=20.1.0"
+ }
+ },
+ "node_modules/@semantic-release/npm/node_modules/execa": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+ "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "figures": "^6.1.0",
+ "get-stream": "^9.0.0",
+ "human-signals": "^8.0.1",
+ "is-plain-obj": "^4.1.0",
+ "is-stream": "^4.0.1",
+ "npm-run-path": "^6.0.0",
+ "pretty-ms": "^9.2.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^4.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.5.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator": {
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz",
+ "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "conventional-changelog-angular": "^8.0.0",
+ "conventional-changelog-writer": "^8.0.0",
+ "conventional-commits-filter": "^5.0.0",
+ "conventional-commits-parser": "^6.0.0",
+ "debug": "^4.0.0",
+ "import-from-esm": "^2.0.0",
+ "lodash-es": "^4.17.21",
+ "read-package-up": "^11.0.0"
+ },
+ "engines": {
+ "node": ">=20.8.1"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=20.1.0"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/hosted-git-info": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
+ "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^10.0.1"
+ },
+ "engines": {
+ "node": "^16.14.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/normalize-package-data": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz",
+ "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "hosted-git-info": "^7.0.0",
+ "semver": "^7.3.5",
+ "validate-npm-package-license": "^3.0.4"
+ },
+ "engines": {
+ "node": "^16.14.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/read-package-up": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
+ "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "find-up-simple": "^1.0.0",
+ "read-pkg": "^9.0.0",
+ "type-fest": "^4.6.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/read-pkg": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz",
+ "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/normalize-package-data": "^2.4.3",
+ "normalize-package-data": "^6.0.0",
+ "parse-json": "^8.0.0",
+ "type-fest": "^4.6.0",
+ "unicorn-magic": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@semantic-release/release-notes-generator/node_modules/unicorn-magic": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
+ "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@simple-libs/stream-utils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
+ "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@sindresorhus/merge-streams": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@types/normalize-package-data": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
+ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/agent-base": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
+ "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz",
+ "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clean-stack": "^5.2.0",
+ "indent-string": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/argv-formatter": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz",
+ "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/array-ify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz",
+ "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/before-after-hook": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
+ "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/bottleneck": {
+ "version": "2.19.5",
+ "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
+ "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/char-regex": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz",
+ "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "5.0.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-highlight": {
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz",
+ "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "chalk": "^4.0.0",
+ "highlight.js": "^10.7.1",
+ "mz": "^2.4.0",
+ "parse5": "^5.1.1",
+ "parse5-htmlparser2-tree-adapter": "^6.0.0",
+ "yargs": "^16.0.0"
+ },
+ "bin": {
+ "highlight": "bin/highlight"
+ },
+ "engines": {
+ "node": ">=8.0.0",
+ "npm": ">=5.0.0"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/cliui": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+ "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/yargs": {
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz",
+ "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^7.0.2",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^20.2.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/yargs-parser": {
+ "version": "20.2.9",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
+ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cli-table3": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
+ "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": "10.* || >= 12.*"
+ },
+ "optionalDependencies": {
+ "@colors/colors": "1.5.0"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/compare-func": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz",
+ "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-ify": "^1.0.0",
+ "dot-prop": "^5.1.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/conventional-changelog-angular": {
+ "version": "8.3.1",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz",
+ "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "compare-func": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/conventional-changelog-writer": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz",
+ "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/stream-utils": "^1.2.0",
+ "conventional-commits-filter": "^5.0.0",
+ "handlebars": "^4.7.7",
+ "meow": "^13.0.0",
+ "semver": "^7.5.2"
+ },
+ "bin": {
+ "conventional-changelog-writer": "dist/cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/conventional-commits-filter": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz",
+ "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/conventional-commits-parser": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz",
+ "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/stream-utils": "^1.2.0",
+ "meow": "^13.0.0"
+ },
+ "bin": {
+ "conventional-commits-parser": "dist/cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-hrtime": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz",
+ "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cosmiconfig": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
+ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cross-spawn/node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/crypto-random-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
+ "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/crypto-random-string/node_modules/type-fest": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
+ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dot-prop": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
+ "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-obj": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/duplexer2": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
+ "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/emojilib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz",
+ "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-ci": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz",
+ "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "execa": "^8.0.0",
+ "java-properties": "^1.0.2"
+ },
+ "engines": {
+ "node": "^18.17 || >=20.6.1"
+ }
+ },
+ "node_modules/env-ci/node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/env-ci/node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/env-ci/node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/env-ci/node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/env-ci/node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/env-ci/node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/execa": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.1.tgz",
+ "integrity": "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "figures": "^6.1.0",
+ "get-stream": "^9.0.1",
+ "human-signals": "^8.0.1",
+ "is-plain-obj": "^4.1.0",
+ "is-stream": "^4.0.1",
+ "npm-run-path": "^6.0.0",
+ "pretty-ms": "^9.3.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^4.0.0",
+ "which-command": "^0.1.0",
+ "yoctocolors": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/figures": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
+ "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+ "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/find-up-simple": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz",
+ "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/find-versions": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz",
+ "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver-regex": "^4.0.5",
+ "super-regex": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "11.4.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
+ "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/function-timeout": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz",
+ "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+ "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sec-ant/readable-stream": "^0.4.1",
+ "is-stream": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/git-log-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz",
+ "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argv-formatter": "~1.0.0",
+ "spawn-error-forwarder": "~1.0.0",
+ "split2": "~1.0.0",
+ "stream-combiner2": "~1.1.1",
+ "through2": "~2.0.0",
+ "traverse": "0.6.8"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/handlebars": {
+ "version": "4.7.9",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
+ "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "10.7.3",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
+ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/hook-std": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz",
+ "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/hosted-git-info": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz",
+ "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^11.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
+ "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "9.0.0",
+ "debug": "^4.3.4",
+ "proxy-agent-negotiate": "1.1.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
+ "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "9.0.0",
+ "debug": "^4.3.4",
+ "proxy-agent-negotiate": "1.1.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
+ "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-fresh/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/import-from-esm": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz",
+ "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "import-meta-resolve": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18.20"
+ }
+ },
+ "node_modules/import-meta-resolve": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
+ "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/index-to-position": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
+ "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-obj": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
+ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
+ "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/issue-parser": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz",
+ "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash.capitalize": "^4.2.1",
+ "lodash.escaperegexp": "^4.1.2",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.uniqby": "^4.7.0"
+ },
+ "engines": {
+ "node": "^18.17 || >=20.6.1"
+ }
+ },
+ "node_modules/java-properties": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz",
+ "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-parse-better-errors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-with-bigint": {
+ "version": "3.5.10",
+ "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz",
+ "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/load-json-file": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
+ "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "parse-json": "^4.0.0",
+ "pify": "^3.0.0",
+ "strip-bom": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/load-json-file/node_modules/parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "error-ex": "^1.3.1",
+ "json-parse-better-errors": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+ "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^2.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.capitalize": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz",
+ "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.escaperegexp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
+ "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.uniqby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
+ "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/make-asynchronous": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz",
+ "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-event": "^6.0.0",
+ "type-fest": "^4.6.0",
+ "web-worker": "^1.5.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-asynchronous/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/marked": {
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/marked-terminal": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz",
+ "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^7.0.0",
+ "ansi-regex": "^6.1.0",
+ "chalk": "^5.4.1",
+ "cli-highlight": "^2.1.11",
+ "cli-table3": "^0.6.5",
+ "node-emoji": "^2.2.0",
+ "supports-hyperlinks": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "marked": ">=1 <16"
+ }
+ },
+ "node_modules/meow": {
+ "version": "13.2.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
+ "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
+ "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/broofa"
+ ],
+ "license": "MIT",
+ "bin": {
+ "mime": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nerf-dart": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz",
+ "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-emoji": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz",
+ "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.6.0",
+ "char-regex": "^1.0.2",
+ "emojilib": "^2.4.0",
+ "skin-tone": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-package-data": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz",
+ "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "hosted-git-info": "^9.0.0",
+ "semver": "^7.3.5",
+ "validate-npm-package-license": "^3.0.4"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz",
+ "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm": {
+ "version": "11.19.0",
+ "resolved": "https://registry.npmjs.org/npm/-/npm-11.19.0.tgz",
+ "integrity": "sha512-SDd/hHg3KqHE5Ht2NHWxNYNtqCQ2pXAPLl6OtQhPyED5PHsRfrOtO199MZTIG2cQoQ1ZRI9t28shrD+2cr3AAw==",
+ "bundleDependencies": [
+ "@isaacs/string-locale-compare",
+ "@npmcli/arborist",
+ "@npmcli/config",
+ "@npmcli/fs",
+ "@npmcli/map-workspaces",
+ "@npmcli/metavuln-calculator",
+ "@npmcli/package-json",
+ "@npmcli/promise-spawn",
+ "@npmcli/redact",
+ "@npmcli/run-script",
+ "@sigstore/tuf",
+ "abbrev",
+ "archy",
+ "cacache",
+ "chalk",
+ "ci-info",
+ "fastest-levenshtein",
+ "fs-minipass",
+ "glob",
+ "graceful-fs",
+ "hosted-git-info",
+ "ini",
+ "init-package-json",
+ "is-cidr",
+ "json-parse-even-better-errors",
+ "libnpmaccess",
+ "libnpmdiff",
+ "libnpmexec",
+ "libnpmfund",
+ "libnpmorg",
+ "libnpmpack",
+ "libnpmpublish",
+ "libnpmsearch",
+ "libnpmteam",
+ "libnpmversion",
+ "make-fetch-happen",
+ "minimatch",
+ "minipass",
+ "minipass-pipeline",
+ "ms",
+ "node-gyp",
+ "nopt",
+ "npm-audit-report",
+ "npm-install-checks",
+ "npm-package-arg",
+ "npm-pick-manifest",
+ "npm-profile",
+ "npm-registry-fetch",
+ "npm-user-validate",
+ "p-map",
+ "pacote",
+ "parse-conflict-json",
+ "proc-log",
+ "qrcode-terminal",
+ "read",
+ "semver",
+ "spdx-expression-parse",
+ "ssri",
+ "supports-color",
+ "tar",
+ "text-table",
+ "tiny-relative-date",
+ "treeverse",
+ "validate-npm-package-name",
+ "which"
+ ],
+ "dev": true,
+ "license": "Artistic-2.0",
+ "workspaces": [
+ "docs",
+ "smoke-tests",
+ "mock-globals",
+ "mock-registry",
+ "workspaces/*"
+ ],
+ "dependencies": {
+ "@isaacs/string-locale-compare": "^1.1.0",
+ "@npmcli/arborist": "^9.9.1",
+ "@npmcli/config": "^10.12.0",
+ "@npmcli/fs": "^5.0.0",
+ "@npmcli/map-workspaces": "^5.0.3",
+ "@npmcli/metavuln-calculator": "^9.0.3",
+ "@npmcli/package-json": "^7.0.5",
+ "@npmcli/promise-spawn": "^9.0.1",
+ "@npmcli/redact": "^4.0.0",
+ "@npmcli/run-script": "^10.0.4",
+ "@sigstore/tuf": "^4.0.2",
+ "abbrev": "^4.0.0",
+ "archy": "~1.0.0",
+ "cacache": "^20.0.4",
+ "chalk": "^5.6.2",
+ "ci-info": "^4.4.0",
+ "fastest-levenshtein": "^1.0.16",
+ "fs-minipass": "^3.0.3",
+ "glob": "^13.0.6",
+ "graceful-fs": "^4.2.11",
+ "hosted-git-info": "^9.0.3",
+ "ini": "^6.0.0",
+ "init-package-json": "^8.2.5",
+ "is-cidr": "^6.0.4",
+ "json-parse-even-better-errors": "^5.0.0",
+ "libnpmaccess": "^10.0.3",
+ "libnpmdiff": "^8.1.12",
+ "libnpmexec": "^10.3.2",
+ "libnpmfund": "^7.0.26",
+ "libnpmorg": "^8.0.1",
+ "libnpmpack": "^9.1.12",
+ "libnpmpublish": "^11.2.0",
+ "libnpmsearch": "^9.0.1",
+ "libnpmteam": "^8.0.2",
+ "libnpmversion": "^8.0.4",
+ "make-fetch-happen": "^15.0.6",
+ "minimatch": "^10.2.5",
+ "minipass": "^7.1.3",
+ "minipass-pipeline": "^1.2.4",
+ "ms": "^2.1.2",
+ "node-gyp": "^12.4.0",
+ "nopt": "^9.0.0",
+ "npm-audit-report": "^7.0.0",
+ "npm-install-checks": "^8.0.0",
+ "npm-package-arg": "^13.0.2",
+ "npm-pick-manifest": "^11.0.3",
+ "npm-profile": "^12.0.2",
+ "npm-registry-fetch": "^19.1.1",
+ "npm-user-validate": "^4.0.0",
+ "p-map": "^7.0.4",
+ "pacote": "^21.5.1",
+ "parse-conflict-json": "^5.0.1",
+ "proc-log": "^6.1.0",
+ "qrcode-terminal": "^0.12.0",
+ "read": "^5.0.1",
+ "semver": "^7.8.5",
+ "spdx-expression-parse": "^4.0.0",
+ "ssri": "^13.0.1",
+ "supports-color": "^10.2.2",
+ "tar": "^7.5.19",
+ "text-table": "~0.2.0",
+ "tiny-relative-date": "^2.0.2",
+ "treeverse": "^3.0.0",
+ "validate-npm-package-name": "^7.0.2",
+ "which": "^6.0.1"
+ },
+ "bin": {
+ "npm": "bin/npm-cli.js",
+ "npx": "bin/npx-cli.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
+ "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0",
+ "unicorn-magic": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm/node_modules/@gar/promise-retry": {
+ "version": "1.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/@isaacs/string-locale-compare": {
+ "version": "1.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/@npmcli/agent": {
+ "version": "4.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.1",
+ "lru-cache": "^11.2.1",
+ "socks-proxy-agent": "^8.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/arborist": {
+ "version": "9.9.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@isaacs/string-locale-compare": "^1.1.0",
+ "@npmcli/fs": "^5.0.0",
+ "@npmcli/installed-package-contents": "^4.0.0",
+ "@npmcli/map-workspaces": "^5.0.0",
+ "@npmcli/metavuln-calculator": "^9.0.2",
+ "@npmcli/name-from-folder": "^4.0.0",
+ "@npmcli/node-gyp": "^5.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/query": "^5.0.0",
+ "@npmcli/redact": "^4.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "bin-links": "^6.0.0",
+ "cacache": "^20.0.1",
+ "common-ancestor-path": "^2.0.0",
+ "hosted-git-info": "^9.0.0",
+ "json-stringify-nice": "^1.1.4",
+ "lru-cache": "^11.2.1",
+ "minimatch": "^10.0.3",
+ "nopt": "^9.0.0",
+ "npm-install-checks": "^8.0.0",
+ "npm-package-arg": "^13.0.0",
+ "npm-pick-manifest": "^11.0.1",
+ "npm-registry-fetch": "^19.0.0",
+ "pacote": "^21.0.2",
+ "parse-conflict-json": "^5.0.1",
+ "proc-log": "^6.0.0",
+ "proggy": "^4.0.0",
+ "promise-all-reject-late": "^1.0.0",
+ "promise-call-limit": "^3.0.1",
+ "semver": "^7.3.7",
+ "ssri": "^13.0.0",
+ "treeverse": "^3.0.0",
+ "walk-up-path": "^4.0.0"
+ },
+ "bin": {
+ "arborist": "bin/index.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/config": {
+ "version": "10.12.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/map-workspaces": "^5.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "ci-info": "^4.0.0",
+ "ini": "^6.0.0",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "walk-up-path": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/fs": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/git": {
+ "version": "7.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/promise-spawn": "^9.0.0",
+ "ini": "^6.0.0",
+ "lru-cache": "^11.2.1",
+ "npm-pick-manifest": "^11.0.1",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "which": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/installed-package-contents": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-bundled": "^5.0.0",
+ "npm-normalize-package-bin": "^5.0.0"
+ },
+ "bin": {
+ "installed-package-contents": "bin/index.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/map-workspaces": {
+ "version": "5.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/name-from-folder": "^4.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "glob": "^13.0.0",
+ "minimatch": "^10.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/metavuln-calculator": {
+ "version": "9.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "cacache": "^20.0.0",
+ "json-parse-even-better-errors": "^5.0.0",
+ "pacote": "^21.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/name-from-folder": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/node-gyp": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/package-json": {
+ "version": "7.0.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/git": "^7.0.0",
+ "glob": "^13.0.0",
+ "hosted-git-info": "^9.0.0",
+ "json-parse-even-better-errors": "^5.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.5.3",
+ "spdx-expression-parse": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/promise-spawn": {
+ "version": "9.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "which": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/query": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "postcss-selector-parser": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/redact": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@npmcli/run-script": {
+ "version": "10.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/node-gyp": "^5.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/promise-spawn": "^9.0.0",
+ "node-gyp": "^12.1.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/bundle": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/protobuf-specs": "^0.5.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/core": {
+ "version": "3.2.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/protobuf-specs": {
+ "version": "0.5.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/sign": {
+ "version": "4.1.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.2",
+ "@sigstore/bundle": "^4.0.0",
+ "@sigstore/core": "^3.2.0",
+ "@sigstore/protobuf-specs": "^0.5.0",
+ "make-fetch-happen": "^15.0.4",
+ "proc-log": "^6.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/tuf": {
+ "version": "4.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/protobuf-specs": "^0.5.0",
+ "tuf-js": "^4.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@sigstore/verify": {
+ "version": "3.1.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/bundle": "^4.0.0",
+ "@sigstore/core": "^3.2.1",
+ "@sigstore/protobuf-specs": "^0.5.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/@tufjs/canonical-json": {
+ "version": "2.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^16.14.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/@tufjs/models": {
+ "version": "4.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tufjs/canonical-json": "2.0.0",
+ "minimatch": "^10.1.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/abbrev": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/agent-base": {
+ "version": "7.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/aproba": {
+ "version": "2.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/archy": {
+ "version": "1.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/bin-links": {
+ "version": "6.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "cmd-shim": "^8.0.0",
+ "npm-normalize-package-bin": "^5.0.0",
+ "proc-log": "^6.0.0",
+ "read-cmd-shim": "^6.0.0",
+ "write-file-atomic": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/binary-extensions": {
+ "version": "3.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm/node_modules/brace-expansion": {
+ "version": "5.0.7",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/cacache": {
+ "version": "20.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/fs": "^5.0.0",
+ "fs-minipass": "^3.0.0",
+ "glob": "^13.0.0",
+ "lru-cache": "^11.1.0",
+ "minipass": "^7.0.3",
+ "minipass-collect": "^2.0.1",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "p-map": "^7.0.2",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/chalk": {
+ "version": "5.6.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/npm/node_modules/chownr": {
+ "version": "3.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/npm/node_modules/ci-info": {
+ "version": "4.4.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/cidr-regex": {
+ "version": "5.0.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/npm/node_modules/cmd-shim": {
+ "version": "8.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/common-ancestor-path": {
+ "version": "2.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/npm/node_modules/cssesc": {
+ "version": "3.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/npm/node_modules/debug": {
+ "version": "4.4.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/npm/node_modules/diff": {
+ "version": "8.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/npm/node_modules/env-paths": {
+ "version": "2.2.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/npm/node_modules/exponential-backoff": {
+ "version": "3.1.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/npm/node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
+ "node_modules/npm/node_modules/fs-minipass": {
+ "version": "3.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/glob": {
+ "version": "13.0.6",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/hosted-git-info": {
+ "version": "9.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "lru-cache": "^11.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/npm/node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/npm/node_modules/ignore-walk": {
+ "version": "8.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minimatch": "^10.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/ini": {
+ "version": "6.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/init-package-json": {
+ "version": "8.2.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/package-json": "^7.0.0",
+ "npm-package-arg": "^13.0.0",
+ "promzard": "^3.0.1",
+ "read": "^5.0.1",
+ "semver": "^7.7.2",
+ "validate-npm-package-name": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/ip-address": {
+ "version": "10.2.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/npm/node_modules/is-cidr": {
+ "version": "6.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "cidr-regex": "^5.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/npm/node_modules/isexe": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/npm/node_modules/json-parse-even-better-errors": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/json-stringify-nice": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/jsonparse": {
+ "version": "1.3.1",
+ "dev": true,
+ "engines": [
+ "node >= 0.2.0"
+ ],
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/just-diff": {
+ "version": "6.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/just-diff-apply": {
+ "version": "5.5.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/libnpmaccess": {
+ "version": "10.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-package-arg": "^13.0.0",
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmdiff": {
+ "version": "8.1.12",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/arborist": "^9.9.1",
+ "@npmcli/installed-package-contents": "^4.0.0",
+ "binary-extensions": "^3.0.0",
+ "diff": "^8.0.2",
+ "minimatch": "^10.0.3",
+ "npm-package-arg": "^13.0.0",
+ "pacote": "^21.0.2",
+ "tar": "^7.5.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmexec": {
+ "version": "10.3.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/arborist": "^9.9.1",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "ci-info": "^4.0.0",
+ "npm-package-arg": "^13.0.0",
+ "pacote": "^21.0.2",
+ "proc-log": "^6.0.0",
+ "read": "^5.0.1",
+ "semver": "^7.3.7",
+ "signal-exit": "^4.1.0",
+ "walk-up-path": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmfund": {
+ "version": "7.0.26",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/arborist": "^9.9.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmorg": {
+ "version": "8.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^2.0.0",
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmpack": {
+ "version": "9.1.12",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/arborist": "^9.9.1",
+ "@npmcli/run-script": "^10.0.0",
+ "npm-package-arg": "^13.0.0",
+ "pacote": "^21.0.2"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmpublish": {
+ "version": "11.2.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/package-json": "^7.0.0",
+ "ci-info": "^4.0.0",
+ "npm-package-arg": "^13.0.0",
+ "npm-registry-fetch": "^19.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.7",
+ "sigstore": "^4.0.0",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmsearch": {
+ "version": "9.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmteam": {
+ "version": "8.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^2.0.0",
+ "npm-registry-fetch": "^19.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/libnpmversion": {
+ "version": "8.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/git": "^7.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "json-parse-even-better-errors": "^5.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.7"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/make-fetch-happen": {
+ "version": "15.0.6",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/agent": "^4.0.0",
+ "@npmcli/redact": "^4.0.0",
+ "cacache": "^20.0.1",
+ "http-cache-semantics": "^4.1.1",
+ "minipass": "^7.0.2",
+ "minipass-fetch": "^5.0.0",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^1.0.0",
+ "proc-log": "^6.0.0",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/minimatch": {
+ "version": "10.2.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/minipass": {
+ "version": "7.1.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-collect": {
+ "version": "2.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-fetch": {
+ "version": "5.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.0.3",
+ "minipass-sized": "^2.0.0",
+ "minizlib": "^3.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ },
+ "optionalDependencies": {
+ "iconv-lite": "^0.7.2"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-flush": {
+ "version": "1.0.6",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minipass": "^7.1.3"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": {
+ "version": "3.3.6",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/npm/node_modules/minipass-sized": {
+ "version": "2.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/npm/node_modules/minizlib": {
+ "version": "3.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/npm/node_modules/ms": {
+ "version": "2.1.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/mute-stream": {
+ "version": "3.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/negotiator": {
+ "version": "1.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/npm/node_modules/node-gyp": {
+ "version": "12.4.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "graceful-fs": "^4.2.6",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "tar": "^7.5.4",
+ "tinyglobby": "^0.2.12",
+ "undici": "^6.25.0",
+ "which": "^6.0.0"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/nopt": {
+ "version": "9.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^4.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-audit-report": {
+ "version": "7.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-bundled": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-normalize-package-bin": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-install-checks": {
+ "version": "8.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "semver": "^7.1.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-normalize-package-bin": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-package-arg": {
+ "version": "13.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "hosted-git-info": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "validate-npm-package-name": "^7.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-packlist": {
+ "version": "10.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "ignore-walk": "^8.0.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-pick-manifest": {
+ "version": "11.0.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-install-checks": "^8.0.0",
+ "npm-normalize-package-bin": "^5.0.0",
+ "npm-package-arg": "^13.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-profile": {
+ "version": "12.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "npm-registry-fetch": "^19.0.0",
+ "proc-log": "^6.1.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-registry-fetch": {
+ "version": "19.1.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/redact": "^4.0.0",
+ "jsonparse": "^1.3.1",
+ "make-fetch-happen": "^15.0.0",
+ "minipass": "^7.0.2",
+ "minipass-fetch": "^5.0.0",
+ "minizlib": "^3.0.1",
+ "npm-package-arg": "^13.0.0",
+ "proc-log": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/npm-user-validate": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/p-map": {
+ "version": "7.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm/node_modules/pacote": {
+ "version": "21.5.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "@gar/promise-retry": "^1.0.0",
+ "@npmcli/git": "^7.0.0",
+ "@npmcli/installed-package-contents": "^4.0.0",
+ "@npmcli/package-json": "^7.0.0",
+ "@npmcli/promise-spawn": "^9.0.0",
+ "@npmcli/run-script": "^10.0.0",
+ "cacache": "^20.0.0",
+ "fs-minipass": "^3.0.0",
+ "minipass": "^7.0.2",
+ "npm-package-arg": "^13.0.0",
+ "npm-packlist": "^10.0.1",
+ "npm-pick-manifest": "^11.0.1",
+ "npm-registry-fetch": "^19.0.0",
+ "proc-log": "^6.0.0",
+ "sigstore": "^4.0.0",
+ "ssri": "^13.0.0",
+ "tar": "^7.4.3"
+ },
+ "bin": {
+ "pacote": "bin/index.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/parse-conflict-json": {
+ "version": "5.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "json-parse-even-better-errors": "^5.0.0",
+ "just-diff": "^6.0.0",
+ "just-diff-apply": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/path-scurry": {
+ "version": "2.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/postcss-selector-parser": {
+ "version": "7.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/npm/node_modules/proc-log": {
+ "version": "6.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/proggy": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/promise-all-reject-late": {
+ "version": "1.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/promise-call-limit": {
+ "version": "3.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/promzard": {
+ "version": "3.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "read": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/qrcode-terminal": {
+ "version": "0.12.0",
+ "dev": true,
+ "inBundle": true,
+ "bin": {
+ "qrcode-terminal": "bin/qrcode-terminal.js"
+ }
+ },
+ "node_modules/npm/node_modules/read": {
+ "version": "5.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "mute-stream": "^3.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/read-cmd-shim": {
+ "version": "6.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/npm/node_modules/semver": {
+ "version": "7.8.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm/node_modules/signal-exit": {
+ "version": "4.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm/node_modules/sigstore": {
+ "version": "4.1.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@sigstore/bundle": "^4.0.0",
+ "@sigstore/core": "^3.2.1",
+ "@sigstore/protobuf-specs": "^0.5.0",
+ "@sigstore/sign": "^4.1.1",
+ "@sigstore/tuf": "^4.0.2",
+ "@sigstore/verify": "^3.1.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/socks": {
+ "version": "2.8.9",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/npm/node_modules/spdx-exceptions": {
+ "version": "2.5.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "CC-BY-3.0"
+ },
+ "node_modules/npm/node_modules/spdx-expression-parse": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/spdx-license-ids": {
+ "version": "3.0.23",
+ "dev": true,
+ "inBundle": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/npm/node_modules/ssri": {
+ "version": "13.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/supports-color": {
+ "version": "10.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/npm/node_modules/tar": {
+ "version": "7.5.19",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/npm/node_modules/text-table": {
+ "version": "0.2.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/tiny-relative-date": {
+ "version": "2.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/npm/node_modules/treeverse": {
+ "version": "3.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm/node_modules/tuf-js": {
+ "version": "4.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tufjs/models": "4.1.0",
+ "debug": "^4.4.3",
+ "make-fetch-happen": "^15.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/undici": {
+ "version": "6.27.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/npm/node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/npm/node_modules/validate-npm-package-name": {
+ "version": "7.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/walk-up-path": {
+ "version": "4.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/npm/node_modules/which": {
+ "version": "6.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^4.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/write-file-atomic": {
+ "version": "7.0.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "ISC",
+ "dependencies": {
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/npm/node_modules/yallist": {
+ "version": "5.0.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-each-series": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz",
+ "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-event": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz",
+ "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-timeout": "^6.1.2"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-filter": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz",
+ "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-map": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
+ "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+ "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz",
+ "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-reduce": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz",
+ "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
+ "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
+ "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse-ms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
+ "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
+ "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz",
+ "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^6.0.1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pkg-conf": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz",
+ "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "find-up": "^2.0.0",
+ "load-json-file": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pretty-ms": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
+ "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parse-ms": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/proxy-agent-negotiate": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
+ "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "kerberos": "^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "kerberos": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/read-package-up": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz",
+ "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "find-up-simple": "^1.0.1",
+ "read-pkg": "^10.0.0",
+ "type-fest": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz",
+ "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/normalize-package-data": "^2.4.4",
+ "normalize-package-data": "^8.0.0",
+ "parse-json": "^8.3.0",
+ "type-fest": "^5.4.4",
+ "unicorn-magic": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg/node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/read-pkg/node_modules/unicorn-magic": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz",
+ "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/registry-auth-token": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz",
+ "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pnpm/npm-conf": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/semantic-release": {
+ "version": "25.0.8",
+ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.8.tgz",
+ "integrity": "sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@semantic-release/commit-analyzer": "^13.0.1",
+ "@semantic-release/error": "^4.0.0",
+ "@semantic-release/github": "^12.0.0",
+ "@semantic-release/npm": "^13.1.1",
+ "@semantic-release/release-notes-generator": "^14.1.0",
+ "aggregate-error": "^5.0.0",
+ "cosmiconfig": "^9.0.0",
+ "debug": "^4.0.0",
+ "env-ci": "^11.0.0",
+ "execa": "^9.0.0",
+ "figures": "^6.0.0",
+ "find-versions": "^6.0.0",
+ "get-stream": "^6.0.0",
+ "git-log-parser": "^1.2.0",
+ "hook-std": "^4.0.0",
+ "hosted-git-info": "^9.0.0",
+ "import-from-esm": "^2.0.0",
+ "lodash-es": "^4.17.21",
+ "marked": "^15.0.0",
+ "marked-terminal": "^7.3.0",
+ "micromatch": "^4.0.2",
+ "p-each-series": "^3.0.0",
+ "p-reduce": "^3.0.0",
+ "read-package-up": "^12.0.0",
+ "resolve-from": "^5.0.0",
+ "semver": "^7.3.2",
+ "signale": "^1.2.1",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "semantic-release": "bin/semantic-release.js"
+ },
+ "engines": {
+ "node": "^22.14.0 || >= 24.10.0"
+ }
+ },
+ "node_modules/semantic-release-rubygem": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/semantic-release-rubygem/-/semantic-release-rubygem-1.2.0.tgz",
+ "integrity": "sha512-w/McMlrO0cJR7UdTx47tvDJuxRNr0vBf+Xv4nZQ5lOHCbNa07ZhYNRo3EfLxNfnlnCHUCWpYFGdpiofTHldrug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@semantic-release/error": "^2.2.0",
+ "execa": "^4.0.2",
+ "fs-extra": "^9.0.1",
+ "glob": "^7.1.6",
+ "tempy": "^0.5.0"
+ },
+ "engines": {
+ "node": ">=10.18"
+ },
+ "peerDependencies": {
+ "semantic-release": ">=16.0.0 <18.0.0"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/@semantic-release/error": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-2.2.0.tgz",
+ "integrity": "sha512-9Tj/qn+y2j+sjCI3Jd+qseGtHjOAeg7dU2/lVcqIQ9TV3QDaDXDYXcoOHU+7o2Hwh8L8ymL4gfuO7KxDs3q2zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/semantic-release-rubygem/node_modules/crypto-random-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
+ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/execa": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
+ "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "get-stream": "^5.0.0",
+ "human-signals": "^1.1.1",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.0",
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/human-signals": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
+ "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.12.0"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/semantic-release-rubygem/node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/temp-dir": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+ "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/tempy": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.5.0.tgz",
+ "integrity": "sha512-VEY96x7gbIRfsxqsafy2l5yVxxp3PhwAGoWMyC2D2Zt5DmEv+2tGiPOrquNRpf21hhGnKLVEsuqleqiZmKG/qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-stream": "^2.0.0",
+ "temp-dir": "^2.0.0",
+ "type-fest": "^0.12.0",
+ "unique-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/type-fest": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz",
+ "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semantic-release-rubygem/node_modules/unique-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
+ "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crypto-random-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/semantic-release/node_modules/execa": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+ "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "figures": "^6.1.0",
+ "get-stream": "^9.0.0",
+ "human-signals": "^8.0.1",
+ "is-plain-obj": "^4.1.0",
+ "is-stream": "^4.0.1",
+ "npm-run-path": "^6.0.0",
+ "pretty-ms": "^9.2.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^4.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.5.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+ "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sec-ant/readable-stream": "^0.4.1",
+ "is-stream": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semantic-release/node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver-regex": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz",
+ "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/signale": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz",
+ "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^2.3.2",
+ "figures": "^2.0.0",
+ "pkg-conf": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/signale/node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/signale/node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/signale/node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/signale/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/signale/node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/signale/node_modules/figures": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
+ "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^1.0.5"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/signale/node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/signale/node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/skin-tone": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz",
+ "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unicode-emoji-modifier-base": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/spawn-error-forwarder": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz",
+ "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/spdx-correct": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
+ "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-exceptions": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
+ "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
+ "dev": true,
+ "license": "CC-BY-3.0"
+ },
+ "node_modules/spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-license-ids": {
+ "version": "3.0.23",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz",
+ "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/split2": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz",
+ "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "through2": "~2.0.0"
+ }
+ },
+ "node_modules/stream-combiner2": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz",
+ "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "duplexer2": "~0.1.0",
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
+ "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/super-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz",
+ "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-timeout": "^1.0.1",
+ "make-asynchronous": "^1.0.1",
+ "time-span": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-hyperlinks": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
+ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
+ }
+ },
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/temp-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz",
+ "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/tempy": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz",
+ "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-stream": "^3.0.0",
+ "temp-dir": "^3.0.0",
+ "type-fest": "^2.12.2",
+ "unique-string": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tempy/node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tempy/node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/through2": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
+ "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "~2.3.6",
+ "xtend": "~4.0.1"
+ }
+ },
+ "node_modules/time-span": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz",
+ "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "convert-hrtime": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/traverse": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz",
+ "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tunnel": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "5.8.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
+ "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unicode-emoji-modifier-base": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz",
+ "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicorn-magic": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+ "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/unique-string": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
+ "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crypto-random-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/universal-user-agent": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
+ "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/url-join": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz",
+ "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "node_modules/web-worker": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
+ "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-command": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz",
+ "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "which-command": "cli.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/which-command?sponsor=1"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.1.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
+ "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^8.2.1",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/yoctocolors": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
+ "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 60015443..3f69ecf1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"name": "graphiti",
- "version": "1.4.0",
+ "private": true,
+ "version": "0.0.0-development",
"repository": {
"type": "git",
"url": "git+https://github.com/graphiti-api/graphiti.git"
@@ -15,12 +16,14 @@
},
"devDependencies": {
"semantic-release-rubygem": "^1.2.0",
- "semantic-release": "^19.0.3",
- "@semantic-release/changelog": "^6.0.1",
- "@semantic-release/git": "^10.0.1"
+ "semantic-release": "^25.0.8",
+ "@semantic-release/changelog": "^7.0.0",
+ "@semantic-release/git": "^11.0.1",
+ "@semantic-release/exec": "^7.1.0"
},
"release": {
"branches": [
+ "1.x",
"main",
{
"name": "beta",
@@ -130,13 +133,25 @@
}
],
"semantic-release-rubygem",
- "@semantic-release/github",
+ [
+ "@semantic-release/github",
+ {
+ "successCommentCondition": "<% return !branch.prerelease; %>"
+ }
+ ],
+ [
+ "@semantic-release/exec",
+ {
+ "prepareCmd": "bundle exec ruby spec/performance/measure_releases.rb --promote v${nextRelease.version}"
+ }
+ ],
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md",
- "lib/graphiti/version.rb"
+ "lib/graphiti/version.rb",
+ "spec/performance/performance_history.tsv"
],
"message": "${nextRelease.version} CHANGELOG [skip ci]\n\n${nextRelease.notes}"
}
diff --git a/spec/audit_spec.rb b/spec/audit_spec.rb
new file mode 100644
index 00000000..f616f48d
--- /dev/null
+++ b/spec/audit_spec.rb
@@ -0,0 +1,588 @@
+require "spec_helper"
+
+RSpec.describe Graphiti::Audit do
+ def audit(resource_class)
+ described_class.findings([resource_class])
+ end
+
+ def finding(resource_class, check)
+ audit(resource_class).find { |f| f.check == check }
+ end
+
+ describe "missing association method" do
+ let(:resource) do
+ Class.new(PORO::TeamResource) do
+ def self.name
+ "PORO::TeamResource"
+ end
+
+ has_many :employees
+ end
+ end
+
+ it "reports the resource, the relationship and the model" do
+ row = described_class.run([resource]).find { |r| r.relationship == :employees }
+
+ expect(row.resource).to eq("PORO::TeamResource")
+ expect(row.type).to eq(:has_many)
+ expect(row.resource_ids_source).to eq(:none)
+ expect(row.severity).to eq(:error)
+ expect(row.findings.first.message).to eq("PORO::Team has no #employees method")
+ end
+
+ it "accepts a private method, since rendering only needs it to exist" do
+ PORO::Team.class_eval { private def employees = [] }
+
+ expect(finding(resource, :missing_association_method)).to be_nil
+ ensure
+ PORO::Team.send(:remove_method, :employees)
+ end
+
+ it "follows `as:` rather than the relationship name" do
+ klass = Class.new(PORO::TeamResource) do
+ def self.name
+ "PORO::TeamResource"
+ end
+
+ has_many :staff, as: :employees, resource: PORO::EmployeeResource
+ end
+
+ expect(finding(klass, :missing_association_method).message)
+ .to eq("PORO::Team has no #employees method")
+ end
+ end
+
+ describe "missing guard method" do
+ it "reports a readable guard defined on neither resource" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee, readable: :nobody_defines_this?
+ end
+
+ found = finding(klass, :missing_guard_method)
+
+ expect(found.severity).to eq(:error)
+ expect(found.message).to match(/#nobody_defines_this\? is defined on neither/)
+ end
+
+ it "accepts a guard defined on the declaring resource" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee, readable: :admin?
+
+ def admin?
+ true
+ end
+ end
+
+ expect(finding(klass, :missing_guard_method)).to be_nil
+ end
+
+ it "ignores a proc guard, which has no name to look up" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee, readable: -> { true }
+ end
+
+ expect(finding(klass, :missing_guard_method)).to be_nil
+ end
+ end
+
+ describe "rendering ids by loading" do
+ def row_for(resource_class)
+ described_class.run([resource_class]).find { |row| row.relationship == :employee }
+ end
+
+ it "reports :load without a finding, since opting in is a choice, not a defect" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee, resource_ids: true do
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
+ end
+ end
+
+ row = row_for(klass)
+
+ expect(row.resource_ids_source).to eq(:load)
+ expect(row.findings).to eq([])
+ end
+
+ describe "whether base_scope preloads the loaded association" do
+ let(:scope_class) { Struct.new(:includes_values, :preload_values, :eager_load_values) }
+
+ def loading_resource(base_scope)
+ Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ define_method(:base_scope) { base_scope }
+
+ belongs_to :employee, resource_ids: true do
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
+ end
+ end
+ end
+
+ it "sees a preload, including a nested one" do
+ row = row_for(loading_resource(scope_class.new([{employee: :department}], [], [])))
+
+ expect(row.preloaded).to eq(true)
+ end
+
+ it "sees a missing preload" do
+ row = row_for(loading_resource(scope_class.new([:other], [], [])))
+
+ expect(row.preloaded).to eq(false)
+ end
+
+ it "answers nil for a scope with no preload lists to read" do
+ row = row_for(loading_resource({type: :positions}))
+
+ expect(row.preloaded).to be_nil
+ end
+ end
+
+ it "reports :load under an API-wide :always" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ self.belongs_to_resource_ids_by_default = :always
+
+ belongs_to :employee, primary_key: :first_name
+ end
+
+ expect(row_for(klass).resource_ids_source).to eq(:load)
+ end
+
+ it "reports :key for a relationship reading ids off the foreign key" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee
+ end
+
+ expect(row_for(klass).resource_ids_source).to eq(:key)
+ end
+
+ it "notes nothing under :never, which asked for exactly this" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ self.belongs_to_resource_ids_by_default = :never
+
+ belongs_to :employee do
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
+ end
+ end
+
+ row = row_for(klass)
+
+ expect(row.resource_ids_source).to eq(:none)
+ expect(row.would_start_loading?).to eq(false)
+ end
+
+ it "reports :none for a relationship rendering no ids at all" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee do
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
+ end
+ end
+
+ expect(row_for(klass).resource_ids_source).to eq(:none)
+ end
+ end
+
+ describe "a relationship that raises while being inspected" do
+ let(:resource) do
+ Class.new(PORO::PositionResource) do
+ def self.name
+ "NotNamespacedResource"
+ end
+
+ belongs_to :employee
+ end
+ end
+
+ it "reports it rather than taking the whole report down" do
+ rows = described_class.run([resource])
+ row = rows.find { |r| r.relationship == :employee }
+
+ expect(row.severity).to eq(:error)
+ expect(row.findings.first.check).to eq(:broken_relationship)
+ expect(row.findings.first.message)
+ .to match(/Could not find resource class for sideload 'employee'/)
+ end
+
+ it "still reports the resource's other relationships" do
+ expect(described_class.run([resource]).map(&:relationship)).to include(:department)
+ end
+ end
+
+ describe "a clean resource" do
+ it "reports nothing" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee
+ end
+
+ expect(audit(klass)).to eq([])
+ end
+ end
+end
+
+RSpec.describe Graphiti::Audit::Report do
+ let(:rows) do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee
+ end
+
+ Graphiti::Audit.run([klass])
+ end
+
+ describe "a relationship loading to render ids" do
+ def output_for(base_scope)
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ define_method(:base_scope) { base_scope }
+
+ belongs_to :employee, resource_ids: true do
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
+ end
+ end
+
+ described_class.new(Graphiti::Audit.run([klass]), color: false).to_s
+ end
+
+ let(:scope_class) { Struct.new(:includes_values, :preload_values, :eager_load_values) }
+
+ it "warns when base_scope does not preload the association" do
+ output = output_for(scope_class.new([], [], []))
+
+ expect(output).to include("WARNING rendering resource ids by loading an association the base_scope does not preload")
+ expect(output).to include("fix: preload the association in base_scope, or drop `resource_ids`")
+ expect(output).to include("1 loading ids without preloading")
+ end
+
+ it "says nothing when base_scope preloads it" do
+ output = output_for(scope_class.new([:employee], [], []))
+
+ expect(output).to_not include("WARNING")
+ expect(output).to include("✓ all id-rendering loads preloaded")
+ expect(output).to_not include("(none)")
+ end
+
+ it "drops the check when nothing loads to render ids" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ self.belongs_to_resource_ids_by_default = :never
+
+ belongs_to :employee
+ end
+
+ output = described_class.new(Graphiti::Audit.run([klass]), color: false).to_s
+
+ expect(output).to_not include("id-rendering loads")
+ end
+
+ it "says nothing when the scope cannot be inspected" do
+ expect(output_for({type: :positions})).to_not include("WARNING")
+ end
+ end
+
+ it "lists every check, with a count for the failed ones" do
+ output = described_class.new(rows, color: false).to_s
+
+ expect(output).to include("checks")
+ expect(output).to include("✓ all association methods defined")
+ expect(output).to include("✓ all sideload filters declared")
+ end
+
+ it "marks a failed check with a red x and the count" do
+ klass = Class.new(PORO::TeamResource) do
+ def self.name
+ "PORO::TeamResource"
+ end
+
+ has_many :employees
+ end
+
+ output = described_class.new(Graphiti::Audit.run([klass]), color: false).to_s
+
+ expect(output).to include("✗ 1 association method missing")
+ expect(output).to include("✗ 1 sideload filter missing")
+ end
+
+ it "shows only the passed checks when there is nothing wrong" do
+ output = described_class.new(rows, color: false).to_s
+
+ expect(output).to_not include("ERROR")
+ expect(output).to_not include("FYI")
+ expect(output.scan(/^ ✓ /).size).to eq(Graphiti::Audit::Report::CHECKLIST.size - 1)
+ expect(output).to_not include("✗")
+ end
+
+ it "repeats back the options that differ from the defaults" do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee, primary_key: :first_name, single: true, link: false
+ end
+
+ output = described_class.new(Graphiti::Audit.run([klass]), color: false).to_s
+
+ expect(output).to include("belongs_to :employee, primary_key: :first_name, single: true, link: false")
+ end
+
+ it "summarises" do
+ output = described_class.new(rows, color: false).to_s
+
+ expect(output).to match(/1 resource, \d+ relationships, 0 errors/)
+ end
+
+ it "says so when there is nothing to report" do
+ expect(described_class.new([]).to_s).to eq("graphiti: no relationships found.\n")
+ end
+
+ describe "the :always column" do
+ let(:rows) do
+ klass = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ belongs_to :employee do
+ scope { |ids| {type: :employees, conditions: {id: ids}} }
+ end
+ end
+
+ Graphiti::Audit.run([klass])
+ end
+
+ let(:row) { rows.find { |r| r.relationship == :employee } }
+
+ it "shows what a relationship would cost without changing what it does now" do
+ expect(row.resource_ids_source).to eq(:none)
+ expect(row.resource_ids_source_if_always).to eq(:load)
+ expect(row.would_start_loading?).to eq(true)
+ end
+
+ it "leaves the resource's own setting untouched" do
+ rows
+
+ expect(PORO::PositionResource.belongs_to_resource_ids_by_default).to eq(:foreign_key)
+ end
+
+ it "shows which relationships would start loading, and why" do
+ output = described_class.new(rows, color: false).to_s
+
+ expect(output).to include("1 belongs_to relationship renders no resource ids")
+ expect(output).to include("To render ids on every response, opt in:")
+ expect(output).to match(/^ resource_ids: true\s+on one relationship$/)
+ expect(output).to match(/^ PORO::PositionResource\n belongs_to :employee/)
+ expect(output).to include("Nothing to fix.")
+ expect(output).to match(/1 without resource ids/)
+ end
+ end
+end
+
+RSpec.describe "Graphiti::Audit::Report colour" do
+ let(:rows) do
+ klass = Class.new(PORO::TeamResource) do
+ def self.name
+ "PORO::TeamResource"
+ end
+
+ has_many :employees
+ end
+
+ Graphiti::Audit.run([klass])
+ end
+
+ it "is off when the output is not a terminal" do
+ expect(Graphiti::Audit::Report.new(rows, color: false).to_s).to_not include("\e[")
+ end
+
+ it "paints an error row red when it is" do
+ output = Graphiti::Audit::Report.new(rows, color: true).to_s
+
+ expect(output).to include("\e[31;1mERROR")
+ end
+
+ it "keeps columns aligned regardless of colour" do
+ plain = Graphiti::Audit::Report.new(rows, color: false).to_s
+ painted = Graphiti::Audit::Report.new(rows, color: true).to_s.gsub(/\e\[[\d;]+m/, "")
+
+ expect(painted).to eq(plain)
+ end
+end
+
+RSpec.describe "Graphiti::Audit::Report layout" do
+ let(:rows) do
+ visa = PORO::VisaResource
+ klass = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+
+ has_many :positions
+ belongs_to :classification
+ polymorphic_belongs_to :credit_card do
+ group_by(:credit_card_type) do
+ on(:Visa).belongs_to :visa, resource: visa
+ end
+ end
+ end
+
+ Graphiti::Audit.run([klass])
+ end
+
+ it "groups relationships with the same issue under one heading, stating the fix once" do
+ team = Class.new(PORO::TeamResource) do
+ def self.name
+ "PORO::TeamResource"
+ end
+
+ has_many :employees
+ end
+ position = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ has_many :teams, resource: PORO::TeamResource
+ end
+
+ output = Graphiti::Audit::Report.new(Graphiti::Audit.run([team, position]), color: false).to_s
+
+ expect(output.scan("ERROR will raise when the relationship is included: the model has no association method").size).to eq(1)
+ expect(output.scan(/^ fix: define it/).size).to eq(1)
+ expect(output).to match(/PORO::PositionResource\s+has_many :teams\s+PORO::Position has no #teams method/)
+ expect(output).to match(/PORO::TeamResource\s+has_many :employees\s+PORO::Team has no #employees method/)
+ end
+
+ it "names the children of a polymorphic_belongs_to rather than nothing" do
+ row = rows.find { |r| r.relationship == :credit_card }
+
+ expect(row.target).to eq("VisaResource")
+ end
+end
+
+RSpec.describe "Graphiti::Audit new checks" do
+ def finding(resource_class, check, relationship: :positions)
+ row = Graphiti::Audit.run([resource_class]).find { |r| r.relationship == relationship }
+ row.findings.find { |f| f.check == check }
+ end
+
+ describe "missing sideload filter" do
+ let(:unfilterable) do
+ Class.new(Graphiti::Resource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+ self.model = PORO::Position
+ self.adapter = Graphiti::Adapters::Null
+ self.type = :positions
+ attribute :title, :string
+ end
+ end
+
+ it "reports a has_many whose resource cannot be filtered by the foreign key" do
+ target = unfilterable
+ klass = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+
+ has_many :positions, resource: target
+ end
+
+ found = finding(klass, :missing_sideload_filter)
+
+ expect(found.severity).to eq(:error)
+ expect(found.message).to match(/is missing `filter :employee_id`/)
+ expect(found.remedy).to eq("declare the filter on the related resource")
+ end
+
+ it "says nothing when a params block builds the query, since it can replace the filter" do
+ target = unfilterable
+ klass = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+
+ has_many :positions, resource: target do
+ params do |hash|
+ hash[:filter] = {id: 1}
+ end
+ end
+ end
+
+ expect(finding(klass, :missing_sideload_filter)).to be_nil
+ end
+
+ it "says nothing when a scope block builds the query" do
+ target = unfilterable
+ klass = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+
+ has_many :positions, resource: target do
+ scope { |ids| {type: :positions, conditions: {employee_id: ids}} }
+ end
+ end
+
+ expect(finding(klass, :missing_sideload_filter)).to be_nil
+ end
+
+ it "says nothing when the filter is there" do
+ klass = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+
+ has_many :positions
+ end
+
+ expect(finding(klass, :missing_sideload_filter)).to be_nil
+ end
+ end
+end
diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb
index ef99e516..5b4fab63 100644
--- a/spec/configuration_spec.rb
+++ b/spec/configuration_spec.rb
@@ -24,52 +24,6 @@
end
end
- # FIXME: Deprecated
- describe "when rails is defined" do
- let(:logger) { double(debug?: false) }
-
- let(:rails) do
- double(root: Pathname.new("/foo/bar"), logger: logger)
- end
-
- before do
- stub_const("::Rails", rails)
- Graphiti.instance_variable_set(:@config, nil)
- end
-
- after do
- Graphiti.instance_variable_set(:@config, nil)
- end
-
- describe "#schema_path" do
- it "defaults" do
- expect(Graphiti.config.schema_path.to_s)
- .to eq("/foo/bar/public/schema.json")
- end
- end
-
- describe "#debug" do
- subject { Graphiti.config.debug }
-
- # FIXME: Deprecated
- context "when rails logger is debug level" do
- let(:logger) { double(debug?: true) }
-
- it { is_expected.to eq(true) }
- end
-
- # FIXME: Deprecated
- context "when rails logger is not debug level" do
- it { is_expected.to eq(false) }
- end
- end
-
- it "sets the graphiti logger to the rails logger" do
- Graphiti.config
- expect(Graphiti.logger).to eq(rails.logger)
- end
- end
-
describe "#debug=" do
it "toggles Debugger.enabled" do
Graphiti.config.debug = true
@@ -103,20 +57,6 @@
Graphiti.config.schema_path = "foo"
expect(Graphiti.config.schema_path).to eq("foo")
end
-
- # FIXME: Deprecated
- context "when Rails is defined" do
- before do
- rails = double(root: Pathname.new("/foo/bar"), logger: double.as_null_object)
- stub_const("::Rails", rails)
- Graphiti.instance_variable_set(:@config, nil)
- end
-
- it "defaults" do
- expect(Graphiti.config.schema_path.to_s)
- .to eq("/foo/bar/public/schema.json")
- end
- end
end
describe "#respond_to" do
@@ -180,6 +120,64 @@
end
end
+ describe "deprecated resource-level settings" do
+ around do |e|
+ relationship_links = Graphiti::Resource.relationship_links
+ page_links = Graphiti::Resource.page_links
+ typecast_reads = Graphiti::Resource.typecast_reads
+ e.run
+ ensure
+ Graphiti::Resource.relationship_links = relationship_links
+ Graphiti::Resource.page_links = page_links
+ Graphiti::Resource.typecast_reads = typecast_reads
+ end
+
+ describe "#typecast_reads" do
+ it "maps to Resource.typecast_reads" do
+ Graphiti.config.typecast_reads = false
+ expect(Graphiti::Resource.typecast_reads).to eq(false)
+ expect(Graphiti.config.typecast_reads).to eq(false)
+ end
+ end
+
+ describe "#links_on_demand" do
+ it "maps to Resource.relationship_links" do
+ Graphiti.config.links_on_demand = true
+ expect(Graphiti::Resource.relationship_links).to eq(:on_demand)
+ expect(Graphiti.config.links_on_demand).to eq(true)
+
+ Graphiti.config.links_on_demand = false
+ expect(Graphiti::Resource.relationship_links).to eq(true)
+ expect(Graphiti.config.links_on_demand).to eq(false)
+ end
+ end
+
+ describe "#pagination_links" do
+ it "maps to Resource.page_links" do
+ Graphiti.config.pagination_links = true
+ expect(Graphiti::Resource.page_links).to eq(true)
+ expect(Graphiti.config.pagination_links).to eq(true)
+ end
+
+ it "does not clobber :on_demand" do
+ Graphiti.config.pagination_links_on_demand = true
+ Graphiti.config.pagination_links = false
+ expect(Graphiti::Resource.page_links).to eq(:on_demand)
+ end
+ end
+
+ describe "#pagination_links_on_demand" do
+ it "maps to Resource.page_links" do
+ Graphiti.config.pagination_links_on_demand = true
+ expect(Graphiti::Resource.page_links).to eq(:on_demand)
+ expect(Graphiti.config.pagination_links_on_demand).to eq(true)
+
+ Graphiti.config.pagination_links_on_demand = false
+ expect(Graphiti::Resource.page_links).to eq(false)
+ end
+ end
+ end
+
describe "#cache_rendering" do
it "defaults" do
expect(Graphiti.config.cache_rendering?).to eq(false)
diff --git a/spec/current_attributes_spec.rb b/spec/current_attributes_spec.rb
new file mode 100644
index 00000000..0915fc5c
--- /dev/null
+++ b/spec/current_attributes_spec.rb
@@ -0,0 +1,77 @@
+require "spec_helper"
+require "active_support/current_attributes"
+
+module CurrentAttributesTest
+ class Current < ActiveSupport::CurrentAttributes
+ attribute :user
+ end
+end
+
+RSpec.describe "CurrentAttributes propagation into concurrent sideloads" do
+ include ConcurrencyHarness
+
+ include_context "resource testing"
+
+ let(:resource) do
+ Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+ end
+ end
+ let(:base_scope) { {type: :employees} }
+
+ let!(:employee) { PORO::Employee.create }
+ let!(:position) { PORO::Position.create(employee_id: employee.id) }
+
+ around do |example|
+ PORO::Employee.class_eval { attr_accessor :probe_positions }
+ example.run
+ ensure
+ PORO::Employee.class_eval do
+ remove_method :probe_positions
+ remove_method :probe_positions=
+ end
+ end
+
+ before do
+ allow(Graphiti.config).to receive(:concurrency).and_return(true)
+ with_thread_pool(max_threads: 2)
+ end
+
+ after { CurrentAttributesTest::Current.reset }
+
+ it "carries Current values onto the pool thread" do
+ observed = nil
+ resource.has_many :probe_positions, resource: PORO::PositionResource, foreign_key: :employee_id do
+ scope do |employee_ids|
+ observed = {thread: Thread.current.object_id, user: CurrentAttributesTest::Current.user}
+ {type: :positions, conditions: {employee_id: employee_ids}}
+ end
+ end
+
+ CurrentAttributesTest::Current.user = "jeff"
+ # Two sideloads, because one resolves inline and never reaches the pool.
+ params[:include] = "probe_positions,positions"
+ resource.all(params).to_a
+
+ expect(observed[:thread]).to_not eq(Thread.current.object_id)
+ expect(observed[:user]).to eq("jeff")
+ end
+
+ it "keeps a sideload's writes off the request thread" do
+ resource.has_many :probe_positions, resource: PORO::PositionResource, foreign_key: :employee_id do
+ scope do |employee_ids|
+ CurrentAttributesTest::Current.user = "someone else"
+ {type: :positions, conditions: {employee_id: employee_ids}}
+ end
+ end
+
+ CurrentAttributesTest::Current.user = "jeff"
+ # Two sideloads, because one resolves inline and never reaches the pool.
+ params[:include] = "probe_positions,positions"
+ resource.all(params).to_a
+
+ expect(CurrentAttributesTest::Current.user).to eq("jeff")
+ end
+end
diff --git a/spec/deep_filter_dedup_spec.rb b/spec/deep_filter_dedup_spec.rb
new file mode 100644
index 00000000..71934d49
--- /dev/null
+++ b/spec/deep_filter_dedup_spec.rb
@@ -0,0 +1,51 @@
+require "spec_helper"
+
+RSpec.describe "deep filter vs dedup" do
+ let!(:position_resource) do
+ Class.new(PORO::ApplicationResource) do
+ def self.name
+ "RtPositionResource"
+ end
+ self.model = PORO::Position
+ self.type = :positions
+ attribute :employee_id, :integer, only: [:filterable]
+ attribute :title, :string
+ attribute :rank, :integer
+ end
+ end
+
+ let!(:employee_resource) do
+ positions = position_resource
+ klass = Class.new(PORO::ApplicationResource) do
+ def self.name
+ "RtEmployeeResource"
+ end
+ self.model = PORO::Employee
+ self.type = :employees
+ attribute :first_name, :string
+ has_many :positions, resource: positions, foreign_key: :employee_id
+ end
+ positions.belongs_to :employee, resource: klass, foreign_key: :employee_id
+ klass
+ end
+
+ before do
+ PORO::DB.clear
+ employee = PORO::Employee.create(first_name: "A")
+ PORO::Position.create(employee_id: employee.id, rank: 1, title: "one")
+ PORO::Position.create(employee_id: employee.id, rank: 2, title: "two")
+ end
+
+ def positions_for(params)
+ json = JSON.parse(employee_resource.all(params).to_jsonapi)
+ json["data"][0]["relationships"]["positions"]["data"].map { |r| r["id"] }.sort
+ end
+
+ it "narrows on the shallow path" do
+ expect(positions_for(include: "positions", filter: {"positions.rank" => 1})).to eq(["1"])
+ end
+
+ it "still narrows when the include round-trips back to positions" do
+ expect(positions_for(include: "positions.employee.positions", filter: {"positions.rank" => 1})).to eq(["1"])
+ end
+end
diff --git a/spec/deprecated_constants_spec.rb b/spec/deprecated_constants_spec.rb
new file mode 100644
index 00000000..213b9228
--- /dev/null
+++ b/spec/deprecated_constants_spec.rb
@@ -0,0 +1,135 @@
+require "graphiti_spec_helpers/rspec"
+
+RSpec.describe "deprecated constants" do
+ def silenced
+ Graphiti::DEPRECATOR.silence { yield }
+ end
+
+ it "resolves to Graphiti::SpecHelpers" do
+ # Compared inside the block. The matcher would otherwise touch the proxy
+ # after silencing has ended.
+ expect(silenced { GraphitiSpecHelpers == Graphiti::SpecHelpers }).to eq(true)
+ end
+
+ it "resolves nested constants" do
+ expect(silenced { GraphitiSpecHelpers::RSpec }).to eq(Graphiti::SpecHelpers::RSpec)
+ expect(silenced { GraphitiSpecHelpers::Sugar }).to eq(Graphiti::SpecHelpers::Sugar)
+ expect(silenced { GraphitiSpecHelpers::Node }).to eq(Graphiti::SpecHelpers::Node)
+ expect(silenced { GraphitiSpecHelpers::Errors::NoSideloads })
+ .to eq(Graphiti::SpecHelpers::Errors::NoSideloads)
+ end
+
+ it "can still be included" do
+ klass = silenced { Class.new { include GraphitiSpecHelpers } }
+
+ expect(klass.ancestors).to include(Graphiti::SpecHelpers::Helpers)
+ end
+
+ # The bare constant just hands back the proxy. It is resolving through it
+ # that reports the deprecation.
+ it "warns when used" do
+ expect(Graphiti::DEPRECATOR).to receive(:warn).at_least(:once).and_return(nil)
+
+ GraphitiSpecHelpers::Node
+ end
+
+ describe "Graphiti::SpecHelpers::Sugar" do
+ it "warns on include but the aliases still work" do
+ expect(Graphiti::DEPRECATOR).to receive(:warn).once.and_return(nil)
+
+ klass = Class.new {
+ include Graphiti::SpecHelpers::Sugar
+
+ def jsonapi_data
+ "data"
+ end
+
+ def jsonapi_errors
+ "errors"
+ end
+ }
+ instance = klass.new
+
+ expect(instance.d).to eq("data")
+ expect(instance.errors).to eq("errors")
+ end
+ end
+
+ describe "the bare shared context names" do
+ it "are still registered alongside the graphiti-prefixed ones" do
+ registry = ::RSpec.world.shared_example_group_registry.send(:shared_example_groups)[:main]
+
+ expect(registry.keys).to include(
+ "graphiti resource testing", "resource testing",
+ "graphiti remote api", "remote api"
+ )
+ end
+ end
+
+ describe "the graphiti_errors serializers" do
+ {
+ "GraphitiErrors::Validation::Serializer" => Graphiti::ErrorSerializers::Validation,
+ "GraphitiErrors::InvalidRequest::Serializer" => Graphiti::ErrorSerializers::InvalidRequest,
+ "GraphitiErrors::ConflictRequest::Serializer" => Graphiti::ErrorSerializers::ConflictRequest,
+ "GraphitiErrors::Serializers::Validation" => Graphiti::ErrorSerializers::Validation
+ }.each do |old_name, target|
+ it "resolves #{old_name}" do
+ resolved = silenced { Object.const_get(old_name) == target }
+
+ expect(resolved).to eq(true)
+ end
+ end
+
+ # These proxies are leaves. Naming one hands back the proxy, and it is
+ # calling through it that reports the deprecation.
+ it "warns when used" do
+ expect(Graphiti::DEPRECATOR).to receive(:warn).at_least(:once).and_return(nil)
+
+ GraphitiErrors::Validation::Serializer.new(Object.new)
+ end
+
+ # The mixin and its exception handlers are replaced by rescue_registry, not
+ # renamed, so there is nothing for them to point at.
+ it "does not resurrect the exception handlers" do
+ expect(defined?(GraphitiErrors::ExceptionHandler)).to be_nil
+ end
+
+ it "still answers #rendered_errors with the payload" do
+ serializer = Graphiti::ErrorSerializers::InvalidRequest
+ .new(double(details: {}, messages: {}))
+
+ expect(Graphiti::DEPRECATOR).to receive(:deprecation_warning)
+ .with("#rendered_errors", a_string_including("#errors"))
+
+ expect(serializer.rendered_errors).to eq([])
+ end
+ end
+
+ describe "GraphitiContextProxy" do
+ it "resolves to Graphiti::SpecHelpers::ContextProxy" do
+ resolved = silenced { GraphitiContextProxy == Graphiti::SpecHelpers::ContextProxy }
+
+ expect(resolved).to eq(true)
+ end
+ end
+
+ describe "include GraphitiErrors" do
+ it "raises with the replacement rather than doing nothing" do
+ expect {
+ Class.new { include GraphitiErrors }
+ }.to raise_error(/include Graphiti::Rails::Controller/)
+ end
+ end
+
+ describe "deprecated require paths" do
+ {
+ "graphiti_spec_helpers" => "Graphiti::SpecHelpers",
+ "graphiti_errors" => "Graphiti::ErrorSerializers::Validation"
+ }.each do |path, still_available|
+ it "#{path.inspect} still loads" do
+ expect { silenced { require path } }.to_not raise_error
+ expect(Object.const_defined?(still_available)).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/deserializer_spec.rb b/spec/deserializer_spec.rb
index 803ec5e9..2757fca3 100644
--- a/spec/deserializer_spec.rb
+++ b/spec/deserializer_spec.rb
@@ -51,14 +51,14 @@
relationships: {
positions: {
data: [
- {'temp-id': "abc123", type: "positions", method: "create"}
+ {"temp-id": "abc123", type: "positions", method: "create"}
]
}
}
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "positions",
attributes: {title: "specialist"}
}
@@ -99,12 +99,12 @@
payload[:included][0][:relationships] = {
department: {
data: {
- type: "departments", 'temp-id': "def456", method: "create"
+ type: "departments", "temp-id": "def456", method: "create"
}
}
}
payload[:included] << {
- 'temp-id': "def456",
+ "temp-id": "def456",
type: "departments",
attributes: {name: "safety"}
}
@@ -148,21 +148,21 @@
relationships: {
positions: {
data: [
- {'temp-id': "abc123", type: "positions", method: "create"},
- {'temp-id': "ghi789", type: "positions", method: "create"}
+ {"temp-id": "abc123", type: "positions", method: "create"},
+ {"temp-id": "ghi789", type: "positions", method: "create"}
]
}
}
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "positions",
attributes: {title: "pos 1"},
relationships: {
department: {
data: {
- 'temp-id': "def456",
+ "temp-id": "def456",
type: "departments",
method: "create"
}
@@ -171,13 +171,13 @@
},
{
type: "positions",
- 'temp-id': "ghi789",
+ "temp-id": "ghi789",
method: "create",
attributes: {title: "pos 2"},
relationships: {
department: {
data: {
- 'temp-id': "d3p2",
+ "temp-id": "d3p2",
method: "create",
type: "departments"
}
@@ -186,33 +186,33 @@
},
{
type: "departments",
- 'temp-id': "def456",
+ "temp-id": "def456",
method: "create",
attributes: {name: "safety"},
relationships: {
tags: {
data: [
- {'temp-id': "t4g1", type: "tags", method: "create"}
+ {"temp-id": "t4g1", type: "tags", method: "create"}
]
}
}
},
{
type: "departments",
- 'temp-id': "d3p1",
+ "temp-id": "d3p1",
method: "create",
attributes: {name: "another dept"},
relationships: {
tags: {
data: [
- {'temp-id': "t4g1", type: "tags", method: "destroy"}
+ {"temp-id": "t4g1", type: "tags", method: "destroy"}
]
}
}
},
{
type: "tags",
- 'temp-id': "t4g1",
+ "temp-id": "t4g1",
attributes: {name: "foo"}
}
]
diff --git a/spec/error_serializers_spec.rb b/spec/error_serializers_spec.rb
new file mode 100644
index 00000000..b4f4c114
--- /dev/null
+++ b/spec/error_serializers_spec.rb
@@ -0,0 +1,129 @@
+RSpec.describe Graphiti::ErrorSerializers do
+ describe Graphiti::ErrorSerializers::InvalidRequest do
+ let(:source) do
+ Graphiti::Util::SimpleErrors.new(Object.new).tap do |errors|
+ errors.add(:"filter.title", :unsupported, message: "is not supported")
+ end
+ end
+
+ subject(:errors) { described_class.new(source).errors }
+
+ it "renders one error per message" do
+ source.add(:"sort.name", :unsupported, message: "is not supported")
+
+ expect(errors.length).to eq(2)
+ end
+
+ it "reports the code, status and title" do
+ expect(errors[0]).to include(
+ code: "bad_request",
+ status: "400",
+ title: "Request Error"
+ )
+ end
+
+ it "takes its title from a locale key named after the code" do
+ original = I18n.backend
+ I18n.backend = I18n::Backend::Simple.new
+ I18n.backend.store_translations(:en, graphiti: {errors: {bad_request: {title: "Equestray Errorway"}}})
+
+ expect(errors[0][:title]).to eq("Equestray Errorway")
+ ensure
+ I18n.backend = original
+ end
+
+ it "turns the attribute path into a JSON pointer" do
+ source.add(:"filter.tags[0]", :unsupported, message: "is not supported")
+
+ expect(errors.map { |error| error[:source][:pointer] })
+ .to eq(["filter/title", "filter/tags/0"])
+ end
+
+ it "carries the attribute, message and code in meta" do
+ expect(errors[0][:meta]).to eq(
+ attribute: :"filter.title",
+ message: "is not supported",
+ code: :unsupported
+ )
+ end
+ end
+
+ describe Graphiti::ErrorSerializers::ConflictRequest do
+ let(:source) do
+ Graphiti::Util::SimpleErrors.new(Object.new).tap do |errors|
+ errors.add(:id, :conflict, message: "does not match")
+ end
+ end
+
+ subject(:error) { described_class.new(source).errors[0] }
+
+ it "reports a conflict in the status, code and title alike" do
+ expect(error).to include(
+ code: "conflict",
+ status: "409",
+ title: "Conflict Error"
+ )
+ end
+
+ it "describes the failure the same way InvalidRequest does" do
+ invalid = Graphiti::ErrorSerializers::InvalidRequest.new(source).errors[0]
+
+ expect(error[:detail]).to eq(invalid[:detail])
+ expect(error[:source]).to eq(invalid[:source])
+ expect(error[:meta]).to eq(invalid[:meta])
+ end
+ end
+
+ describe Graphiti::ErrorSerializers::Validation do
+ let(:object) do
+ Class.new do
+ include ActiveModel::Validations
+
+ attr_accessor :name
+
+ def self.name
+ "Author"
+ end
+
+ validates :name, presence: true
+ end.new
+ end
+
+ subject(:errors) { described_class.new(object).errors }
+
+ before { object.valid? }
+
+ it "renders a 422 validation error per failure" do
+ expect(errors).to match([a_hash_including(
+ code: "unprocessable_entity",
+ status: "422",
+ title: "Validation Error",
+ detail: "Name can't be blank",
+ source: {pointer: "/data/attributes/name"}
+ )])
+ end
+
+ it "carries the attribute, message and code in meta" do
+ expect(errors[0][:meta]).to eq(
+ attribute: :name,
+ message: "can't be blank",
+ code: :blank
+ )
+ end
+
+ it "takes its title from a locale key named after the code" do
+ original = I18n.backend
+ I18n.backend = I18n::Backend::Simple.new
+ I18n.backend.store_translations(:en, graphiti: {errors: {unprocessable_entity: {title: "Nope"}}})
+
+ expect(errors[0][:title]).to eq("Nope")
+ expect(errors[0][:code]).to eq("unprocessable_entity")
+ ensure
+ I18n.backend = original
+ end
+
+ it "is empty for an object that cannot have errors" do
+ expect(described_class.new(Object.new).errors).to eq([])
+ end
+ end
+end
diff --git a/spec/filtering_spec.rb b/spec/filtering_spec.rb
index 5600fef0..7825baf1 100644
--- a/spec/filtering_spec.rb
+++ b/spec/filtering_spec.rb
@@ -285,10 +285,30 @@ def self.name
end
end
- context "when passed null and filter marked allow_nil: true" do
+ context "when the filter uses the deprecated blank options" do
+ it "maps allow_nil to :null" do
+ resource.filter :first_name, allow_nil: true
+ expect(resource.filters[:first_name][:blanks]).to eq(:null)
+ end
+
+ it "maps deny_empty to :rejected" do
+ resource.filter :first_name, deny_empty: true
+ expect(resource.filters[:first_name][:blanks]).to eq(:rejected)
+ end
+ end
+
+ context "when the filter is given an unknown blanks value" do
+ it "raises" do
+ expect {
+ resource.filter :first_name, blanks: :whatever
+ }.to raise_error(Graphiti::Errors::InvalidFilterBlanks, /must be one of :literal, :null, or :rejected/)
+ end
+ end
+
+ context "when passed null and filter marked blanks: :null" do
context "with string type" do
before do
- resource.filter :first_name, allow_nil: true
+ resource.filter :first_name, blanks: :null
employee2.update_attributes(first_name: nil)
params[:filter] = {first_name: "null"}
end
@@ -312,7 +332,7 @@ def self.name
context "with integer type" do
before do
resource.attribute :age, :integer
- resource.filter :age, allow_nil: true
+ resource.filter :age, blanks: :null
employee1.update_attributes(age: 20)
employee2.update_attributes(age: nil)
employee3.update_attributes(age: 30)
@@ -338,9 +358,9 @@ def self.name
end
end
- context "when passed an empty value when deny_empty is true" do
+ context "when passed a blank value and blanks is :rejected" do
before do
- resource.filter :first_name, deny_empty: true
+ resource.filter :first_name, blanks: :rejected
employee2.update_attributes(first_name: value)
params[:filter] = {first_name: "null"}
end
@@ -468,14 +488,14 @@ def self.name
before do
params[:filter] = {
id: employee1.id,
- 'positions.title': "bar"
+ "positions.title": "bar"
}
params[:include] = "positions"
end
it "works" do
render
- sl = d[0].sideload(:positions)
+ sl = jsonapi_data[0].sideload(:positions)
expect(sl.map(&:id)).to eq([pos2.id])
end
end
@@ -495,14 +515,14 @@ def self.name
before do
params[:filter] = {
id: employee1.id,
- 'positions.department.name': "bar"
+ "positions.department.name": "bar"
}
params[:include] = "positions.department"
end
it "works" do
render
- positions = d[0].sideload(:positions)
+ positions = jsonapi_data[0].sideload(:positions)
expect(positions[0].sideload(:department)).to be_nil
expect(positions[1].sideload(:department).id).to eq(department2.id)
end
@@ -518,7 +538,7 @@ def self.name
it "works" do
render
- positions = d[0].sideload(:positions)
+ positions = jsonapi_data[0].sideload(:positions)
expect(positions[0].sideload(:department).id).to eq(department2.id)
expect(positions[1].sideload(:department)).to be_nil
end
@@ -535,7 +555,7 @@ def self.name
it "works" do
render
- positions = d[0].sideload(:positions)
+ positions = jsonapi_data[0].sideload(:positions)
expect(positions.map(&:id)).to eq([2])
expect(positions[0].sideload(:department).id).to eq(department2.id)
end
@@ -972,7 +992,7 @@ def assert_filter_value(value)
it "coerces integers" do
params[:filter] = {foo: 40}
- assert_filter_value([BigDecimal("40")])
+ assert_filter_value([BigDecimal(40)])
end
it "coerces strings" do
diff --git a/spec/fixtures/employee_directory.rb b/spec/fixtures/employee_directory.rb
index d074b61a..f33d7655 100644
--- a/spec/fixtures/employee_directory.rb
+++ b/spec/fixtures/employee_directory.rb
@@ -47,6 +47,7 @@
create_table :positions do |t|
t.belongs_to :department, index: true
t.belongs_to :employee, index: true
+ t.string :region_name
t.string :title
end
@@ -54,6 +55,12 @@
t.string :name
end
+ # Keyed by a string rather than an integer :id, so a belongs_to pointing at
+ # it has a foreign key that is not the related resource's rendered id.
+ create_table :regions, primary_key: :code, id: :string do |t|
+ t.string :name
+ end
+
create_table :salaries do |t|
t.integer :employee_id
t.decimal :base_rate
@@ -154,12 +161,18 @@ class Employee < ApplicationRecord
class Position < ApplicationRecord
belongs_to :employee
belongs_to :department
+ belongs_to :region, foreign_key: :region_name, primary_key: :name, optional: true
end
class Department < ApplicationRecord
has_many :positions
end
+class Region < ApplicationRecord
+ self.primary_key = "code"
+ has_many :positions, foreign_key: :region_name, primary_key: :name
+end
+
class Salary < ApplicationRecord
belongs_to :employee
end
@@ -184,6 +197,10 @@ class DepartmentResource < ApplicationResource
attribute :name, :string
end
+class RegionResource < ApplicationResource
+ attribute :name, :string
+end
+
class PositionResource < ApplicationResource
attribute :employee_id, :integer, only: [:writable, :filterable]
attribute :title, :string
diff --git a/spec/fixtures/legacy.rb b/spec/fixtures/legacy.rb
index 15a97f54..9bd14d62 100644
--- a/spec/fixtures/legacy.rb
+++ b/spec/fixtures/legacy.rb
@@ -139,11 +139,11 @@ class Author < ApplicationRecord
class_name: "Legacy::State"
has_many :mentor_joins, class_name: "AuthorMentorship",
- foreign_key: :mentee_id, inverse_of: :mentee
+ foreign_key: :mentee_id, inverse_of: :mentee
has_many :mentors, through: :mentor_joins, class_name: "Author", source: :mentor
has_many :mentee_joins, class_name: "AuthorMentorship",
- foreign_key: :mentor_id, inverse_of: :mentor
+ foreign_key: :mentor_id, inverse_of: :mentor
has_many :mentees, through: :mentee_joins, class_name: "Author", source: :mentee
end
@@ -381,7 +381,7 @@ class AuthorResource < ApplicationResource
attribute :created_at_date, :date, only: [:filterable]
attribute :identifier, :uuid
- filter :last_login, allow_nil: true
+ filter :last_login, blanks: :null
has_many :books
belongs_to :state
diff --git a/spec/fixtures/poro.rb b/spec/fixtures/poro.rb
index f7d0d64e..5ba71f6b 100644
--- a/spec/fixtures/poro.rb
+++ b/spec/fixtures/poro.rb
@@ -51,7 +51,7 @@ def klasses
def all(params)
target_types = params[:type]
- records = data.select { |k, v| Array(target_types).include?(k) }
+ records = data.slice(*Array(target_types))
return [] unless records
records = records.map { |type, records_for_type|
records_for_type.map { |attrs| klasses[type].new(attrs) }
@@ -101,7 +101,7 @@ def apply_pagination(records, params)
return records unless params[:per]
records = records[params[:offset]..records.length] if params[:offset]
- start_at = (params[:page] - 1) * (params[:per])
+ start_at = (params[:page] - 1) * params[:per]
end_at = (params[:page] * params[:per]) - 1
return [] if end_at < 0
records[start_at..end_at]
@@ -111,6 +111,7 @@ def apply_pagination(records, params)
class Base
include ActiveModel::Validations
+
attr_accessor :id
def self.create(attrs = {})
diff --git a/spec/graphiti_spec.rb b/spec/graphiti_spec.rb
index 0d2f5807..5378df0a 100644
--- a/spec/graphiti_spec.rb
+++ b/spec/graphiti_spec.rb
@@ -19,4 +19,33 @@
described_class.setup!
end
end
+
+ describe ".context" do
+ it "reads :namespace as a deprecated alias of :action" do
+ described_class.with_context(double, :index) do
+ expect(Graphiti::DEPRECATOR).to receive(:deprecation_warning)
+ .with(:"context[:namespace]", /current_action/, anything)
+ expect(described_class.context[:namespace]).to eq(:index)
+ end
+ end
+
+ it "writes :namespace through to :action" do
+ described_class.with_context(double, :index) do
+ allow(Graphiti::DEPRECATOR).to receive(:deprecation_warning)
+ described_class.context[:namespace] = :update
+ expect(described_class.context[:action]).to eq(:update)
+ end
+ end
+
+ it "is visible to a fiber started within the request" do
+ object = double
+ seen = nil
+
+ described_class.with_context(object, :index) do
+ Fiber.new { seen = described_class.context[:object] }.resume
+ end
+
+ expect(seen).to eq(object)
+ end
+ end
end
diff --git a/spec/integration/rails/activerecord_to_poro_spec.rb b/spec/integration/rails/activerecord_to_poro_spec.rb
index 05044d7d..038bf172 100644
--- a/spec/integration/rails/activerecord_to_poro_spec.rb
+++ b/spec/integration/rails/activerecord_to_poro_spec.rb
@@ -1,6 +1,6 @@
if ENV["APPRAISAL_INITIALIZED"]
RSpec.describe "associating an ActiveRecord to a PORO", type: :controller do
- include GraphitiSpecHelpers
+ include Graphiti::SpecHelpers
module ARToPORO
class AuthorResource < Legacy::ApplicationResource
@@ -46,7 +46,7 @@ def index
context "when has_many" do
it "works" do
do_index({include: "books"})
- sl = d[0].sideload(:books)
+ sl = jsonapi_data[0].sideload(:books)
expect(sl.map(&:id)).to eq([book.id])
expect(sl[0].jsonapi_type).to eq("books")
end
@@ -55,7 +55,7 @@ def index
context "when belongs_to" do
it "works" do
do_index({include: "state"})
- sl = d[0].sideload(:state)
+ sl = jsonapi_data[0].sideload(:state)
expect(sl.id).to eq(state.id)
expect(sl.jsonapi_type).to eq("states")
end
diff --git a/spec/integration/rails/ar_dedup_spec.rb b/spec/integration/rails/ar_dedup_spec.rb
new file mode 100644
index 00000000..4e7a9f3e
--- /dev/null
+++ b/spec/integration/rails/ar_dedup_spec.rb
@@ -0,0 +1,78 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails_spec_helper"
+
+ module DedupTest
+ DB_PATH = File.expand_path("../../tmp/dedup_probe.sqlite3", __dir__)
+ end
+
+ # Two sideloads write to the same record from different threads. That only works because Ruby runs one thread at a time, so this test fails if that ever changes.
+ RSpec.describe "dedup against ActiveRecord's association cache" do
+ include ConcurrencyHarness
+
+ before(:all) do
+ base = FileBackedDatabase.connect(DedupTest, DedupTest::DB_PATH, pool: 10)
+
+ base.connection.create_table(:dedup_parents, force: true) { |t| t.string :name }
+ base.connection.create_table(:dedup_children, force: true) do |t|
+ t.integer :dedup_parent_id
+ t.string :kind
+ end
+
+ DedupTest.const_set(:Child, Class.new(base) {
+ self.table_name = "dedup_children"
+ })
+ DedupTest.const_set(:Parent, Class.new(base) {
+ self.table_name = "dedup_parents"
+ has_many :alphas, -> { where(kind: "alpha") }, class_name: "DedupTest::Child", foreign_key: :dedup_parent_id
+ has_many :betas, -> { where(kind: "beta") }, class_name: "DedupTest::Child", foreign_key: :dedup_parent_id
+ })
+
+ DedupTest.const_set(:ChildResource, Class.new(Graphiti::Resource) {
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.model = DedupTest::Child
+ self.type = :dedup_children
+ attribute :dedup_parent_id, :integer, only: [:filterable]
+ attribute :kind, :string
+ })
+ DedupTest.const_set(:ParentResource, Class.new(Graphiti::Resource) {
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.model = DedupTest::Parent
+ self.type = :dedup_parents
+ attribute :name, :string
+ has_many :alphas, resource: DedupTest::ChildResource, foreign_key: :dedup_parent_id
+ has_many :betas, resource: DedupTest::ChildResource, foreign_key: :dedup_parent_id
+ })
+
+ 60.times do |index|
+ parent = DedupTest::Parent.create!(name: "p#{index}")
+ 2.times { DedupTest::Child.create!(dedup_parent_id: parent.id, kind: "alpha") }
+ 2.times { DedupTest::Child.create!(dedup_parent_id: parent.id, kind: "beta") }
+ end
+ end
+
+ after(:all) do
+ FileBackedDatabase.disconnect(DedupTest, DedupTest::DB_PATH,
+ %i[ParentResource ChildResource Child Parent])
+ end
+
+ before do
+ allow(Graphiti.config).to receive(:concurrency).and_return(true)
+ with_thread_pool(max_threads: 4)
+ end
+
+ it "keeps both sibling associations on every shared instance" do
+ damaged = []
+
+ 8.times do |run|
+ parents = DedupTest::ParentResource.all(page: {size: 60}, include: "alphas,betas").to_a
+ parents.each do |parent|
+ cache = parent.instance_variable_get(:@association_cache) || {}
+ loaded = cache.keys.sort
+ damaged << [run, parent.id, loaded] unless loaded == [:alphas, :betas]
+ end
+ end
+
+ expect(damaged).to be_empty, "lost associations on #{damaged.size} parents, e.g. #{damaged.first(3).inspect}"
+ end
+ end
+end
diff --git a/spec/integration/rails/belongs_to_resource_ids_spec.rb b/spec/integration/rails/belongs_to_resource_ids_spec.rb
new file mode 100644
index 00000000..3c7ba6a0
--- /dev/null
+++ b/spec/integration/rails/belongs_to_resource_ids_spec.rb
@@ -0,0 +1,202 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ RSpec.describe "belongs_to resource linkage" do
+ include Graphiti::SpecHelpers
+
+ let!(:employee) { Employee.create!(first_name: "Jane") }
+ let!(:position) do
+ Position.create!(employee: employee, title: "Engineer")
+ end
+
+ def queries_while
+ queries = []
+ subscriber = ActiveSupport::Notifications
+ .subscribe("sql.active_record") do |_, _, _, _, payload|
+ sql = payload[:sql]
+ next if payload[:name].to_s == "SCHEMA"
+ next if sql.start_with?("PRAGMA", "begin", "commit")
+ queries << sql
+ end
+ yield
+ queries
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber)
+ end
+
+ # Filter to the record under test: examples in this file share a database.
+ def linkage_for(resource_class, record = position)
+ json = JSON.parse(resource_class.all(filter: {id: record.id}).to_jsonapi)
+ json["data"][0]["relationships"]["employee"]
+ end
+
+ context "when the relationship is a plain belongs_to" do
+ let(:resource_class) do
+ Class.new(PositionResource) do
+ def self.name
+ "PositionResource"
+ end
+
+ belongs_to :employee, resource_ids: true
+ end
+ end
+
+ it "renders the linkage" do
+ expect(linkage_for(resource_class)).to eq(
+ "data" => {"type" => "employees", "id" => employee.id.to_s}
+ )
+ end
+
+ it "does not query employees to do it" do
+ queries = queries_while { linkage_for(resource_class) }
+
+ expect(queries.grep(/FROM .employees./)).to be_empty
+ end
+
+ it "renders null when the foreign key is nil" do
+ # update_column, not update!: the loaded belongs_to association writes
+ # its id back over the nil on save.
+ position.update_column(:employee_id, nil)
+ expect(linkage_for(resource_class)).to eq("data" => nil)
+ end
+
+ it "still renders correct linkage when the relationship is included" do
+ json = JSON.parse(
+ resource_class.all(
+ filter: {id: position.id}, include: "employee"
+ ).to_jsonapi
+ )
+
+ expect(json["data"][0]["relationships"]["employee"]["data"]).to eq(
+ "type" => "employees", "id" => employee.id.to_s
+ )
+ expect(json["included"].map { |r| r["type"] }).to include("employees")
+ end
+
+ it "matches what loading the association produces" do
+ from_foreign_key = linkage_for(resource_class)
+
+ loaded = Class.new(PositionResource) do
+ def self.name
+ "PositionResource"
+ end
+
+ belongs_to :employee, resource_ids: true
+ end
+ loaded.sideloads[:employee]
+ .define_singleton_method(:resource_ids_from_foreign_key?) { false }
+
+ expect(from_foreign_key).to eq(linkage_for(loaded))
+ end
+ end
+
+ context "when the base scope does not select the foreign key" do
+ let(:resource_class) do
+ Class.new(PositionResource) do
+ def self.name
+ "PositionResource"
+ end
+
+ def base_scope
+ Position.select(:id, :title)
+ end
+
+ belongs_to :employee, resource_ids: true
+ end
+ end
+
+ it "raises an error naming the opt-out" do
+ expect {
+ linkage_for(resource_class)
+ }.to raise_error(Graphiti::Errors::UnselectedForeignKey, /resource_ids: false/)
+ end
+ end
+
+ context "when the relationship could resolve to a different record" do
+ def sideload_for(&blk)
+ klass = Class.new(PositionResource) do
+ def self.name
+ "PositionResource"
+ end
+ end
+ klass.instance_eval(&blk)
+ klass.sideloads[:employee]
+ end
+
+ it "loads the association when a params block is present" do
+ sideload = sideload_for do
+ belongs_to :employee do
+ params do |hash|
+ hash[:filter][:active] = true
+ end
+ end
+ end
+
+ expect(sideload.resource_ids_from_foreign_key?).to eq(false)
+ end
+
+ it "loads the association when a base_scope is present" do
+ sideload = sideload_for do
+ belongs_to :employee, base_scope: -> { Employee.all }
+ end
+
+ expect(sideload.resource_ids_from_foreign_key?).to eq(false)
+ end
+
+ it "loads the association when the target resource is polymorphic" do
+ sideload = sideload_for do
+ belongs_to :employee, resource: TaskResource
+ end
+
+ expect(sideload.resource_ids_from_foreign_key?).to eq(false)
+ end
+ end
+
+ context "when the target is keyed by something other than :id" do
+ # Examples in this file share a database, so this may already exist.
+ let!(:region) do
+ Region.find_or_create_by!(code: "rg-1") { |r| r.name = "Northeast" }
+ end
+
+ before { position.update_column(:region_name, "Northeast") }
+
+ let(:resource_class) do
+ Class.new(PositionResource) do
+ def self.name
+ "PositionResource"
+ end
+
+ belongs_to :region,
+ resource: RegionResource,
+ foreign_key: :region_name,
+ primary_key: :name,
+ resource_ids: true
+ end
+ end
+
+ def region_linkage
+ json = JSON.parse(
+ resource_class.all(filter: {id: position.id}).to_jsonapi
+ )
+ json["data"][0]["relationships"]["region"]
+ end
+
+ it "renders the related id, not the foreign key" do
+ expect(region_linkage["data"]).to eq(
+ "type" => "regions", "id" => region.id.to_s
+ )
+ end
+
+ it "loads the association to get it" do
+ queries = queries_while { region_linkage }
+
+ expect(queries.grep(/FROM .regions./)).to_not be_empty
+ end
+ end
+
+ context "for relationship types other than belongs_to" do
+ it "never derives linkage from a foreign key" do
+ expect(EmployeeResource.sideloads[:positions].resource_ids_from_foreign_key?)
+ .to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/cache_key_spec.rb b/spec/integration/rails/cache_key_spec.rb
new file mode 100644
index 00000000..f0de12dd
--- /dev/null
+++ b/spec/integration/rails/cache_key_spec.rb
@@ -0,0 +1,193 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails_spec_helper"
+
+ RSpec.describe "cache keys over a resolved graph" do
+ include ConcurrencyHarness
+
+ before(:all) do
+ @db_path = File.expand_path("../../tmp/cache_key_probe.sqlite3", __dir__)
+ FileUtils.mkdir_p(File.dirname(@db_path))
+ FileUtils.rm_f(@db_path)
+ Object.const_set(:CacheRecord, Class.new(ActiveRecord::Base) { self.abstract_class = true })
+ CacheRecord.establish_connection(adapter: "sqlite3", database: @db_path, pool: 10, timeout: 5000)
+
+ CacheRecord.connection.create_table(:cache_parents, force: true) { |t|
+ t.string :name
+ t.timestamps
+ }
+ CacheRecord.connection.create_table(:cache_children, force: true) do |t|
+ t.integer :cache_parent_id
+ t.string :name
+ t.timestamps
+ end
+ CacheRecord.connection.create_table(:cache_grandchildren, force: true) do |t|
+ t.integer :cache_child_id
+ t.string :name
+ t.timestamps
+ end
+
+ Object.const_set(:CacheGrandchild, Class.new(CacheRecord) { self.table_name = "cache_grandchildren" })
+ Object.const_set(:CacheChild, Class.new(CacheRecord) {
+ self.table_name = "cache_children"
+ has_many :cache_grandchildren, class_name: "CacheGrandchild", foreign_key: :cache_child_id
+ })
+ Object.const_set(:CacheParent, Class.new(CacheRecord) {
+ self.table_name = "cache_parents"
+ has_many :cache_children, class_name: "CacheChild", foreign_key: :cache_parent_id
+ has_many :ghosts, class_name: "CacheChild", foreign_key: :cache_parent_id
+ })
+
+ Object.const_set(:CacheGrandchildResource, Class.new(Graphiti::Resource) {
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.model = CacheGrandchild
+ self.type = :cache_grandchildren
+ attribute :cache_child_id, :integer, only: [:filterable]
+ attribute :name, :string
+ })
+ Object.const_set(:CacheChildResource, Class.new(Graphiti::Resource) {
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.model = CacheChild
+ self.type = :cache_children
+ attribute :cache_parent_id, :integer, only: [:filterable]
+ attribute :name, :string
+ has_many :cache_grandchildren,
+ resource: CacheGrandchildResource,
+ foreign_key: :cache_child_id
+ })
+ Object.const_set(:CacheParentResource, Class.new(Graphiti::Resource) {
+ self.adapter = Graphiti::Adapters::ActiveRecord
+ self.model = CacheParent
+ self.type = :cache_parents
+ attribute :name, :string
+ has_many :cache_children,
+ resource: CacheChildResource,
+ foreign_key: :cache_parent_id
+
+ # Assigns nothing back to its parents, so the association reader on a
+ # parent lazy-loads instead of returning what the sideload resolved.
+ has_many :ghosts, resource: CacheChildResource, foreign_key: :cache_parent_id do
+ assign do |parents, children|
+ children.each(&:name)
+ end
+ end
+ })
+
+ 3.times do |i|
+ parent = CacheParent.create!(name: "p#{i}")
+ 2.times do |j|
+ child = CacheChild.create!(cache_parent_id: parent.id, name: "c#{i}#{j}")
+ 2.times { |k| CacheGrandchild.create!(cache_child_id: child.id, name: "g#{i}#{j}#{k}") }
+ end
+ end
+ end
+
+ after(:all) do
+ CacheRecord.remove_connection
+ FileUtils.rm_f(@db_path)
+ %i[
+ CacheParentResource CacheChildResource CacheGrandchildResource
+ CacheParent CacheChild CacheGrandchild CacheRecord
+ ].each do |name|
+ Object.send(:remove_const, name) if Object.const_defined?(name)
+ end
+ end
+
+ def proxy(include:)
+ CacheParentResource.all(page: {size: 10}, include: include)
+ end
+
+ def etag_after_render(include:)
+ rendered = proxy(include: include)
+ rendered.to_jsonapi
+ rendered.cache_key_with_version
+ end
+
+ def etag_without_render(include:)
+ proxy(include: include).cache_key_with_version
+ end
+
+ [false, true].each do |concurrency|
+ context "with concurrency #{concurrency}" do
+ before do
+ allow(Graphiti.config).to receive(:concurrency).and_return(concurrency)
+ with_thread_pool(max_threads: 4) if concurrency
+ end
+
+ it "computes the same key whether or not the graph was resolved first" do
+ expect(etag_after_render(include: "cache_children.cache_grandchildren"))
+ .to eq(etag_without_render(include: "cache_children.cache_grandchildren"))
+ end
+
+ it "changes the key when a sideloaded record changes" do
+ before_touch = etag_after_render(include: "cache_children")
+ CacheChild.first.touch
+ expect(etag_after_render(include: "cache_children")).not_to eq(before_touch)
+ end
+
+ it "changes the key when a record two levels down changes" do
+ before_touch = etag_after_render(include: "cache_children.cache_grandchildren")
+ CacheGrandchild.last.touch
+ expect(etag_after_render(include: "cache_children.cache_grandchildren")).not_to eq(before_touch)
+ end
+
+ it "changes the key when a sideload whose assign writes nothing back changes" do
+ before_touch = etag_after_render(include: "ghosts")
+ CacheChild.last.touch
+ expect(etag_after_render(include: "ghosts")).not_to eq(before_touch)
+ end
+
+ it "reports the latest updated_at across the graph" do
+ rendered = proxy(include: "cache_children.cache_grandchildren")
+ rendered.to_jsonapi
+ latest = CacheGrandchild.last
+ latest.update!(updated_at: 1.day.from_now)
+
+ expect(proxy(include: "cache_children.cache_grandchildren").updated_at.to_i)
+ .to eq(latest.reload.updated_at.to_i)
+ end
+ end
+ end
+
+ describe "resolutions" do
+ # Prepended once: a module per example would stack and multiply the counts.
+ before(:all) do
+ Graphiti::Adapters::ActiveRecord.prepend(Module.new do
+ def resolve(scope)
+ count = Thread.current[:cache_key_resolves]
+ Thread.current[:cache_key_resolves] = count + 1 if count
+ super
+ end
+ end)
+ end
+
+ def resolves
+ Thread.current[:cache_key_resolves] = 0
+ yield
+ Thread.current[:cache_key_resolves]
+ ensure
+ Thread.current[:cache_key_resolves] = nil
+ end
+
+ before { allow(Graphiti.config).to receive(:concurrency).and_return(false) }
+
+ {
+ "cache_children" => 2,
+ "cache_children.cache_grandchildren" => 3
+ }.each_pair do |include, expected|
+ it "resolves #{expected} times for include=#{include}, with or without a cache key" do
+ render_only = resolves { proxy(include: include).to_jsonapi }
+
+ with_cache_key = resolves do
+ rendered = proxy(include: include)
+ rendered.to_jsonapi
+ rendered.cache_key_with_version
+ rendered.updated_at
+ end
+
+ expect(render_only).to eq(expected)
+ expect(with_cache_key).to eq(expected)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/callbacks_spec.rb b/spec/integration/rails/callbacks_spec.rb
index f08a74b7..065e337f 100644
--- a/spec/integration/rails/callbacks_spec.rb
+++ b/spec/integration/rails/callbacks_spec.rb
@@ -8,6 +8,7 @@
routes.draw do
post "create" => "anonymous#create"
+ put "update" => "anonymous#update"
delete "destroy" => "anonymous#destroy"
end
@@ -34,6 +35,7 @@ class ApplicationResource < Graphiti::Resource
class EmployeeResource < ApplicationResource
self.model = Employee
+ self.type = "employees"
before_attributes :one
before_attributes :two
@@ -168,6 +170,18 @@ def create
end
end
+ def update
+ employee = IntegrationCallbacks::EmployeeResource._find(params)
+ Thread.current[:proxy] = employee
+ employee.assign_attributes(params)
+
+ if employee.update_attributes
+ render jsonapi: employee
+ else
+ raise "whoops"
+ end
+ end
+
def destroy
employee = IntegrationCallbacks::EmployeeResource._find(params)
Thread.current[:proxy] = employee
@@ -227,6 +241,39 @@ def params
end
end
+ describe "update callbacks" do
+ let!(:employee) { Employee.create!(first_name: "asdf") }
+ let(:payload) {
+ {id: employee.id,
+ data: {
+ id: employee.id,
+ type: "employees",
+ attributes: {first_name: "Jane"}
+ }}
+ }
+
+ it "fires hooks in order" do
+ expect {
+ put :update, params: payload
+ }.to change { Employee.find(employee.id).first_name }
+ employee = proxy.data
+ expect(employee.first_name)
+ .to eq("Jane5a6a7a12347b6b5b_12a_13a_14a89_10_11_14b_13b_12b")
+ end
+
+ context "when an error is raised" do
+ before do
+ $raise = true
+ end
+
+ it "rolls back the transaction" do
+ expect {
+ expect { put :update, params: payload }.to raise_error("test")
+ }.to_not(change { Employee.count })
+ end
+ end
+ end
+
describe "destroy callbacks" do
let!(:employee) { Employee.create!(first_name: "Jane") }
diff --git a/spec/integration/rails/concurrency_stress_spec.rb b/spec/integration/rails/concurrency_stress_spec.rb
index 1f1a9f78..cf5c557e 100644
--- a/spec/integration/rails/concurrency_stress_spec.rb
+++ b/spec/integration/rails/concurrency_stress_spec.rb
@@ -12,13 +12,7 @@ module StressTest
include ConcurrencyHarness
before(:all) do
- FileUtils.mkdir_p(File.dirname(StressTest::DB_PATH))
- FileUtils.rm_f(StressTest::DB_PATH)
-
- StressTest.const_set(:Base, Class.new(ActiveRecord::Base) { self.abstract_class = true })
- StressTest::Base.establish_connection(
- adapter: "sqlite3", database: StressTest::DB_PATH, pool: StressTest::DB_POOL_SIZE, timeout: 5000
- )
+ FileBackedDatabase.connect(StressTest, StressTest::DB_PATH, pool: StressTest::DB_POOL_SIZE)
StressTest::Base.connection.create_table(:departments, force: true) do |t|
t.string :name
@@ -53,11 +47,8 @@ module StressTest
end
after(:all) do
- StressTest::Base.remove_connection
- FileUtils.rm_f(StressTest::DB_PATH)
- %i[EmployeeResource Employee Department Base].each do |name|
- StressTest.send(:remove_const, name) if StressTest.const_defined?(name, false)
- end
+ FileBackedDatabase.disconnect(StressTest, StressTest::DB_PATH,
+ %i[EmployeeResource Employee Department])
end
before do
diff --git a/spec/integration/rails/connection_pool_hint_spec.rb b/spec/integration/rails/connection_pool_hint_spec.rb
new file mode 100644
index 00000000..e85c2727
--- /dev/null
+++ b/spec/integration/rails/connection_pool_hint_spec.rb
@@ -0,0 +1,72 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails_spec_helper"
+ require "graphiti/rails/rake_helpers"
+
+ RSpec.describe "connection pool exhaustion inside a concurrent sideload" do
+ include ConcurrencyHarness
+
+ let!(:employee) { PORO::Employee.create }
+
+ before do
+ allow(Graphiti.config).to receive(:concurrency).and_return(true)
+ with_thread_pool(max_threads: 2)
+ end
+
+ it "points the timeout at the sizing formula" do
+ position_resource = Class.new(PORO::PositionResource) do
+ def self.name
+ "PORO::PositionResource"
+ end
+
+ def resolve(_scope)
+ raise ActiveRecord::ConnectionTimeoutError, "could not obtain a connection from the pool"
+ end
+ end
+ resource_class = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+ end
+ resource_class.has_many :probe_positions, resource: position_resource, foreign_key: :employee_id
+ # Two sideloads, since one on its own resolves inline and never reaches the pool.
+ resource_class.has_many :other_positions, resource: position_resource, foreign_key: :employee_id
+
+ expect {
+ resource_class.all(filter: {id: employee.id}, include: "probe_positions,other_positions").to_a
+ }.to raise_error(ActiveRecord::ConnectionTimeoutError) { |error|
+ expect(error.message).to include("could not obtain a connection")
+ expect(error.message).to include("concurrency_max_threads")
+ expect(error.message).to include("concurrency-pool-sizing")
+ }
+ end
+ end
+
+ RSpec.describe Graphiti::Rails::RakeHelpers do
+ describe ".connection_pool_advisory" do
+ before do
+ allow(ENV).to receive(:fetch).and_call_original
+ allow(ENV).to receive(:fetch).with("RAILS_MAX_THREADS", 5).and_return("5")
+ end
+
+ def with_pool_size(size)
+ config = ActiveRecord::Base.connection_db_config
+ allow(ActiveRecord::Base).to receive(:connection_db_config)
+ .and_return(instance_double(config.class, pool: size))
+ end
+
+ it "warns when the pool cannot cover web and sideload threads" do
+ with_pool_size(5)
+
+ advisory = described_class.connection_pool_advisory
+ expect(advisory).to include("pool is 5")
+ expect(advisory).to include("+ 1 = 10")
+ end
+
+ it "stays quiet when the pool is big enough" do
+ with_pool_size(50)
+
+ expect(described_class.connection_pool_advisory).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/context_spec.rb b/spec/integration/rails/context_spec.rb
new file mode 100644
index 00000000..530493fa
--- /dev/null
+++ b/spec/integration/rails/context_spec.rb
@@ -0,0 +1,85 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ RSpec.describe Graphiti::Rails::Context, type: :controller do
+ controller(ApplicationController) do
+ def index
+ render json: {
+ object: Graphiti.context[:object].class.name,
+ action: Graphiti.context[:action]
+ }
+ end
+ end
+
+ it "comes along with Graphiti::Rails::Controller" do
+ expect(controller).to be_a(described_class)
+ end
+
+ it "defaults the context to the controller instance" do
+ expect(controller.graphiti_context).to eq(controller)
+ end
+
+ it "wraps the action in a context of the controller and the action name" do
+ wrapped = nil
+ allow(Graphiti).to receive(:with_context).and_wrap_original do |original, object, action, &block|
+ wrapped = [object, action]
+ original.call(object, action, &block)
+ end
+
+ get :index
+
+ expect(wrapped).to eq([controller, :index])
+ end
+
+ it "exposes the context to resources for the duration of the action" do
+ get :index
+
+ body = JSON.parse(response.body)
+ expect(body["object"]).to eq(controller.class.name)
+ expect(body["action"]).to eq("index")
+ end
+
+ it "unsets the context once the action returns" do
+ get :index
+
+ expect(Graphiti.context).to eq({})
+ end
+
+ context "when the controller overrides graphiti_context" do
+ controller(ApplicationController) do
+ def index
+ render json: {object: Graphiti.context[:object]}
+ end
+
+ def graphiti_context
+ "custom"
+ end
+ end
+
+ it "wraps the action in that context instead" do
+ get :index
+
+ expect(JSON.parse(response.body)["object"]).to eq("custom")
+ end
+ end
+
+ context "when the controller overrides the deprecated jsonapi_context" do
+ controller(ApplicationController) do
+ def index
+ render json: {object: Graphiti.context[:object]}
+ end
+
+ def jsonapi_context
+ "legacy"
+ end
+ end
+
+ it "still uses it, and deprecates the override" do
+ expect(Graphiti::DEPRECATOR).to receive(:deprecation_warning)
+ .with("Overriding jsonapi_context", "Override #graphiti_context instead")
+
+ get :index
+
+ expect(JSON.parse(response.body)["object"]).to eq("legacy")
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/controller_spec.rb b/spec/integration/rails/controller_spec.rb
new file mode 100644
index 00000000..fa2c9b63
--- /dev/null
+++ b/spec/integration/rails/controller_spec.rb
@@ -0,0 +1,57 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ RSpec.describe Graphiti::Rails::Controller, type: :controller do
+ # Graphiti applied all of this to every controller in the app until 2.0.
+ # These assert the opposite: a controller that has not asked for Graphiti
+ # is left alone.
+ describe "a controller without it" do
+ controller(ActionController::Base) do
+ def index
+ render json: {context: Graphiti.context}
+ end
+ end
+
+ it "gets no graphiti context" do
+ get :index
+
+ expect(JSON.parse(response.body)["context"]).to eq({})
+ end
+
+ it "does not wrap actions in the debugger" do
+ expect(Graphiti::Debugger).to_not receive(:debug)
+
+ get :index
+ end
+
+ it "does not register Graphiti's exception handlers" do
+ registry = controller.class.rescue_registry
+
+ expect(registry.handles_exception?(Graphiti::Errors::RecordNotFound.new)).to eq(false)
+ expect(registry.handles_exception?(RuntimeError.new)).to eq(false)
+ end
+ end
+
+ describe "a controller with it" do
+ controller(ActionController::Base) do
+ include Graphiti::Rails::Controller
+
+ def index
+ render json: {object: Graphiti.context[:object].class.name}
+ end
+ end
+
+ it "gets the graphiti context" do
+ get :index
+
+ expect(JSON.parse(response.body)["object"]).to eq(controller.class.name)
+ end
+
+ it "registers Graphiti's exception handlers" do
+ registry = controller.class.rescue_registry
+
+ expect(registry.handles_exception?(Graphiti::Errors::RecordNotFound.new)).to eq(true)
+ # the Exception catch-all
+ expect(registry.handles_exception?(RuntimeError.new)).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/current_attributes_spec.rb b/spec/integration/rails/current_attributes_spec.rb
new file mode 100644
index 00000000..29809ad5
--- /dev/null
+++ b/spec/integration/rails/current_attributes_spec.rb
@@ -0,0 +1,51 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails_spec_helper"
+
+ module RailsCurrentAttributesTest
+ class Current < ActiveSupport::CurrentAttributes
+ attribute :user
+ end
+ end
+
+ RSpec.describe "CurrentAttributes inside concurrent sideloads" do
+ include ConcurrencyHarness
+
+ let!(:employee) { Employee.create!(first_name: "Jane") }
+
+ before do
+ allow(Graphiti.config).to receive(:concurrency).and_return(true)
+ with_thread_pool(max_threads: 2)
+ end
+
+ after { RailsCurrentAttributesTest::Current.reset }
+
+ it "survives the executor handing the pool thread a fresh Current" do
+ observed = nil
+
+ resource_class = Class.new(EmployeeResource) do
+ def self.name
+ "EmployeeResource"
+ end
+ end
+ # Position.none, because the pool thread's connection does not see the
+ # in-memory test schema. The scope block still runs where a real one would.
+ resource_class.has_many :probe_positions, resource: PositionResource, foreign_key: :employee_id do
+ scope do |_employee_ids|
+ observed = {thread: Thread.current.object_id, user: RailsCurrentAttributesTest::Current.user}
+ Position.none
+ end
+ end
+
+ # A second sideload, since one on its own resolves inline and never reaches the pool.
+ resource_class.has_many :other_positions, resource: PositionResource, foreign_key: :employee_id do
+ scope { |_employee_ids| Position.none }
+ end
+
+ RailsCurrentAttributesTest::Current.user = "jeff"
+ resource_class.all(filter: {id: employee.id}, include: "probe_positions,other_positions").to_a
+
+ expect(observed[:thread]).to_not eq(Thread.current.object_id)
+ expect(observed[:user]).to eq("jeff")
+ end
+ end
+end
diff --git a/spec/integration/rails/cursor_pagination_spec.rb b/spec/integration/rails/cursor_pagination_spec.rb
index 25539481..f053481f 100644
--- a/spec/integration/rails/cursor_pagination_spec.rb
+++ b/spec/integration/rails/cursor_pagination_spec.rb
@@ -1,6 +1,6 @@
if ENV["APPRAISAL_INITIALIZED"]
RSpec.describe "cursor pagination", type: :controller do
- include GraphitiSpecHelpers
+ include Graphiti::SpecHelpers
controller(ApplicationController) do
def index
@@ -28,12 +28,12 @@ def resource
let!(:author4) { Legacy::Author.create!(age: 30, last_login: 1.days.ago) }
around do |e|
- original = Legacy::AuthorResource.cursor_paginatable
- Legacy::AuthorResource.cursor_paginatable = true
+ original = Legacy::AuthorResource.page_cursors
+ Legacy::AuthorResource.page_cursors = true
begin
e.run
ensure
- Legacy::AuthorResource.cursor_paginatable = original
+ Legacy::AuthorResource.page_cursors = original
end
end
@@ -41,7 +41,7 @@ def decode(cursor)
JSON.parse(Base64.decode64(cursor)).deep_symbolize_keys
end
- # don't go through 'd' helper b/c it is memoized
+ # don't go through jsonapi_data b/c it is memoized
def ids
json["data"].map { |d| d["id"].to_i }
end
diff --git a/spec/integration/rails/debugging_spec.rb b/spec/integration/rails/debugging_spec.rb
new file mode 100644
index 00000000..1c6e6071
--- /dev/null
+++ b/spec/integration/rails/debugging_spec.rb
@@ -0,0 +1,27 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ RSpec.describe Graphiti::Rails::Debugging, type: :controller do
+ controller(ApplicationController) do
+ def index
+ render json: {}
+ end
+ end
+
+ it "comes along with Graphiti::Rails::Controller" do
+ expect(controller).to be_a(described_class)
+ end
+
+ it "wraps the action in a debugger" do
+ ran_inside_debugger = false
+ allow(Graphiti::Debugger).to receive(:debug).and_wrap_original do |original, &block|
+ original.call do
+ ran_inside_debugger = true
+ block.call
+ end
+ end
+
+ get :index
+
+ expect(ran_inside_debugger).to eq(true)
+ end
+ end
+end
diff --git a/spec/integration/rails/deprecated_rails_constants_spec.rb b/spec/integration/rails/deprecated_rails_constants_spec.rb
new file mode 100644
index 00000000..ed980c69
--- /dev/null
+++ b/spec/integration/rails/deprecated_rails_constants_spec.rb
@@ -0,0 +1,101 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ RSpec.describe "deprecated Rails constants" do
+ def silenced
+ Graphiti::DEPRECATOR.silence { yield }
+ end
+
+ describe "Graphiti::Responders" do
+ it "resolves to Graphiti::Rails::Responders" do
+ expect(silenced { Graphiti::Responders == Graphiti::Rails::Responders }).to eq(true)
+ end
+
+ it "warns when used" do
+ expect(Graphiti::DEPRECATOR).to receive(:warn).at_least(:once).and_return(nil)
+
+ Graphiti::Responders == Graphiti::Rails::Responders
+ end
+ end
+
+ describe "including Graphiti::Rails" do
+ it "warns" do
+ expect(Graphiti::DEPRECATOR).to receive(:deprecation_warning)
+ .with("Including Graphiti::Rails", a_string_including("Graphiti::Rails::Controller"))
+
+ Class.new(ActionController::Base) { include Graphiti::Rails }
+ end
+
+ it "still sets the controller up, rather than silently doing nothing" do
+ klass = silenced { Class.new(ActionController::Base) { include Graphiti::Rails } }
+
+ expect(klass.ancestors).to include(Graphiti::Rails::Controller)
+ expect(klass.ancestors).to include(Graphiti::Rails::Context)
+ expect(klass.rescue_registry.handles_exception?(Graphiti::Errors::RecordNotFound.new))
+ .to eq(true)
+ end
+ end
+
+ describe 'require "graphiti-rails"' do
+ it "still resolves" do
+ expect { silenced { require "graphiti-rails" } }.to_not raise_error
+ end
+ end
+
+ describe "Graphiti::Rails::DEPRECATOR" do
+ it "resolves on an explicit lookup, not just lexically" do
+ expect(Graphiti::Rails::DEPRECATOR).to eq(Graphiti::DEPRECATOR)
+ end
+ end
+
+ describe 'require "graphiti/responders"' do
+ it "still resolves" do
+ expect { silenced { require "graphiti/responders" } }.to_not raise_error
+ end
+ end
+
+ describe "Graphiti::Railtie" do
+ it "resolves to Graphiti::Rails::Railtie" do
+ expect(silenced { Graphiti::Railtie == Graphiti::Rails::Railtie }).to eq(true)
+ end
+ end
+
+ describe 'require "graphiti/railtie"' do
+ it "still resolves" do
+ expect { silenced { require "graphiti/railtie" } }.to_not raise_error
+ end
+ end
+
+ describe "Graphiti::Rails::GraphitiErrorsTesting" do
+ it "resolves to Graphiti::Rails::TestHelpers" do
+ expect(silenced { Graphiti::Rails::GraphitiErrorsTesting == Graphiti::Rails::TestHelpers })
+ .to eq(true)
+ end
+ end
+
+ describe "GraphitiErrors.disable!/enable!" do
+ around do |example|
+ original = ::Rails.application.config.action_dispatch.show_exceptions
+ example.run
+ ensure
+ ::Rails.application.config.action_dispatch.show_exceptions = original
+ ::Rails.application.env_config["action_dispatch.show_exceptions"] = original
+ end
+
+ it "warns" do
+ expect(Graphiti::DEPRECATOR).to receive(:deprecation_warning)
+ .with("GraphitiErrors.disable!", a_string_including("handle_request_exceptions"))
+
+ GraphitiErrors.disable!
+ end
+
+ it "still toggles exception rendering" do
+ silenced do
+ GraphitiErrors.enable!
+ expect(GraphitiErrors.disabled?).to eq(false)
+
+ GraphitiErrors.disable!
+ expect(GraphitiErrors.disabled?).to eq(true)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/exception_handling_spec.rb b/spec/integration/rails/exception_handling_spec.rb
new file mode 100644
index 00000000..c46c0853
--- /dev/null
+++ b/spec/integration/rails/exception_handling_spec.rb
@@ -0,0 +1,207 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ # rescue_registry renders exceptions from ActionDispatch middleware, which
+ # controller specs bypass entirely, so these have to be request specs.
+ RSpec.describe "exception handling", type: :request do
+ include Graphiti::Rails::TestHelpers
+
+ class SpecErrorsController < ApplicationController
+ def index
+ raise SPEC_ERROR
+ end
+ end
+
+ let(:invalid_request_errors) do
+ Graphiti::Util::SimpleErrors.new(Object.new).tap do |errors|
+ errors.add(:"filter.title", :unsupported, message: "is not supported")
+ end
+ end
+
+ before do
+ stub_const("SPEC_ERROR", error)
+ Rails.application.routes.draw do
+ get "/spec_errors" => "spec_errors#index"
+ end
+ end
+
+ # Exceptions propagate untouched in tests unless the request is wrapped.
+ def get_errors(accept: "application/vnd.api+json")
+ handle_request_exceptions do
+ get "/spec_errors", headers: {"HTTP_ACCEPT" => accept}
+ end
+ end
+
+ let(:json) { JSON.parse(response.body) }
+
+ context "Graphiti::Errors::InvalidRequest" do
+ let(:error) { Graphiti::Errors::InvalidRequest.new(invalid_request_errors) }
+
+ it "renders a 400 naming the rejected parameter" do
+ get_errors
+
+ expect(response.status).to eq(400)
+ expect(json["errors"]).to match([a_hash_including(
+ "code" => "bad_request",
+ "status" => "400",
+ "title" => "Request Error",
+ "source" => {"pointer" => "filter/title"},
+ "meta" => a_hash_including(
+ "attribute" => "filter.title",
+ "message" => "is not supported",
+ "code" => "unsupported"
+ )
+ )])
+ end
+ end
+
+ context "Graphiti::Errors::ConflictRequest" do
+ let(:error) { Graphiti::Errors::ConflictRequest.new(invalid_request_errors) }
+
+ it "renders a 409, not the 400 its superclass would give" do
+ get_errors
+
+ expect(response.status).to eq(409)
+ expect(json["errors"][0]).to include(
+ "code" => "conflict",
+ "status" => "409",
+ "title" => "Conflict Error"
+ )
+ end
+ end
+
+ context "Graphiti::Errors::RecordNotFound" do
+ let(:error) { Graphiti::Errors::RecordNotFound.new("employees", 123) }
+
+ it "renders a 404 with the exception message as detail" do
+ get_errors
+
+ expect(response.status).to eq(404)
+ expect(json["errors"][0]["code"]).to eq("not_found")
+ expect(json["errors"][0]["detail"])
+ .to eq("The referenced resource 'employees' with id '123' could not be found.")
+ end
+ end
+
+ context "a client-caused query error" do
+ let(:error) do
+ Graphiti::Errors::InvalidInclude.new(PORO::EmployeeResource.new, "foo")
+ end
+
+ it "renders a 400 with the exception message as detail" do
+ get_errors
+
+ expect(response.status).to eq(400)
+ expect(json["errors"][0]["detail"]).to match(/"foo" is not supported/)
+ end
+ end
+
+ context "paginating a sideload across multiple parents" do
+ let(:error) { Graphiti::Errors::UnsupportedPagination.new }
+
+ it "renders a 400, not the 500 an unregistered error would give" do
+ get_errors
+
+ expect(response.status).to eq(400)
+ expect(json["errors"][0]["detail"]).to match(/pagination of a sideload/)
+ end
+ end
+
+ context "an unregistered exception" do
+ let(:error) { RuntimeError.new("boom") }
+
+ it "is rendered as jsonapi by the fallback handler" do
+ get_errors
+
+ expect(response.status).to eq(500)
+ expect(response.content_type).to start_with("application/vnd.api+json")
+ end
+
+ it "claims nothing on the application's behalf" do
+ get_errors
+
+ expect(json["errors"][0]).to eq(
+ "code" => "internal_server_error",
+ "status" => "500",
+ "title" => "Internal Server Error"
+ )
+ end
+
+ context "when a locale names the error" do
+ around do |example|
+ original = ::I18n.backend
+ ::I18n.backend = ::I18n::Backend::Simple.new
+ ::I18n.backend.store_translations(:en, graphiti: {
+ errors: {internal_server_error: {title: "Omethingsay entway ongwray", detail: "Ytray againway ortlyshay."}}
+ })
+ example.run
+ ensure
+ ::I18n.backend = original
+ end
+
+ it "renders the title and detail from it" do
+ get_errors
+
+ expect(json["errors"][0]).to include(
+ "title" => "Omethingsay entway ongwray",
+ "detail" => "Ytray againway ortlyshay."
+ )
+ end
+ end
+
+ # The override dropped rescue_registry's status_code >= 500 guard, so the
+ # lookup runs at every status the fallback passes through to.
+ context "when the exception passes through to a status of its own" do
+ let(:error) { ActionController::RoutingError.new("nope") }
+
+ around do |example|
+ original = ::I18n.backend
+ ::I18n.backend = ::I18n::Backend::Simple.new
+ ::I18n.backend.store_translations(:en, graphiti: {
+ errors: {not_found: {detail: "Onegay orevermoreflay."}}
+ })
+ example.run
+ ensure
+ ::I18n.backend = original
+ end
+
+ it "reads the detail keyed by that status, not the fallback's" do
+ get_errors
+
+ expect(response.status).to eq(404)
+ expect(json["errors"][0]).to include(
+ "code" => "not_found",
+ "detail" => "Onegay orevermoreflay."
+ )
+ end
+ end
+
+ # FallbackHandler returns nil for anything outside
+ # handled_exception_formats, handing the request back to Rails.
+ it "is left to Rails for formats Graphiti does not handle" do
+ get_errors(accept: "text/html")
+
+ expect(response.status).to eq(500)
+ expect(response.content_type).to_not include("vnd.api+json")
+ end
+ end
+
+ context "when request exception handling is turned off" do
+ let(:error) { Graphiti::Errors::RecordNotFound.new }
+
+ it "lets the exception through so specs can assert on it directly" do
+ handle_request_exceptions(false) do
+ expect {
+ get "/spec_errors", headers: {"HTTP_ACCEPT" => "application/vnd.api+json"}
+ }.to raise_error(Graphiti::Errors::RecordNotFound)
+ end
+ end
+
+ it "restores the previous setting afterwards" do
+ handle_request_exceptions(false) {}
+
+ get_errors
+
+ expect(response.status).to eq(404)
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/finders_spec.rb b/spec/integration/rails/finders_spec.rb
index ca7ff869..bd519691 100644
--- a/spec/integration/rails/finders_spec.rb
+++ b/spec/integration/rails/finders_spec.rb
@@ -1,6 +1,6 @@
if ENV["APPRAISAL_INITIALIZED"]
RSpec.describe "integrated resources and adapters", type: :controller do
- include GraphitiSpecHelpers
+ include Graphiti::SpecHelpers
controller(ApplicationController) do
def index
@@ -74,22 +74,22 @@ def resource
it "allows basic sorting" do
do_index({sort: "-id"})
- expect(d.map(&:id)).to eq([author2.id, author1.id])
+ expect(jsonapi_data.map(&:id)).to eq([author2.id, author1.id])
end
it "allows allowlisted filters (and other configs)" do
do_index({filter: {first_name: "George"}})
- expect(d.map(&:id)).to eq([author2.id])
+ expect(jsonapi_data.map(&:id)).to eq([author2.id])
end
it "allows basic sideloading" do
do_index({include: "books"})
- expect(included.map(&:jsonapi_type).uniq).to match_array(%w[books])
+ expect(jsonapi_included.map(&:jsonapi_type).uniq).to match_array(%w[books])
end
it "allows nested sideloading" do
do_index({include: "books.genre"})
- expect(included.map(&:jsonapi_type).uniq)
+ expect(jsonapi_included.map(&:jsonapi_type).uniq)
.to match_array(%w[books genres])
end
@@ -99,7 +99,7 @@ def resource
subject do
do_index(page: params)
- d.map(&:id)
+ jsonapi_data.map(&:id)
end
context "by number and size" do
@@ -130,13 +130,10 @@ def resource
let(:params) { {size: 1, number: 2} }
around do |e|
- original = Graphiti.config.pagination_links
- begin
- Graphiti.config.pagination_links = true
- e.run
- ensure
- Graphiti.config.pagination_links = original
- end
+ Legacy::AuthorResource.page_links = true
+ e.run
+ ensure
+ Legacy::AuthorResource.page_links = false
end
let(:links) do
@@ -174,7 +171,7 @@ def resource
it "works" do
do_index(page: params)
- expect(d[0].id).to eq(author3.id)
+ expect(jsonapi_data[0].id).to eq(author3.id)
expect(links["self"])
.to eq("page[number]=2&page[offset]=1&page[size]=1")
expect(links["first"])
@@ -194,7 +191,7 @@ def resource
it "does not render 'next'" do
do_index(page: params)
- expect(d[0].id).to eq(author4.id)
+ expect(jsonapi_data[0].id).to eq(author4.id)
expect(links).to_not have_key("next")
end
end
@@ -206,7 +203,7 @@ def resource
describe "filtering" do
subject(:ids) do
do_index({filter: filter})
- d.map(&:id)
+ jsonapi_data.map(&:id)
end
let!(:author3) do
@@ -253,24 +250,24 @@ def resource
context "eq" do
it "executes case-sensitive search (invalid)" do
do_index({filter: {identifier: "abc123"}})
- expect(d.map(&:id)).to eq([])
+ expect(jsonapi_data.map(&:id)).to eq([])
end
it "executes case-sensitive search (valid)" do
do_index({filter: {identifier: "AbC123"}})
- expect(d.map(&:id)).to eq([author3.id])
+ expect(jsonapi_data.map(&:id)).to eq([author3.id])
end
end
context "!eq" do
it "executes case-sensitive search (invalid)" do
do_index({filter: {identifier: {not_eq: "abc123"}}})
- expect(d.map(&:id).length).to eq(3)
+ expect(jsonapi_data.map(&:id).length).to eq(3)
end
it "executes case-sensitive search (valid)" do
do_index({filter: {identifier: {not_eq: "AbC123"}}})
- expect(d.map(&:id).length).to eq(2)
+ expect(jsonapi_data.map(&:id).length).to eq(2)
end
end
end
@@ -321,14 +318,14 @@ def resource
end
context "!eq" do
- let(:value) { {'!eq': "george"} }
+ let(:value) { {"!eq": "george"} }
it "executes case-insensitive NOT search" do
expect(ids).to eq([author1.id])
end
context "with nil value" do
- let(:value) { {'!eq': nil} }
+ let(:value) { {"!eq": nil} }
let!(:author3) { Legacy::Author.create! }
it "works" do
@@ -365,7 +362,7 @@ def resource
end
context "!eql" do
- let(:value) { {'!eql': "GeOrge"} }
+ let(:value) { {"!eql": "GeOrge"} }
it "executes case-sensitive search" do
expect(ids).to eq([author1.id, author2.id])
@@ -399,7 +396,7 @@ def resource
end
context "!prefix" do
- let(:value) { {'!prefix': "Geo"} }
+ let(:value) { {"!prefix": "Geo"} }
it "executes case-insensitive prefix NOT query" do
expect(ids).to eq([author1.id])
@@ -433,7 +430,7 @@ def resource
end
context "!suffix" do
- let(:value) { {'!suffix': "orge"} }
+ let(:value) { {"!suffix": "orge"} }
it "executes case-insensitive suffix NOT query" do
expect(ids).to eq([author1.id])
@@ -467,7 +464,7 @@ def resource
end
context "!match" do
- let(:value) { {'!match': "org"} }
+ let(:value) { {"!match": "org"} }
it "executes case-insensitive NOT match query" do
expect(ids).to eq([author1.id])
@@ -487,7 +484,7 @@ def resource
end
context "!eq" do
- let(:value) { {'!eq': 65} }
+ let(:value) { {"!eq": 65} }
it "works" do
expect(ids).to eq([author1.id, author3.id])
@@ -539,14 +536,14 @@ def resource
let(:filter) { {decimal_age: value} }
context "eq" do
- let(:value) { {eq: 70.011.to_d} }
+ let(:value) { {eq: BigDecimal("70.011")} }
it "works" do
expect(ids).to eq([author2.id])
end
context "as a string" do
- let(:value) { {eq: 70.011.to_d.to_s} }
+ let(:value) { {eq: BigDecimal("70.011").to_s} }
it "works" do
expect(ids).to eq([author2.id])
@@ -555,7 +552,7 @@ def resource
end
context "!eq" do
- let(:value) { {'!eq': 70.011.to_d} }
+ let(:value) { {"!eq": BigDecimal("70.011")} }
it "works" do
expect(ids).to eq([author1.id, author3.id])
@@ -563,7 +560,7 @@ def resource
end
context "nothing" do
- let(:value) { 70.011.to_d }
+ let(:value) { BigDecimal("70.011") }
it "defaults to eq" do
expect(ids).to eq([author2.id])
@@ -571,7 +568,7 @@ def resource
end
context "gt" do
- let(:value) { {gt: 70.033.to_d} }
+ let(:value) { {gt: BigDecimal("70.033")} }
it "works" do
expect(ids).to eq([author3.id])
@@ -579,7 +576,7 @@ def resource
end
context "gte" do
- let(:value) { {gte: 70.033.to_d} }
+ let(:value) { {gte: BigDecimal("70.033")} }
it "works" do
expect(ids).to eq([author1.id, author3.id])
@@ -587,7 +584,7 @@ def resource
end
context "lt" do
- let(:value) { {lt: 70.033.to_d} }
+ let(:value) { {lt: BigDecimal("70.033")} }
it "works" do
expect(ids).to eq([author2.id])
@@ -595,7 +592,7 @@ def resource
end
context "lte" do
- let(:value) { {lte: 70.033.to_d} }
+ let(:value) { {lte: BigDecimal("70.033")} }
it "works" do
expect(ids).to eq([author1.id, author2.id])
@@ -615,7 +612,7 @@ def resource
end
context "!eq" do
- let(:value) { {'!eq': 70.01} }
+ let(:value) { {"!eq": 70.01} }
it "works" do
expect(ids).to eq([author1.id, author3.id])
@@ -675,7 +672,7 @@ def resource
end
context "!eq" do
- let(:value) { {'!eq': two_days_ago.to_date.iso8601} }
+ let(:value) { {"!eq": two_days_ago.to_date.iso8601} }
it "works" do
expect(ids).to eq([author1.id, author3.id])
@@ -744,7 +741,7 @@ def resource
end
context "!eq" do
- let(:value) { {'!eq': two_days_ago.iso8601} }
+ let(:value) { {"!eq": two_days_ago.iso8601} }
it "works" do
expect(ids).to eq([author1.id, author3.id])
@@ -752,7 +749,7 @@ def resource
context "when value is nil" do
let(:filter) { {last_login: value} }
- let(:value) { {'!eq': "null"} }
+ let(:value) { {"!eq": "null"} }
it "works" do
expect(ids).to eq([author1.id, author2.id])
@@ -1035,7 +1032,7 @@ def resource
"decimal_age" => "70.033",
"active" => true
})
- expect(included.map(&:jsonapi_type).uniq).to match_array(%w[books])
+ expect(jsonapi_included.map(&:jsonapi_type).uniq).to match_array(%w[books])
end
context "and record not found" do
@@ -1057,7 +1054,7 @@ def resource
include: "books"
})
expect(json["data"][0]["relationships"]).to be_present
- expect(included.map(&:jsonapi_type).uniq).to match_array(%w[books])
+ expect(jsonapi_included.map(&:jsonapi_type).uniq).to match_array(%w[books])
end
end
end
@@ -1065,7 +1062,7 @@ def resource
context "sideloading has_many" do
it "can sideload" do
do_index({include: "books"})
- expect(included("books").map(&:id)).to eq([book1.id, book2.id])
+ expect(jsonapi_included("books").map(&:id)).to eq([book1.id, book2.id])
end
context "when paginating the sideload" do
@@ -1083,7 +1080,7 @@ def resource
it "works" do
request
- expect(included("books").map(&:id)).to eq([book2.id])
+ expect(jsonapi_included("books").map(&:id)).to eq([book2.id])
end
end
@@ -1102,17 +1099,17 @@ def resource
it "allows sorting of sideloaded resource" do
do_index({include: "books", sort: "-books.id"})
- expect(included("books").map(&:id)).to eq([book2.id, book1.id])
+ expect(jsonapi_included("books").map(&:id)).to eq([book2.id, book1.id])
end
it "allows filtering of sideloaded resource" do
do_index({include: "books", filter: {books: {id: book2.id}}})
- expect(included("books").map(&:id)).to eq([book2.id])
+ expect(jsonapi_included("books").map(&:id)).to eq([book2.id])
end
it "allows extra fields for sideloaded resource" do
do_index({include: "books", extra_fields: {books: "alternate_title"}})
- book = included("books")[0]
+ book = jsonapi_included("books")[0]
expect(book["title"]).to be_present
expect(book["pages"]).to be_present
expect(book["alternate_title"]).to eq("alt title")
@@ -1120,7 +1117,7 @@ def resource
it "allows sparse fieldsets for the sideloaded resource" do
do_index({include: "books", fields: {books: "pages"}})
- book = included("books")[0]
+ book = jsonapi_included("books")[0]
expect(book).to_not have_key("title")
expect(book).to_not have_key("alternate_title")
expect(book["pages"]).to eq(500)
@@ -1128,7 +1125,7 @@ def resource
it "allows extra fields and sparse fieldsets for the sideloaded resource" do
do_index({include: "books", fields: {books: "pages"}, extra_fields: {books: "alternate_title"}})
- book = included("books")[0]
+ book = jsonapi_included("books")[0]
expect(book).to have_key("pages")
expect(book).to have_key("alternate_title")
expect(book).to_not have_key("title")
@@ -1149,7 +1146,7 @@ def resource
it "still works" do
do_index({include: "books"})
- expect(included("books").map(&:id)).to eq([book1.id, book2.id])
+ expect(jsonapi_included("books").map(&:id)).to eq([book1.id, book2.id])
end
end
end
@@ -1157,12 +1154,12 @@ def resource
context "sideloading belongs_to" do
it "can sideload" do
do_index({include: "state"})
- expect(included("states").map(&:id)).to eq([state.id])
+ expect(jsonapi_included("states").map(&:id)).to eq([state.id])
end
it "allows extra fields for sideloaded resource" do
do_index({include: "state", extra_fields: {states: "population"}})
- state = included("states")[0]
+ state = jsonapi_included("states")[0]
expect(state["name"]).to be_present
expect(state["abbreviation"]).to be_present
expect(state["population"]).to be_present
@@ -1170,7 +1167,7 @@ def resource
it "allows sparse fieldsets for the sideloaded resource" do
do_index({include: "state", fields: {states: "name"}})
- state = included("states")[0]
+ state = jsonapi_included("states")[0]
expect(state["name"]).to be_present
expect(state).to_not have_key("abbreviation")
expect(state).to_not have_key("population")
@@ -1178,7 +1175,7 @@ def resource
it "allows extra fields and sparse fieldsets for the sideloaded resource" do
do_index({include: "state", fields: {states: "name"}, extra_fields: {states: "population"}})
- state = included("states")[0]
+ state = jsonapi_included("states")[0]
expect(state).to have_key("name")
expect(state).to have_key("population")
expect(state).to_not have_key("abbreviation")
@@ -1188,12 +1185,12 @@ def resource
context "sideloading has_one" do
it "can sideload" do
do_index({include: "bio"})
- expect(included("bios").map(&:id)).to eq([bio.id])
+ expect(jsonapi_included("bios").map(&:id)).to eq([bio.id])
end
it "allows extra fields for sideloaded resource" do
do_index({include: "bio", extra_fields: {bios: "created_at"}})
- bio = included("bios")[0]
+ bio = jsonapi_included("bios")[0]
expect(bio["description"]).to be_present
expect(bio["created_at"]).to be_present
expect(bio["picture"]).to be_present
@@ -1201,7 +1198,7 @@ def resource
it "allows sparse fieldsets for the sideloaded resource" do
do_index({include: "bio", fields: {bios: "description"}})
- bio = included("bios")[0]
+ bio = jsonapi_included("bios")[0]
expect(bio["description"]).to be_present
expect(bio).to_not have_key("created_at")
expect(bio).to_not have_key("picture")
@@ -1209,7 +1206,7 @@ def resource
it "allows extra fields and sparse fieldsets for the sideloaded resource" do
do_index({include: "bio", fields: {bios: "description"}, extra_fields: {bios: "created_at"}})
- bio = included("bios")[0]
+ bio = jsonapi_included("bios")[0]
expect(bio).to have_key("description")
expect(bio).to have_key("created_at")
expect(bio).to_not have_key("picture")
@@ -1240,7 +1237,7 @@ def resource
it "still works" do
do_index({include: "bio.bio_labels"})
- expect(included("bio_labels").length).to eq(1)
+ expect(jsonapi_included("bio_labels").length).to eq(1)
end
end
end
@@ -1249,12 +1246,12 @@ def resource
context "sideloading many_to_many" do
it "can sideload" do
do_index({include: "hobbies"})
- expect(included("hobbies").map(&:id)).to eq([hobby1.id, hobby2.id])
+ expect(jsonapi_included("hobbies").map(&:id)).to eq([hobby1.id, hobby2.id])
end
it "allows sorting of sideloaded resource" do
do_index({include: "hobbies", sort: "-hobbies.name"})
- expect(included("hobbies").map(&:id)).to eq([hobby2.id, hobby1.id])
+ expect(jsonapi_included("hobbies").map(&:id)).to eq([hobby2.id, hobby1.id])
end
it "allows filtering of sideloaded resource" do
@@ -1262,7 +1259,7 @@ def resource
include: "hobbies",
filter: {hobbies: {id: hobby2.id}}
})
- expect(included("hobbies").map(&:id)).to eq([hobby2.id])
+ expect(jsonapi_included("hobbies").map(&:id)).to eq([hobby2.id])
end
it "allows extra fields for sideloaded resource" do
@@ -1270,7 +1267,7 @@ def resource
include: "hobbies",
extra_fields: {hobbies: "reason"}
})
- hobby = included("hobbies")[0]
+ hobby = jsonapi_included("hobbies")[0]
expect(hobby["name"]).to be_present
expect(hobby["description"]).to be_present
expect(hobby["reason"]).to eq("hobby reason")
@@ -1278,7 +1275,7 @@ def resource
it "allows sparse fieldsets for the sideloaded resource" do
do_index({include: "hobbies", fields: {hobbies: "name"}})
- hobby = included("hobbies")[0]
+ hobby = jsonapi_included("hobbies")[0]
expect(hobby["name"]).to be_present
expect(hobby).to_not have_key("description")
expect(hobby).to_not have_key("reason")
@@ -1290,7 +1287,7 @@ def resource
fields: {hobbies: "name"},
extra_fields: {hobbies: "reason"}
})
- hobby = included("hobbies")[0]
+ hobby = jsonapi_included("hobbies")[0]
expect(hobby).to have_key("name")
expect(hobby).to have_key("reason")
expect(hobby).to_not have_key("description")
@@ -1302,8 +1299,8 @@ def resource
fields: {hobbies: "name", books: "title"},
extra_fields: {hobbies: "reason", books: "alternate_title"}
})
- hobby = included("hobbies")[0]
- book = included("books")[0]
+ hobby = jsonapi_included("hobbies")[0]
+ book = jsonapi_included("books")[0]
expect(hobby).to have_key("name")
expect(hobby).to have_key("reason")
expect(hobby).to_not have_key("description")
@@ -1320,7 +1317,7 @@ def resource
author1_hobbies = author1_relationships["hobbies"]["data"]
author2_hobbies = author2_relationships["hobbies"]["data"]
- expect(included("hobbies").size).to eq(2)
+ expect(jsonapi_included("hobbies").size).to eq(2)
expect(author1_hobbies.size).to eq(2)
expect(author2_hobbies.size).to eq(1)
end
@@ -1329,14 +1326,14 @@ def resource
let(:shop) { Legacy::Sales::Shop.create(name: "shop") }
before do
- allow(Legacy::Sales::ShopResource).to receive(:validate_endpoints?) { false }
+ allow(Legacy::Sales::ShopResource).to receive(:validate_requests?) { false }
allow(controller).to receive(:resource).and_return(Legacy::Sales::ShopResource)
Legacy::Sales::Stock.create(shop_id: shop.id, book_id: book1.id, amount: 2)
end
it "works" do
do_index({include: "books"})
- expect(included("books").map(&:id)).to eq([book1.id])
+ expect(jsonapi_included("books").map(&:id)).to eq([book1.id])
end
end
@@ -1392,7 +1389,7 @@ def resource
it "still works" do
do_index({include: "mentors"})
- target = d.find { |e| e.id == author_with_mentors.id }
+ target = jsonapi_data.find { |e| e.id == author_with_mentors.id }
expect(target.relationships["mentors"]).to eq({
"data" => [
{"type" => "authors", "id" => author_with_mentees.id.to_s},
@@ -1404,7 +1401,7 @@ def resource
it "allows filtering by the association" do
do_index({filter: {mentor_id: author_with_mentees.id}})
- expect(d.map(&:id)).to eq([author_with_mentors.id, author_with_both.id])
+ expect(jsonapi_data.map(&:id)).to eq([author_with_mentors.id, author_with_both.id])
end
end
@@ -1413,7 +1410,7 @@ def resource
Legacy::UserResource.class_eval do
many_to_many :books, resource: Legacy::BookResource
end
- allow(Legacy::BookResource).to receive(:validate_endpoints?) { false }
+ allow(Legacy::BookResource).to receive(:validate_requests?) { false }
allow(controller).to receive(:resource) { Legacy::BookResource }
end
@@ -1426,7 +1423,7 @@ def resource
it "correctly infers the filter name for the association from the inverse_of option" do
do_index({filter: {reader_id: reader.id}})
- expect(d.map(&:id)).to eq([book2.id])
+ expect(jsonapi_data.map(&:id)).to eq([book2.id])
end
context "when the graphiti association manually sets inverse_filter" do
@@ -1439,7 +1436,7 @@ def resource
it "overrides the inferred one" do
do_index({filter: {the_reader_id: reader.id}})
- expect(d.map(&:id)).to eq([book2.id])
+ expect(jsonapi_data.map(&:id)).to eq([book2.id])
end
end
end
@@ -1466,7 +1463,7 @@ def resource
it "still works" do
do_index({include: "hobbies"})
- expect(included("hobbies").map(&:id))
+ expect(jsonapi_included("hobbies").map(&:id))
.to eq([other_table_hobby1.id, other_table_hobby2.id])
end
end
@@ -1484,8 +1481,7 @@ def resource
required: false,
operators:
{eq: nil, not_eq: nil, gt: nil, gte: nil, lt: nil, lte: nil},
- allow_nil: false,
- deny_empty: false
+ blanks: :literal
}
}
@@ -1533,7 +1529,7 @@ def resource
it "still works" do
do_index({include: "hobbies"})
- expect(included("hobbies").map(&:id)).to eq([hobby1.id, hobby2.id])
+ expect(jsonapi_included("hobbies").map(&:id)).to eq([hobby1.id, hobby2.id])
end
describe "filtering relationship" do
@@ -1551,7 +1547,7 @@ def index
it "can filter the relationship by the custom name" do
do_index(filter: {the_id_of_the_author: [author1.id, author2.id].join(",")})
- expect(d.map(&:id)).to eq([hobby1.id, hobby2.id])
+ expect(jsonapi_data.map(&:id)).to eq([hobby1.id, hobby2.id])
end
end
end
@@ -1569,9 +1565,9 @@ def index
it "still works" do
do_index({include: "tags"})
- sl = d[0].sideload(:tags)
+ sl = jsonapi_data[0].sideload(:tags)
expect(sl.map(&:id)).to eq([tag1.id])
- sl = d[1].sideload(:tags)
+ sl = jsonapi_data[1].sideload(:tags)
expect(sl.map(&:id)).to eq([tag1.id, tag2.id])
end
end
@@ -1584,12 +1580,12 @@ def index
end
it "still works" do
- allow(Legacy::BookResource).to receive(:validate_endpoints?) { false }
+ allow(Legacy::BookResource).to receive(:validate_requests?) { false }
allow(controller).to receive(:resource) { Legacy::BookResource }
do_index({include: "tags"})
- sl = d[0].sideload(:tags)
+ sl = jsonapi_data[0].sideload(:tags)
expect(sl.map(&:id)).to eq([tag1.id, tag2.id])
- sl = d[1].sideload(:tags)
+ sl = jsonapi_data[1].sideload(:tags)
expect(sl.map(&:id)).to eq([tag1.id])
end
end
@@ -1605,7 +1601,7 @@ def index
end
it "still works" do
- allow(Legacy::TagResource).to receive(:validate_endpoints?) { false }
+ allow(Legacy::TagResource).to receive(:validate_requests?) { false }
allow(controller).to receive(:resource) { Legacy::TagResource }
do_index({
filter: {
@@ -1615,7 +1611,7 @@ def index
]
}
})
- expect(d.map(&:name)).to eq(%w[One Two Three])
+ expect(jsonapi_data.map(&:name)).to eq(%w[One Two Three])
end
end
end
@@ -1625,7 +1621,7 @@ def index
context "sideloading self-referential" do
it "works" do
do_index({include: "organization.children"})
- includes = included("organizations")
+ includes = jsonapi_included("organizations")
expect(includes[0]["name"]).to eq("Org1")
expect(includes[1]["name"]).to eq("Org2")
end
@@ -1651,7 +1647,7 @@ def index
filter: {books: {id: book1.id}, other_books: {id: book2.id}},
include: "books.genre,other_books.genre"
})
- expect(included("genres").length).to eq(2)
+ expect(jsonapi_included("genres").length).to eq(2)
end
end
@@ -1661,11 +1657,11 @@ def index
include: "dwelling",
extra_fields: {houses: "house_price", condos: "condo_price"}
})
- house = included("houses")[0]
+ house = jsonapi_included("houses")[0]
expect(house["name"]).to be_present
expect(house["house_description"]).to be_present
expect(house["house_price"]).to eq(1_000_000)
- condo = included("condos")[0]
+ condo = jsonapi_included("condos")[0]
expect(condo["name"]).to be_present
expect(condo["condo_description"]).to be_present
expect(condo["condo_price"]).to eq(500_000)
@@ -1676,11 +1672,11 @@ def index
include: "dwelling",
fields: {houses: "name", condos: "condo_description"}
})
- house = included("houses")[0]
+ house = jsonapi_included("houses")[0]
expect(house["name"]).to be_present
expect(house).to_not have_key("house_description")
expect(house).to_not have_key("house_price")
- condo = included("condos")[0]
+ condo = jsonapi_included("condos")[0]
expect(condo["condo_description"]).to be_present
expect(condo).to_not have_key("name")
expect(condo).to_not have_key("condo_price")
@@ -1692,8 +1688,8 @@ def index
fields: {houses: "name", condos: "condo_description"},
extra_fields: {houses: "house_price", condos: "condo_price"}
})
- house = included("houses")[0]
- condo = included("condos")[0]
+ house = jsonapi_included("houses")[0]
+ condo = jsonapi_included("condos")[0]
expect(house).to have_key("name")
expect(house).to have_key("house_price")
expect(house).to_not have_key("house_description")
@@ -1705,7 +1701,7 @@ def index
# NB: Condo does NOT have a state relationship
it "allows additional levels of nesting" do
do_index({include: "dwelling.state"})
- expect(included("states").length).to eq(1)
+ expect(jsonapi_included("states").length).to eq(1)
end
end
@@ -1720,7 +1716,7 @@ def base_scope
it "can query stats total count" do
do_index({stats: {total: "count"}})
- expect(d.map(&:id)).to eq([author1.id, author2.id])
+ expect(jsonapi_data.map(&:id)).to eq([author1.id, author2.id])
end
end
end
diff --git a/spec/integration/rails/hooks_spec.rb b/spec/integration/rails/hooks_spec.rb
index a136a35f..a32789ea 100644
--- a/spec/integration/rails/hooks_spec.rb
+++ b/spec/integration/rails/hooks_spec.rb
@@ -137,8 +137,8 @@ def json
context "after_save" do
before do
- book_data << {'temp-id': "abc123", type: "books", method: "create"}
- book_included << {'temp-id': "abc123", type: "books", attributes: {title: "one"}}
+ book_data << {"temp-id": "abc123", type: "books", method: "create"}
+ book_included << {"temp-id": "abc123", type: "books", attributes: {title: "one"}}
book_data << {id: update_book.id.to_s, type: "books", method: "update"}
book_included << {id: update_book.id.to_s, type: "books", attributes: {title: "updated!"}}
end
@@ -146,10 +146,10 @@ def json
context "after_create" do
before do
- book_data << {'temp-id': "abc123", type: "books", method: "create"}
- book_included << {'temp-id': "abc123", type: "books", attributes: {title: "one"}}
- book_data << {'temp-id': "abc456", type: "books", method: "create"}
- book_included << {'temp-id': "abc456", type: "books", attributes: {title: "two"}}
+ book_data << {"temp-id": "abc123", type: "books", method: "create"}
+ book_included << {"temp-id": "abc123", type: "books", attributes: {title: "one"}}
+ book_data << {"temp-id": "abc456", type: "books", method: "create"}
+ book_included << {"temp-id": "abc456", type: "books", attributes: {title: "two"}}
end
it "fires hooks correctly" do
@@ -230,10 +230,10 @@ def json
end
context "belongs_to" do
- let(:state_data) { {'temp-id': "abc123", type: "states", method: "create"} }
+ let(:state_data) { {"temp-id": "abc123", type: "states", method: "create"} }
before do
- state_included << {'temp-id': "abc123", type: "states", attributes: {name: "New York"}}
+ state_included << {"temp-id": "abc123", type: "states", attributes: {name: "New York"}}
end
it "also works" do
diff --git a/spec/integration/rails/i18n_spec.rb b/spec/integration/rails/i18n_spec.rb
index 98593023..73624a11 100644
--- a/spec/integration/rails/i18n_spec.rb
+++ b/spec/integration/rails/i18n_spec.rb
@@ -1,7 +1,9 @@
if ENV["APPRAISAL_INITIALIZED"]
RSpec.describe "i18n features" do
+ # An earlier lookup leaves the backend initialized, and it reads load_path once.
before(:all) do
I18n.load_path << File.expand_path("../../support/locale/documentation_i18n.yml", __dir__)
+ I18n.reload!
end
describe "Type descriptions" do
diff --git a/spec/integration/rails/install_generator_spec.rb b/spec/integration/rails/install_generator_spec.rb
new file mode 100644
index 00000000..edf37938
--- /dev/null
+++ b/spec/integration/rails/install_generator_spec.rb
@@ -0,0 +1,94 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails/generators"
+ require "generators/graphiti/install_generator"
+ require "fileutils"
+ require "tmpdir"
+
+ RSpec.describe Graphiti::InstallGenerator do
+ let(:destination) { Dir.mktmpdir("graphiti-install") }
+
+ def generated(path)
+ File.read(File.join(destination, path))
+ end
+
+ def install!(*arguments)
+ Dir.chdir(destination) do
+ described_class.start(
+ arguments,
+ destination_root: destination, shell: Thor::Shell::Basic.new
+ )
+ end
+ end
+
+ before do
+ allow(::Rails).to receive(:root).and_return(Pathname.new(destination))
+
+ FileUtils.mkdir_p(File.join(destination, "app/controllers"))
+ FileUtils.mkdir_p(File.join(destination, "config"))
+ File.write(File.join(destination, "app/controllers/application_controller.rb"), <<~RUBY)
+ class ApplicationController < ActionController::API
+ end
+ RUBY
+ File.write(File.join(destination, "config/application.rb"), <<~RUBY)
+ module Dummy
+ class Application < Rails::Application
+ end
+ end
+ RUBY
+ File.write(File.join(destination, "config/routes.rb"), <<~RUBY)
+ Rails.application.routes.draw do
+ end
+ RUBY
+ # Present so the generator does not prompt for an API namespace on stdin.
+ File.write(File.join(destination, ".graphiticfg.yml"), {"namespace" => "/api/v1"}.to_yaml)
+ end
+
+ after { FileUtils.remove_entry(destination) }
+
+ it "still finishes when the app has no rails_helper to inject into" do
+ install!
+
+ expect(generated("config/routes.rb")).to include("scope path:")
+ end
+
+ it "wires the schema check into an existing rails_helper" do
+ FileUtils.mkdir_p(File.join(destination, "spec"))
+ File.write(File.join(destination, "spec/rails_helper.rb"), <<~RUBY)
+ RSpec.configure do |config|
+ end
+ RUBY
+
+ install!
+
+ expect(generated("spec/rails_helper.rb")).to include("Graphiti::SpecHelpers::RSpec.schema!")
+ expect(generated("config/routes.rb")).to include("scope path:")
+ end
+
+ it "writes the locale file too" do
+ install!
+
+ expect(File.exist?(File.join(destination, "config/locales/graphiti.en.yml"))).to eq(true)
+ end
+
+ it "leaves config/application.rb alone" do
+ install!
+
+ expect(generated("config/application.rb")).to_not include("default_url_options")
+ end
+
+ it "scopes routes under the namespace without naming ApplicationResource" do
+ install!
+
+ expect(generated("config/routes.rb"))
+ .to include(%(scope path: "/api/v1", defaults: {format: :jsonapi} do))
+ expect(generated("config/routes.rb")).to_not include("ApplicationResource")
+ end
+
+ it "gives ApplicationResource a base_url that stands on its own" do
+ install!
+
+ expect(generated("app/resources/application_resource.rb"))
+ .to include(%(self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000')))
+ end
+ end
+end
diff --git a/spec/integration/rails/locale_generator_spec.rb b/spec/integration/rails/locale_generator_spec.rb
new file mode 100644
index 00000000..dd13e3d5
--- /dev/null
+++ b/spec/integration/rails/locale_generator_spec.rb
@@ -0,0 +1,94 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails/generators"
+ require "generators/graphiti/locale_generator"
+ require "fileutils"
+ require "tmpdir"
+ require "yaml"
+
+ RSpec.describe Graphiti::LocaleGenerator do
+ let(:destination) { Dir.mktmpdir("graphiti-locale") }
+
+ def generated
+ File.read(File.join(destination, "config/locales/graphiti.en.yml"))
+ end
+
+ def generate!(*arguments)
+ described_class.start(
+ arguments,
+ destination_root: destination, shell: Thor::Shell::Basic.new
+ )
+ end
+
+ after { FileUtils.remove_entry(destination) }
+
+ it "writes every message Graphiti renders" do
+ generate!
+
+ locale = YAML.safe_load(generated)
+
+ expect(locale.dig("en", "graphiti", "errors", "messages"))
+ .to eq(Graphiti::Util::SimpleErrors::DEFAULT_MESSAGES.transform_keys(&:to_s))
+ expect(locale.dig("en", "graphiti", "errors", "format"))
+ .to eq(Graphiti::Util::SimpleErrors::DEFAULT_FORMAT)
+ end
+
+ it "writes the title Graphiti renders for every code" do
+ generate!
+
+ titles = YAML.safe_load(generated)
+ .dig("en", "graphiti", "errors")
+ .slice(*Graphiti::LocaleGenerator::ERROR_TITLES.keys.map(&:to_s))
+ .transform_values { |entry| entry["title"] }
+
+ expect(titles).to eq(Graphiti::LocaleGenerator::ERROR_TITLES.transform_keys(&:to_s))
+ end
+
+ it "names the exceptions behind each registered code" do
+ generate!
+
+ expect(generated).to include("# RecordNotFound")
+ expect(generated).to include("# ConflictRequest")
+ expect(generated).to include("# InvalidRequest, RemoteWrite,")
+ end
+
+ it "names every registered exception, so a new one cannot go undocumented" do
+ generate!
+
+ Graphiti::Rails::CLIENT_ERROR_STATUSES.each_key do |exception|
+ expect(generated).to include(exception.delete_prefix("Graphiti::Errors::"))
+ end
+ end
+
+ it "gives the fallback a detail, since nothing else reports one for it" do
+ generate!
+
+ expect(YAML.safe_load(generated).dig("en", "graphiti", "errors", "internal_server_error"))
+ .to eq(
+ "title" => "Internal Server Error",
+ "detail" => "We've probably received an error report already, but please contact us if the issue persists."
+ )
+ end
+
+ it "writes a detail for no other code" do
+ generate!
+
+ codes = YAML.safe_load(generated).dig("en", "graphiti", "errors")
+
+ expect(codes.select { |_code, entry| entry.is_a?(Hash) && entry.key?("detail") }.keys)
+ .to eq(["internal_server_error"])
+ end
+
+ it "still writes the detail with comments omitted" do
+ generate!("--omit-comments")
+
+ expect(YAML.safe_load(generated).dig("en", "graphiti", "errors", "internal_server_error", "detail"))
+ .to eq("We've probably received an error report already, but please contact us if the issue persists.")
+ end
+
+ it "omits the comments when asked" do
+ generate!("--omit-comments")
+
+ expect(generated).to_not include("#")
+ end
+ end
+end
diff --git a/spec/integration/rails/locale_spec.rb b/spec/integration/rails/locale_spec.rb
new file mode 100644
index 00000000..2f568bf2
--- /dev/null
+++ b/spec/integration/rails/locale_spec.rb
@@ -0,0 +1,49 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails_spec_helper"
+
+ RSpec.describe "I18n.locale inside concurrent sideloads" do
+ include ConcurrencyHarness
+
+ let!(:employee) { Employee.create!(first_name: "Jane") }
+
+ before do
+ allow(Graphiti.config).to receive(:concurrency).and_return(true)
+ with_thread_pool(max_threads: 2)
+ end
+
+ around do |example|
+ available = I18n.available_locales
+ I18n.available_locales = available | [:de]
+ example.run
+ ensure
+ I18n.locale = I18n.default_locale
+ I18n.available_locales = available
+ end
+
+ it "resolves a sideload in the locale the request set" do
+ observed = nil
+
+ resource_class = Class.new(EmployeeResource) do
+ def self.name
+ "EmployeeResource"
+ end
+ end
+ resource_class.has_many :probe_positions, resource: PositionResource, foreign_key: :employee_id do
+ scope do |_employee_ids|
+ observed = {thread: Thread.current.object_id, locale: I18n.locale}
+ Position.none
+ end
+ end
+
+ resource_class.has_many :other_positions, resource: PositionResource, foreign_key: :employee_id do
+ scope { |_employee_ids| Position.none }
+ end
+
+ I18n.locale = :de
+ resource_class.all(filter: {id: employee.id}, include: "probe_positions,other_positions").to_a
+
+ expect(observed[:thread]).to_not eq(Thread.current.object_id)
+ expect(observed[:locale]).to eq(:de)
+ end
+ end
+end
diff --git a/spec/integration/rails/persistence_spec.rb b/spec/integration/rails/persistence_spec.rb
index a7cd55b9..9cb8e585 100644
--- a/spec/integration/rails/persistence_spec.rb
+++ b/spec/integration/rails/persistence_spec.rb
@@ -1,6 +1,6 @@
if ENV["APPRAISAL_INITIALIZED"]
RSpec.describe "persistence", type: :controller do
- include GraphitiSpecHelpers
+ include Graphiti::SpecHelpers
# defined in spec/supports/rails/employee_controller.rb
controller(ApplicationController, &EMPLOYEE_CONTROLLER_BLOCK)
@@ -109,7 +109,7 @@
context "when reserved parameter used" do
before do
resource = Class.new(EmployeeResource) do
- self.validate_endpoints = false
+ self.validate_requests = false
attribute :page, :integer
end
allow(controller).to receive(:resource) { resource }
@@ -263,7 +263,7 @@ def self.name
context "when has_many" do
let(:klass) do
Class.new(EmployeeResource) do
- self.validate_endpoints = false
+ self.validate_requests = false
end
end
@@ -283,7 +283,7 @@ def self.name
relationships: {
positions: {
data: [{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "positions",
method: "create"
}]
@@ -292,7 +292,7 @@ def self.name
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "positions",
attributes: {title: "foo"}
}
@@ -310,7 +310,7 @@ def self.name
make_request
}.to(raise_error { |e|
expect(e).to be_a Graphiti::Errors::InvalidRequest
- expect(e.errors.full_messages).to eq ["data.relationships.positions is unwritable relationship"]
+ expect(e.errors.full_messages).to eq ["data.relationships.positions cannot be written"]
})
end
end
@@ -318,7 +318,7 @@ def self.name
context "when belongs_to" do
let(:klass) do
Class.new(EmployeeResource) do
- self.validate_endpoints = false
+ self.validate_requests = false
belongs_to :classification, writable: false
end
end
@@ -352,7 +352,7 @@ def self.name
make_request
}.to(raise_error { |e|
expect(e).to be_a Graphiti::Errors::InvalidRequest
- expect(e.errors.full_messages).to eq ["data.relationships.classification is unwritable relationship"]
+ expect(e.errors.full_messages).to eq ["data.relationships.classification cannot be written"]
})
end
end
@@ -363,7 +363,7 @@ def self.name
let(:klass) do
Class.new(EmployeeResource) do
- self.validate_endpoints = false
+ self.validate_requests = false
after_graph_persist do |model|
model.valid?(:after_graph_persisted)
@@ -390,20 +390,20 @@ def self.model_name
relationships: {
positions: {
data: [
- {'temp-id': "pos1", type: "positions", method: "create"},
- {'temp-id': "pos2", type: "positions", method: "create"}
+ {"temp-id": "pos1", type: "positions", method: "create"},
+ {"temp-id": "pos2", type: "positions", method: "create"}
]
}
}
},
included: [
{
- 'temp-id': "pos1",
+ "temp-id": "pos1",
type: "positions",
attributes: {title: "foo"}
},
{
- 'temp-id': "pos2",
+ "temp-id": "pos2",
type: "positions",
attributes: {title: "bar"}
}
@@ -450,7 +450,7 @@ def self.model_name
context "when belongs_to" do
let(:klass) do
Class.new(EmployeeResource) do
- self.validate_endpoints = false
+ self.validate_requests = false
attribute :classification_id, :integer, writable: false
end
end
@@ -518,7 +518,7 @@ def self.name
context "when has_many" do
let(:klass) do
Class.new(EmployeeResource) do
- self.validate_endpoints = false
+ self.validate_requests = false
end
end
@@ -541,7 +541,7 @@ def self.name
relationships: {
positions: {
data: [{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "positions",
method: "create"
}]
@@ -550,7 +550,7 @@ def self.name
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "positions",
attributes: {title: "foo"}
}
@@ -604,7 +604,7 @@ def self.name
relationships: {
salary: {
data: {
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "salaries",
method: "create"
}
@@ -613,7 +613,7 @@ def self.name
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "salaries",
attributes: {
base_rate: 15.00,
@@ -790,13 +790,13 @@ def self.name
relationships: {
positions: {
data: [
- {type: "positions", 'temp-id': "pos1", method: "create"},
- {type: "positions", 'temp-id': "pos2", method: "create"}
+ {type: "positions", "temp-id": "pos1", method: "create"},
+ {type: "positions", "temp-id": "pos2", method: "create"}
]
},
teams: {
data: [
- {type: "teams", 'temp-id': "team1", method: "create"}
+ {type: "teams", "temp-id": "team1", method: "create"}
]
}
}
@@ -804,27 +804,27 @@ def self.name
included: [
{
type: "positions",
- 'temp-id': "pos1",
+ "temp-id": "pos1",
attributes: {title: "specialist"},
relationships: {
department: {
- data: {type: "departments", 'temp-id': "dep1", method: "create"}
+ data: {type: "departments", "temp-id": "dep1", method: "create"}
}
}
},
{
type: "departments",
- 'temp-id': "dep1",
+ "temp-id": "dep1",
attributes: {name: "safety"}
},
{
type: "positions",
- 'temp-id': "pos2",
+ "temp-id": "pos2",
attributes: {title: "manager"}
},
{
type: "teams",
- 'temp-id': "team1",
+ "temp-id": "team1",
attributes: {name: "Team 1"}
}
]
@@ -1166,9 +1166,9 @@ def self.name
# NB - should only sideload updated position, not all positions
it "sideloads the objects in response" do
make_request
- expect(included("positions").length).to eq(1)
- expect(included("positions")[0].id).to eq(position2.id)
- expect(included("departments").length).to eq(1)
+ expect(jsonapi_included("positions").length).to eq(1)
+ expect(jsonapi_included("positions")[0].id).to eq(position2.id)
+ expect(jsonapi_included("departments").length).to eq(1)
end
end
@@ -1268,26 +1268,26 @@ def self.name
relationships: {
positions: {
data: [
- {'temp-id': "a", type: "positions", method: "create"}
+ {"temp-id": "a", type: "positions", method: "create"}
]
}
}
},
included: [
{
- 'temp-id': "a",
+ "temp-id": "a",
type: "positions",
attributes: {},
relationships: {
department: {
data: {
- 'temp-id': "b", type: "departments", method: "create"
+ "temp-id": "b", type: "departments", method: "create"
}
}
}
},
{
- 'temp-id': "b",
+ "temp-id": "b",
type: "departments",
attributes: {}
}
@@ -1385,7 +1385,7 @@ def self.name
relationships: {
teams: {
data: [
- {'temp-id': "abc123", type: "teams", method: "create"},
+ {"temp-id": "abc123", type: "teams", method: "create"},
{id: prior_team.id.to_s, type: "teams", method: "update"},
{id: disassociate_team.id.to_s, type: "teams", method: "disassociate"},
{id: destroy_team.id.to_s, type: "teams", method: "destroy"},
@@ -1396,7 +1396,7 @@ def self.name
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "teams",
attributes: {name: "Team #1"}
},
@@ -1447,20 +1447,20 @@ def self.name
relationships: {
tasks: {
data: [
- {'temp-id': "abc123", type: "features", method: "create"},
- {'temp-id': "abc456", type: "bugs", method: "create"}
+ {"temp-id": "abc123", type: "features", method: "create"},
+ {"temp-id": "abc456", type: "bugs", method: "create"}
]
}
}
},
included: [
{
- 'temp-id': "abc123",
+ "temp-id": "abc123",
type: "features",
attributes: {name: "test feature"}
},
{
- 'temp-id': "abc456",
+ "temp-id": "abc456",
type: "bugs",
attributes: {name: "test bug"}
}
@@ -1507,7 +1507,7 @@ def self.name
context "when creating" do
let(:location_id) { "abc123" }
- let(:location_id_key) { :'temp-id' }
+ let(:location_id_key) { :"temp-id" }
let(:method) { "create" }
it "works" do
@@ -1595,7 +1595,7 @@ def self.name
context "when creating" do
let(:note_id) { "abc123" }
- let(:note_id_key) { :'temp-id' }
+ let(:note_id_key) { :"temp-id" }
let(:method) { "create" }
it "works" do
@@ -1668,7 +1668,7 @@ def self.name
relationships: {
workspace: {
data: {
- 'temp-id': "work1", type: workspace_type, method: "create"
+ "temp-id": "work1", type: workspace_type, method: "create"
}
}
}
@@ -1676,7 +1676,7 @@ def self.name
included: [
{
type: workspace_type,
- 'temp-id': "work1",
+ "temp-id": "work1",
attributes: {
address: "Fake Workspace Address"
}
@@ -1748,4 +1748,78 @@ def self.name
end
end
end
+
+ # Applying the payload without saving lets a controller inspect ActiveRecord's
+ # dirty tracking to decide whether the update is permitted at all.
+ # type: :controller only to opt into the DatabaseCleaner hooks in spec_helper
+ RSpec.describe "applying a payload without saving", type: :controller do
+ let!(:employee) { Employee.create!(first_name: "Joe", last_name: "Smith") }
+
+ let(:payload) do
+ {
+ id: employee.id,
+ data: {
+ id: employee.id.to_s,
+ type: "employees",
+ attributes: {first_name: "Jane"}
+ }
+ }
+ end
+
+ around do |e|
+ Graphiti.with_context({}, :update) { e.run }
+ end
+
+ it "leaves the record clean before the payload is applied" do
+ proxy = EmployeeResource.find(payload)
+
+ expect(proxy.data.changed).to eq([])
+ expect(proxy.data.first_name).to eq("Joe")
+ end
+
+ it "reports the changed attributes once the payload is applied" do
+ proxy = EmployeeResource.find(payload)
+ proxy.assign_attributes(payload)
+
+ expect(proxy.data.changed).to eq(["first_name"])
+ expect(proxy.data.first_name_was).to eq("Joe")
+ expect(proxy.data).to be_changed
+ end
+
+ it "does not touch the database" do
+ proxy = EmployeeResource.find(payload)
+ proxy.assign_attributes(payload)
+
+ expect(employee.reload.first_name).to eq("Joe")
+ end
+
+ it "reports the same changes when applied more than once" do
+ proxy = EmployeeResource.find(payload)
+ proxy.assign_attributes(payload)
+ proxy.assign_attributes(payload)
+
+ expect(proxy.data.changed).to eq(["first_name"])
+ end
+
+ it "commits the pending changes on update_attributes" do
+ proxy = EmployeeResource.find(payload)
+ proxy.assign_attributes(payload)
+
+ expect {
+ expect(proxy.update_attributes).to eq(true)
+ }.to change { employee.reload.first_name }.from("Joe").to("Jane")
+
+ expect(proxy.data.changed).to eq([])
+ end
+
+ it "surfaces validation errors without saving" do
+ payload[:data][:attributes][:first_name] = nil
+ proxy = EmployeeResource.find(payload)
+ proxy.assign_attributes(payload)
+
+ expect(proxy.data.valid?).to eq(false)
+ expect(proxy.data.errors.full_messages).to eq(["First name can't be blank"])
+ expect(employee.reload.first_name).to eq("Joe")
+ end
+ end
end
diff --git a/spec/integration/rails/persistence_with_finders_spec.rb b/spec/integration/rails/persistence_with_finders_spec.rb
index 9ad8d1c5..04035575 100644
--- a/spec/integration/rails/persistence_with_finders_spec.rb
+++ b/spec/integration/rails/persistence_with_finders_spec.rb
@@ -87,13 +87,13 @@
{
data: {
type: "employees",
- 'temp-id': "empl1",
+ "temp-id": "empl1",
attributes: {first_name: "Joe"},
relationships: {
positions: {
data: [
- {type: "positions", 'temp-id': "pos1", method: "create"},
- {type: "positions", 'temp-id': "pos2", method: "create"}
+ {type: "positions", "temp-id": "pos1", method: "create"},
+ {type: "positions", "temp-id": "pos2", method: "create"}
]
}
}
@@ -101,22 +101,22 @@
included: [
{
type: "positions",
- 'temp-id': "pos1",
+ "temp-id": "pos1",
attributes: {title: "specialist"},
relationships: {
department: {
- data: {type: "departments", 'temp-id': "dep1", method: "create"}
+ data: {type: "departments", "temp-id": "dep1", method: "create"}
}
}
},
{
type: "departments",
- 'temp-id': "dep1",
+ "temp-id": "dep1",
attributes: {name: "safety"}
},
{
type: "positions",
- 'temp-id': "pos2",
+ "temp-id": "pos2",
attributes: {title: "manager"}
}
]
@@ -174,7 +174,7 @@
it "allows sideloading nested records" do
make_request
- expect(included("departments")[0].attributes).to eq({
+ expect(jsonapi_included("departments")[0].attributes).to eq({
"id" => department.id.to_s,
"name" => department.name,
"jsonapi_type" => "departments"
@@ -238,7 +238,7 @@
it "includes requested sideloads and their requested fields" do
make_request
- expect(included("salaries").first.attributes).to eq({
+ expect(jsonapi_included("salaries").first.attributes).to eq({
"id" => salary.id.to_s,
"jsonapi_type" => "salaries",
"base_rate" => 80.0
@@ -321,10 +321,10 @@
it "sideloads the updated objects plus included objects" do
make_request
- expect(included("teams").length).to eq(2)
- expect(included("positions").length).to eq(2)
- expect(included("departments").length).to eq(1)
- expect(included("salaries").length).to eq(1)
+ expect(jsonapi_included("teams").length).to eq(2)
+ expect(jsonapi_included("positions").length).to eq(2)
+ expect(jsonapi_included("departments").length).to eq(1)
+ expect(jsonapi_included("salaries").length).to eq(1)
end
end
end
diff --git a/spec/integration/rails/railtie_spec.rb b/spec/integration/rails/railtie_spec.rb
new file mode 100644
index 00000000..6f9ba85e
--- /dev/null
+++ b/spec/integration/rails/railtie_spec.rb
@@ -0,0 +1,96 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ # Graphiti.config's own Rails defaults (schema_path, logger, debug) are covered
+ # in spec/configuration_spec.rb. This covers what the Railtie registers with
+ # Rails on boot.
+ RSpec.describe Graphiti::Rails::Railtie do
+ describe "the jsonapi mime type" do
+ it "is registered" do
+ expect(Mime[:jsonapi].to_s).to eq("application/vnd.api+json")
+ end
+ end
+
+ describe "the jsonapi parameter parser" do
+ subject(:parser) { ActionDispatch::Request.parameter_parsers[:jsonapi] }
+
+ it "is registered" do
+ expect(parser).to be_a(Proc)
+ end
+
+ it "parses a jsonapi body into params readable by string or symbol" do
+ parsed = parser.call({data: {type: "authors"}}.to_json)
+
+ expect(parsed[:data][:type]).to eq("authors")
+ expect(parsed["data"]["type"]).to eq("authors")
+ end
+ end
+
+ describe "renderers" do
+ it "registers jsonapi and jsonapi_errors" do
+ expect(ActionController::Renderers::RENDERERS)
+ .to include(:jsonapi, :jsonapi_errors)
+ end
+ end
+
+ describe "config.graphiti options" do
+ it "defaults handled_exception_formats to jsonapi" do
+ expect(Graphiti::Rails.handled_exception_formats).to eq([:jsonapi])
+ end
+
+ it "defaults respond_to_formats to json, jsonapi and xml" do
+ expect(Graphiti::Rails.respond_to_formats)
+ .to eq([:json, :jsonapi, :xml])
+ end
+ end
+
+ describe "endpoint lookup" do
+ it "is configured" do
+ expect(Graphiti.config.context_for_endpoint).to be_a(Proc)
+ end
+ end
+
+ describe "rescue_responses" do
+ let(:controller) do
+ Class.new(ActionController::Base) { include Graphiti::Rails::Controller }
+ end
+
+ it "tells Rails which Graphiti errors are client errors" do
+ expect(ActionDispatch::ExceptionWrapper.rescue_responses)
+ .to include(Graphiti::Rails::CLIENT_ERROR_STATUSES)
+ end
+
+ it "keeps them out of the error reporter" do
+ wrapper = ActionDispatch::ExceptionWrapper
+ .new(nil, Graphiti::Errors::RecordNotFound.new)
+
+ expect(wrapper.rescue_response?).to eq(true)
+ end
+
+ it "gives each the status the controller renders" do
+ Graphiti::Rails::CLIENT_ERROR_STATUSES.each do |name, status|
+ registered = controller.rescue_registry
+ .status_code_for_exception(name.constantize, passthrough: false)
+
+ expect(registered).to eq(Rack::Utils.status_code(status)), name
+ end
+ end
+
+ it "covers every client error the controller registers" do
+ handlers = controller.rescue_registry.instance_variable_get(:@handlers)
+ client_errors = handlers.select { |_, (_, options)|
+ (400..499).cover?(options[:status])
+ }
+
+ expect(client_errors.keys.map(&:name))
+ .to match_array(Graphiti::Rails::CLIENT_ERROR_STATUSES.keys)
+ end
+ end
+
+ describe "rake tasks" do
+ it "ships the graphiti tasks the Railtie loads" do
+ task_path = File.expand_path("../../../lib/tasks/graphiti.rake", __dir__)
+
+ expect(File.exist?(task_path)).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/register_exception_reach_spec.rb b/spec/integration/rails/register_exception_reach_spec.rb
new file mode 100644
index 00000000..18eff967
--- /dev/null
+++ b/spec/integration/rails/register_exception_reach_spec.rb
@@ -0,0 +1,51 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ # register_exception comes from rescue_registry, which adds it to every
+ # controller, and rendering happens in its middleware. Neither needs
+ # Graphiti::Rails::Controller. What that adds is Graphiti's own
+ # registrations and the fallback for exceptions nobody registered.
+ RSpec.describe "register_exception without Graphiti::Rails::Controller", type: :request do
+ include Graphiti::Rails::TestHelpers
+
+ class BareForbidden < StandardError; end
+
+ class BareErrorsController < ActionController::Base
+ register_exception BareForbidden, status: 403
+
+ def registered
+ raise BareForbidden
+ end
+
+ def unregistered
+ raise "kaboom"
+ end
+ end
+
+ before do
+ Rails.application.routes.draw do
+ get "/bare/registered" => "bare_errors#registered"
+ get "/bare/unregistered" => "bare_errors#unregistered"
+ end
+ end
+
+ def get_jsonapi(path)
+ handle_request_exceptions do
+ get path, headers: {"HTTP_ACCEPT" => "application/vnd.api+json"}
+ end
+ end
+
+ it "registers and renders an exception the controller declared" do
+ get_jsonapi "/bare/registered"
+
+ expect(response.status).to eq(403)
+ expect(response.content_type).to start_with("application/vnd.api+json")
+ expect(JSON.parse(response.body)["errors"][0]["code"]).to eq("forbidden")
+ end
+
+ it "leaves unregistered exceptions to Rails, having no Graphiti fallback" do
+ get_jsonapi "/bare/unregistered"
+
+ expect(response.status).to eq(500)
+ expect(response.content_type).to_not include("vnd.api+json")
+ end
+ end
+end
diff --git a/spec/integration/rails/resource_generator_spec.rb b/spec/integration/rails/resource_generator_spec.rb
new file mode 100644
index 00000000..022313e1
--- /dev/null
+++ b/spec/integration/rails/resource_generator_spec.rb
@@ -0,0 +1,182 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rails/generators"
+ require "generators/graphiti/resource_generator"
+ require "fileutils"
+ require "tmpdir"
+
+ # graphiti-rails checked its generators' output into its own suite as
+ # spec/generated/** and ran that against a dummy app. Those specs re-tested
+ # behavior this suite already covers at length. What they really stood in for
+ # is that the templates still produce sane code. This runs the generator
+ # rather than shipping its output.
+ RSpec.describe Graphiti::ResourceGenerator do
+ let(:destination) { Dir.mktmpdir("graphiti-generator") }
+
+ def generated(path)
+ File.read(File.join(destination, path))
+ end
+
+ def generate!(*arguments)
+ Dir.chdir(destination) do
+ described_class.start(
+ arguments,
+ destination_root: destination, shell: Thor::Shell::Basic.new
+ )
+ end
+ end
+
+ before do
+ stub_const("Post", Class.new)
+ # The generator requires what it writes, relative to Rails.root, so each
+ # run defines PostResource for real. Hidden per example so the next run
+ # does not hit a superclass mismatch against the previous definition.
+ allow(::Rails).to receive(:root).and_return(Pathname.new(destination))
+ hide_const("PostResource")
+
+ FileUtils.mkdir_p(File.join(destination, "config"))
+ # The shape apps installed before 2.0 still have. Resource routes are
+ # injected after the scope line, matched on its format default.
+ File.write(File.join(destination, "config/routes.rb"), <<~RUBY)
+ Rails.application.routes.draw do
+ scope path: ApplicationResource.endpoint_namespace, defaults: { format: :jsonapi } do
+ end
+ end
+ RUBY
+ # Present so the generator does not prompt for an API namespace on stdin.
+ File.write(File.join(destination, ".graphiticfg.yml"), {"namespace" => "/api/v1"}.to_yaml)
+ end
+
+ after { FileUtils.remove_entry(destination) }
+
+ context "when the app already has an ApplicationResource" do
+ before do
+ stub_const("ApplicationResource", Class.new(Graphiti::Resource) {
+ self.abstract_class = true
+ self.endpoint_namespace = "/api/v1"
+ })
+
+ generate!("Post", "title:string", "upvotes:integer")
+ end
+
+ it "generates a resource with the requested attributes and types" do
+ resource = generated("app/resources/post_resource.rb")
+
+ expect(resource).to include("class PostResource < ApplicationResource")
+ expect(resource).to include("attribute :title, :string")
+ expect(resource).to include("attribute :upvotes, :integer")
+ end
+
+ it "generates a controller wired to the resource" do
+ controller = generated("app/controllers/posts_controller.rb")
+
+ expect(controller).to include("class PostsController < ApplicationController")
+ expect(controller).to include("PostResource")
+ end
+
+ it "routes the resource under the configured namespace" do
+ expect(generated("config/routes.rb")).to include("resources :posts")
+ end
+
+ it "routes into the scope the current install generator writes" do
+ File.write(File.join(destination, "config/routes.rb"), <<~RUBY)
+ Rails.application.routes.draw do
+ scope path: "/api/v1", defaults: {format: :jsonapi} do
+ end
+ end
+ RUBY
+
+ generate!("Post", "title:string")
+
+ expect(generated("config/routes.rb")).to include("resources :posts")
+ end
+
+ it "generates resource specs" do
+ expect(generated("spec/resources/post/reads_spec.rb"))
+ .to include("RSpec.describe PostResource, type: :resource")
+ expect(File).to exist(File.join(destination, "spec/resources/post/writes_spec.rb"))
+ end
+
+ it "generates request specs under the namespace" do
+ %w[index show create update destroy].each do |action|
+ expect(File).to exist(File.join(destination, "spec/api/v1/posts/#{action}_spec.rb"))
+ end
+ end
+
+ it "does not generate a second ApplicationResource" do
+ expect(File).to_not exist(File.join(destination, "app/resources/application_resource.rb"))
+ end
+ end
+
+ context "when --controller is given" do
+ before do
+ stub_const("ApplicationResource", Class.new(Graphiti::Resource) {
+ self.abstract_class = true
+ self.endpoint_namespace = "/api/v1"
+ })
+ end
+
+ it "generates the controller under that name" do
+ generate!("Post", "title:string", "--controller", "Api::V1::Posts")
+
+ controller = generated("app/controllers/api/v1/posts_controller.rb")
+ expect(controller).to include("class Api::V1::PostsController < ApplicationController")
+ expect(controller).to include("PostResource")
+ end
+
+ it "points the route at it, since the name no longer matches the resource" do
+ generate!("Post", "title:string", "--controller", "Api::V1::Posts")
+
+ expect(generated("config/routes.rb"))
+ .to include(%(resources :posts, controller: "api/v1/posts"))
+ end
+
+ it "accepts the name with or without the Controller suffix" do
+ generate!("Post", "title:string", "--controller", "Api::V1::PostsController")
+
+ expect(generated("app/controllers/api/v1/posts_controller.rb"))
+ .to include("class Api::V1::PostsController")
+ end
+
+ it "singularizes to the same controller a plural name would give" do
+ generate!("Post", "title:string", "--controller", "Api::V1::Post")
+
+ expect(generated("app/controllers/api/v1/posts_controller.rb"))
+ .to include("class Api::V1::PostsController")
+ end
+
+ it "leaves the resource itself unnamespaced" do
+ generate!("Post", "title:string", "--controller", "Api::V1::Posts")
+
+ expect(generated("app/resources/post_resource.rb"))
+ .to include("class PostResource < ApplicationResource")
+ end
+ end
+
+ context "when the app has no ApplicationResource yet" do
+ before do
+ hide_const("ApplicationResource")
+
+ generate!("Post", "title:string", "--actions", "index", "show")
+ end
+
+ it "generates one to inherit from" do
+ expect(generated("app/resources/application_resource.rb"))
+ .to include("class ApplicationResource < Graphiti::Resource")
+ end
+
+ it "documents the inherited settings with their defaults and options" do
+ 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")
+ end
+
+ it "only generates request specs for the requested actions" do
+ expect(File).to exist(File.join(destination, "spec/api/v1/posts/index_spec.rb"))
+ expect(File).to exist(File.join(destination, "spec/api/v1/posts/show_spec.rb"))
+ expect(File).to_not exist(File.join(destination, "spec/api/v1/posts/destroy_spec.rb"))
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/responders_spec.rb b/spec/integration/rails/responders_spec.rb
new file mode 100644
index 00000000..d8b40378
--- /dev/null
+++ b/spec/integration/rails/responders_spec.rb
@@ -0,0 +1,81 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ # Rails extracted respond_with into the responders gem. An app gets it from
+ # Bundler.require. The bare app this suite boots has to ask for it.
+ require "responders"
+
+ RSpec.describe Graphiti::Rails::Responders, type: :controller do
+ controller(ApplicationController) do
+ include Graphiti::Rails::Responders
+
+ def index
+ respond_with(Legacy::AuthorResource.all(params))
+ end
+ end
+
+ let!(:author) { Legacy::Author.create!(first_name: "Stephen") }
+
+ # The resource validates that the request path is one of its endpoints, and
+ # an anonymous controller has no route matching it.
+ before do
+ allow(controller.request.env).to receive(:[])
+ .with(anything).and_call_original
+ allow(controller.request.env).to receive(:[])
+ .with("PATH_INFO") { "/legacy/authors" }
+ end
+
+ it "responds to the configured formats" do
+ expect(controller.class.mimes_for_respond_to.keys)
+ .to match_array(Graphiti::Rails.respond_to_formats)
+ end
+
+ it "renders jsonapi through respond_with" do
+ request.accept = "application/vnd.api+json"
+
+ get :index
+
+ expect(response.status).to eq(200)
+ expect(JSON.parse(response.body)["data"].map { |d| d["id"] })
+ .to eq([author.id.to_s])
+ end
+
+ describe "the respond_with location override" do
+ # This is the only behaviour the module adds beyond declaring formats.
+ # Rails' responder builds a Location header for a created resource by
+ # calling polymorphic_url on it, which expects an ActiveModel. A
+ # Graphiti::ResourceProxy is not one.
+ controller(ApplicationController) do
+ include Graphiti::Rails::Responders
+
+ def create
+ respond_with(Legacy::AuthorResource.build(params).tap(&:save))
+ end
+ end
+
+ let(:payload) do
+ {data: {type: "authors", attributes: {first_name: "Stephen"}}}
+ end
+
+ it "creates without trying to generate a url for the proxy" do
+ request.accept = "application/vnd.api+json"
+
+ post :create, params: payload
+
+ expect(response.status).to eq(201)
+ expect(response.headers["Location"]).to be_nil
+ end
+
+ it "is what stops respond_with raising on the proxy" do
+ plain = Class.new(ApplicationController) do
+ include ActionController::MimeResponds
+
+ respond_to :jsonapi
+ end
+
+ expect(plain.instance_method(:respond_with).owner)
+ .to_not eq(Graphiti::Rails::Responders)
+ expect(described_class.instance_method(:respond_with).owner)
+ .to eq(described_class)
+ end
+ end
+ end
+end
diff --git a/spec/integration/rails/schema_tasks_spec.rb b/spec/integration/rails/schema_tasks_spec.rb
new file mode 100644
index 00000000..c1595ccc
--- /dev/null
+++ b/spec/integration/rails/schema_tasks_spec.rb
@@ -0,0 +1,51 @@
+if ENV["APPRAISAL_INITIALIZED"]
+ require "rake"
+ require "tmpdir"
+
+ RSpec.describe "the graphiti:schema rake tasks" do
+ let(:path) { File.join(@dir, "nested", "schema.json") }
+
+ # The suite defines resources the generator cannot resolve.
+ before do
+ allow(Graphiti::Schema).to receive(:generate)
+ .and_return({resources: [], endpoints: {}, types: {}})
+ end
+
+ around do |example|
+ original = Rake.application
+ Rake.application = Rake::Application.new
+ Rake::Task.define_task(:environment)
+ load File.expand_path("../../../lib/tasks/graphiti.rake", __dir__)
+
+ Dir.mktmpdir do |dir|
+ @dir = dir
+ example.run
+ end
+ ensure
+ Rake.application = original
+ end
+
+ def run(task)
+ Rake::Task[task].invoke(path)
+ end
+
+ it "generates the file at the given path" do
+ expect { run("graphiti:schema:generate") }.to output(/Schema written/).to_stdout
+
+ expect(JSON.parse(File.read(path))).to include("resources")
+ end
+
+ it "checks a file it just generated" do
+ expect { run("graphiti:schema:generate") }.to output.to_stdout
+ Rake::Task["graphiti:schema:generate"].reenable
+
+ expect { run("graphiti:schema:check") }.to output(/up to date/).to_stdout
+ end
+
+ it "fails the check when the file is missing" do
+ expect { run("graphiti:schema:check") }
+ .to raise_error(SystemExit)
+ .and output(/Schema file not found/).to_stderr
+ end
+ end
+end
diff --git a/spec/integration/rails/sunspot_pattern_spec.rb b/spec/integration/rails/sunspot_pattern_spec.rb
index 262fb2c7..c029b9ab 100644
--- a/spec/integration/rails/sunspot_pattern_spec.rb
+++ b/spec/integration/rails/sunspot_pattern_spec.rb
@@ -9,7 +9,7 @@
#
# This scenario is likely specific to ActiveRecord.
RSpec.describe "a non-ActiveRecord adapter that returns ActiveRecord models", type: :controller do
- include GraphitiSpecHelpers
+ include Graphiti::SpecHelpers
controller(ApplicationController) do
def index
@@ -35,13 +35,13 @@ def index
it "works" do
do_index({include: "special_books"})
- expect(d[0].sideload(:special_books).map(&:id)).to eq([book.id])
+ expect(jsonapi_data[0].sideload(:special_books).map(&:id)).to eq([book.id])
end
context "belongs_to" do
it "works" do
do_index({include: "special_state"})
- expect(d[0].sideload(:special_state).id).to eq(state.id)
+ expect(jsonapi_data[0].sideload(:special_state).id).to eq(state.id)
end
end
end
diff --git a/spec/integration/rails/transaction_hooks_spec.rb b/spec/integration/rails/transaction_hooks_spec.rb
index d76aa098..abb6e03f 100644
--- a/spec/integration/rails/transaction_hooks_spec.rb
+++ b/spec/integration/rails/transaction_hooks_spec.rb
@@ -274,7 +274,7 @@ def json
relationships: {
positions: {
data: [
- {'temp-id': "a", type: "positions", method: "create"}
+ {"temp-id": "a", type: "positions", method: "create"}
]
}
}
@@ -282,18 +282,18 @@ def json
included: [
{
type: "positions",
- 'temp-id': "a",
+ "temp-id": "a",
relationships: {
department: {
data: {
- 'temp-id': "b", type: "departments", method: "create"
+ "temp-id": "b", type: "departments", method: "create"
}
}
}
},
{
type: "departments",
- 'temp-id': "b"
+ "temp-id": "b"
}
]
}
@@ -351,7 +351,7 @@ class << self
attr_accessor :meta
end
self.model = ::Employee
- self.validate_endpoints = false
+ self.validate_requests = false
attribute :first_name, :string
@@ -383,7 +383,7 @@ class << self
relationships: {
positions: {
data: {
- type: "positions", 'temp-id': "abc123", method: "create"
+ type: "positions", "temp-id": "abc123", method: "create"
}
}
}
@@ -391,7 +391,7 @@ class << self
included: [
{
type: "positions",
- 'temp-id': "abc123",
+ "temp-id": "abc123",
attributes: {title: "foo"}
}
]
diff --git a/spec/no_rails_spec.rb b/spec/no_rails_spec.rb
new file mode 100644
index 00000000..8cde55cb
--- /dev/null
+++ b/spec/no_rails_spec.rb
@@ -0,0 +1,90 @@
+require "bundler"
+
+RSpec.describe "graphiti without Rails" do
+ describe "the gemspec" do
+ let(:gemspec) do
+ Gem::Specification.load(File.expand_path("../graphiti.gemspec", __dir__))
+ end
+
+ # The Rails integration ships inside this gem as of 2.0. It has to stay
+ # opt-in at load time rather than becoming a dependency, or every non-Rails
+ # user installs Rails to use Graphiti.
+ it "does not depend on Rails" do
+ runtime = gemspec.dependencies
+ .select { |dependency| dependency.type == :runtime }
+ .map(&:name)
+
+ expect(runtime).to_not include("rails", "railties", "actionpack", "activerecord")
+ end
+ end
+
+ # Only meaningful in the run that has no Rails in the bundle at all: the
+ # plain Gemfile rows of the CI matrix.
+ if !defined?(::Rails)
+ describe "loading" do
+ it "does not define the Rails integration" do
+ expect(defined?(Graphiti::Rails)).to be_nil
+ end
+
+ it "ships the Rails integration without loading it" do
+ expect(File).to exist(File.expand_path("../lib/graphiti/rails.rb", __dir__))
+ end
+
+ it "does not pull ActionController in" do
+ expect(defined?(::ActionController::Base)).to be_nil
+ end
+ end
+
+ describe "a process that requires nothing but graphiti" do
+ # This suite loads active_model, active_record and more, any of which
+ # pulls in enough of ActiveSupport to hide a missing require in
+ # lib/graphiti.rb. A bare subprocess is the only way to see what an app
+ # that requires graphiti and nothing else actually gets.
+ it "renders" do
+ script = <<~RUBY
+ require "graphiti"
+
+ class Post < Graphiti::Resource
+ self.adapter = Graphiti::Adapters::Null
+ self.model = Struct.new(:id, :title, keyword_init: true)
+ self.validate_requests = false
+ attribute :title, :string
+ def base_scope = {}
+ def resolve(_scope) = [model.new(id: 1, title: "Bare")]
+ end
+
+ print Graphiti.with_context(Object.new, :index) { Post.all({}).to_jsonapi }
+ RUBY
+
+ output = nil
+ gem_path = [Bundler.bundle_path.to_s, *Gem.path].uniq.join(File::PATH_SEPARATOR)
+ # Without this the child inherits RUBYOPT=-rbundler/setup and loads this
+ # suite's bundle, which is the opposite of what the test is checking.
+ Bundler.with_unbundled_env do
+ IO.popen([{"GEM_PATH" => gem_path}, "ruby", "-I", File.expand_path("../lib", __dir__), "-e", script], err: [:child, :out]) do |io|
+ output = io.read
+ end
+ end
+
+ expect($?).to be_success, "subprocess failed:\n#{output}"
+ expect(JSON.parse(output)["data"][0]["attributes"]["title"]).to eq("Bare")
+ end
+ end
+
+ describe "the library itself" do
+ it "resolves resources and renders without Rails" do
+ resource = Class.new(PORO::EmployeeResource) do
+ def self.name
+ "PORO::EmployeeResource"
+ end
+ end
+ PORO::Employee.create(first_name: "Jane")
+
+ rendered = JSON.parse(resource.all({}).to_jsonapi)
+
+ expect(rendered["data"].map { |d| d["attributes"]["first_name"] })
+ .to eq(["Jane"])
+ end
+ end
+ end
+end
diff --git a/spec/pagination_spec.rb b/spec/pagination_spec.rb
index 915a95de..6ef9f12f 100644
--- a/spec/pagination_spec.rb
+++ b/spec/pagination_spec.rb
@@ -16,7 +16,7 @@
it "applies default pagination" do
resource.class_eval do
- self.default_page_size = 2
+ self.page_default_size = 2
end
expect(ids.length).to eq(2)
end
@@ -24,7 +24,7 @@
context "when pagination disabled" do
before do
resource.class_eval do
- self.default_page_size = 2
+ self.page_default_size = 2
end
end
diff --git a/spec/performance/chart_page.rb b/spec/performance/chart_page.rb
new file mode 100644
index 00000000..6da99501
--- /dev/null
+++ b/spec/performance/chart_page.rb
@@ -0,0 +1,683 @@
+require "json"
+require "fileutils"
+require_relative "scenarios"
+# Guarded because measure_releases may already be running as the main script.
+require_relative "measure_releases" unless defined?(MeasureReleases)
+
+# Renders performance_history.tsv to tmp/performance.html and opens it.
+module ChartPage
+ ROOT = File.expand_path("../..", __dir__)
+ HISTORY = File.expand_path("performance_history.tsv", __dir__)
+ OUTPUT = File.join(ROOT, "tmp", "performance.html")
+ WORKING_TREE = "working tree"
+ CHART_LIBRARY = File.expand_path("vendor/chart.umd.js", __dir__)
+
+ module_function
+
+ # measured skips the probe when the caller already has the numbers.
+ def run(open: true, current: true, measured: nil)
+ abort "no #{HISTORY}, run rake performance:record_all" unless File.exist?(HISTORY)
+
+ rows = measurements
+ rows += measured || working_tree if current
+ FileUtils.mkdir_p(File.dirname(OUTPUT))
+ File.write(OUTPUT, page(rows))
+ launch if open
+ OUTPUT
+ end
+
+ def as_rows(measured)
+ measured.map do |_, ruby, mode, scenario, phase, allocations, milliseconds|
+ row(WORKING_TREE, ruby, mode, scenario, phase, allocations, milliseconds, MeasureReleases.machine)
+ end
+ end
+
+ def row(version, ruby, concurrency, scenario, phase, allocations, milliseconds, machine)
+ {
+ version: version, ruby: ruby, concurrency: concurrency,
+ scenario: scenario, phase: phase, count: allocations.to_i,
+ ms: milliseconds.to_s.empty? ? nil : milliseconds.to_f,
+ machine: machine.to_s
+ }
+ end
+
+ # Plotted as one more release so the branch you are on sits beside the history.
+ def working_tree
+ MeasureReleases::Progress.start(MeasureReleases::MODES.length * MeasureReleases.measurements_per_mode)
+ MeasureReleases::MODES.flat_map { |mode|
+ measured = MeasureReleases.measure_in(MeasureReleases::ROOT, mode) || []
+ measured.map { |scenario, phase, allocations, milliseconds|
+ row(WORKING_TREE, MeasureReleases::RUBY, mode, scenario, phase, allocations, milliseconds, MeasureReleases.machine)
+ }
+ }
+ ensure
+ MeasureReleases::Progress.finish
+ end
+
+ def measurements
+ File.readlines(HISTORY, chomp: true).drop(1).reject(&:empty?).map do |line|
+ cells = line.split("\t").first(8)
+ row(*cells.fill(nil, cells.length...8))
+ end
+ end
+
+ def launch
+ opener = if RUBY_PLATFORM.include?("darwin")
+ "open"
+ else
+ (system("which xdg-open > /dev/null 2>&1") ? "xdg-open" : nil)
+ end
+ opener ? system(opener, OUTPUT) : puts("open it yourself, no opener found")
+ end
+
+ def page(rows)
+ # Block form throughout: a replacement string would read \\1 and friends in
+ # minified javascript and json as backreferences.
+ TEMPLATE
+ .sub("__DATA__") { JSON.generate(rows) }
+ .sub("__NAMES__") { JSON.generate(Scenarios.descriptions) }
+ .sub("__WAITING__") { JSON.generate(Scenarios.waiting) }
+ .gsub("__NOISE__") { JSON.generate(NOISE_FLOOR) }
+ .sub("__CHARTJS__") { File.read(CHART_LIBRARY) }
+ end
+
+ # Six repeats of the probe, worst measurement in each group rounded up: 1.7% where nothing waits, 3.7% where something does.
+ NOISE_FLOOR = {quiet: 0.02, waiting: 0.04}.freeze
+
+ TEMPLATE = <<~HTML
+
+
+
+ graphiti performance
+
This project has gone through a number of iterations over the past three years. I’m happy to say we’ve now released 1.0, committing to semantic versioning. Graphiti has now seen enough use, across a wide variety of scenarios, that we can commit to no backwards-incompatible changes for a long while, and a changelog for future releases.
+
+
We’ll be writing more in this blog about our future roadmap. Stay tuned!
Our first minor version bump centers around an important update:
+better errors for clients. This is the first phase of work to allow us to provide more actionable errors to API users who might not also be developers of the API itself. This was brought to us by Graphiti maintainer Wade Tandy.
+
+
Previously, when a client sent a bad write request to the server -
+unwritable attributes/relationships, or bad types - we would render the
+same generic 500 error JSON. We now render helpful error messages, so
+third-party clients know how to correct their requests.
+
+
This is currently only affecting invalid writes, but the foundation laid
+will be extendable to reads as well.
I’m thrilled to announce much-needed, much-improved Rails integration,
+courtesy of the amazing Peter Wagenet and
+the new graphiti-rails gem.
+This gives us better error handling, tighter controllers, and a solid
+foundation for the codebase moving forward.
+
+
You’ll be especially happy about this release if you’ve ever tried to
+add error-handling middleware. Because prior versions of Graphiti used
+rescue_from in ApplicationController, we intercepted errors before
+other middleware had access to them. Now everything just works.
+
+
As part of this upgrade, we’re switching from GraphitiErrors to
+RescueRegistry. Think of
+RescueRegistry as a well-refactored version of GraphitiErrors, available to
+even non-Graphiti developers.
+
+
Though you don’t have to do anything to upgrade to Graphiti 1.2, this release will start throwing deprecation notices around ApplicationController. Don’t worry, the required changes are quite minor and spelled out in the graphiti-rails migration guide.
+
+
I’m quite happy with how this effort turned out. This release dramatically improves our integration with Rails internals, and I think everyone involved learned a lot. I’m honored to work with such talented developers in our open-source community ❤️
+
+
If you have any further questions, check out the #dev or #rails
+channels in our Discord Chat
Though we’ve long had a sample application with step-by-step diffs, we’ve been missing a full walkthrough. That now exists! Start with Step 0: Bootstrapping.
+
+
These write-ups will tell you the relevant code and commands, but
+they’ll also give helpful context around the decisions Graphiti makes.
+Even if you’re already a Graphiti user, I suggest checking it out!
Here you can keep up-to-date with Graphiti, including updates,
+deprecations, and our future roadmap. If there’s anything you’d like
+to read about, let us know on Discord
Though we’ve long had a sample application with step-by-step diffs, we’ve been missing a full walkthrough. That now exists! Start with Step 0: Bootstrapping.
I’m thrilled to announce much-needed, much-improved Rails integration,
+courtesy of the amazing Peter Wagenet and
+the new graphiti-rails gem.
+This gives us better error handling, tighter controllers, and a solid
+foundation for the codebase moving forward.
Our first minor version bump centers around an important update:
+better errors for clients. This is the first phase of work to allow us to provide more actionable errors to API users who might not also be developers of the API itself. This was brought to us by Graphiti maintainer Wade Tandy.
This project has gone through a number of iterations over the past three years. I’m happy to say we’ve now released 1.0, committing to semantic versioning. Graphiti has now seen enough use, across a wide variety of scenarios, that we can commit to no backwards-incompatible changes for a long while, and a changelog for future releases.
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 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:
+
+
# 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
+
+ beforedo
+ params[:include]='watchers'
+ end
+
+ it'sideloads employees via watcher_emails'do
+ render
+ sl=d[0].sideload(:watchers)
+ expect(sl.map(&:id)).toeq([employee1.id,employee3.id])
+ expect(sl.map(&:jsonapi_type).uniq).toeq(['employees'])
+ end
+ end
+end
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. Let’s
+take a look at the query parameters that would be used to connect these
+two Resources:
+
+
has_many:watchers,resource: EmployeeResourcedo
+ paramsdo|hash,departments|
+ binding.pry
+ end
+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:
+
+
undefined method `department_id' for #<Employee:0x00007f9652ae6d80>
+
+
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 Departments and an array of Employees. Now we need
+to specify which employees should be assigned as watchers of which
+department.
+
+
Let’s write that code manually:
+
+
has_many:watchers,resource: EmployeeResourcedo
+ # ... code ...
+ assigndo|departments,employees|
+ departments.eachdo|d|
+ d.watchers=employees.selectdo|e|
+ e.email.in?(d.watcher_emails)
+ end
+ end
+ end
+end
+
+
We’re selecting all relevant Employees 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:
Note that we’re using #select to return an array of relevant
+Employees. 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:
+
+
undefined method `watchers=' for #<Department:0x00007feb625a7468>
+
+
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:
Our sample application
+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 Departments. Here’s the
+spec:
+
+
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)}
+
+ beforedo
+ params[:include]='departments'
+ end
+
+ it'finds the departments for all positions'do
+ render
+ sl=d[0].sideload(:departments)
+ expect(sl.map(&:id)).toeq([department1.id,department2.id])
+ expect(sl.map(&:jsonapi_type).uniq).toeq(['departments'])
+ end
+ end
+end
+
+
Start by defining the association:
+
+
has_many:departments
+
+
And you’ll get this 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:
+
+
filter:employee_id,:integer,only: [:eq]do
+ eqdo|scope,value|
+ scope.joins(:positions).merge(Position.where(employee_id: value))
+ end
+end
+
+
In order to find Departments by an employee_id, we need to join the
+positions table which has the employee_id column.
+
+
We now get this error:
+
+
NoMethodError:
+ undefined method `employee_id' for #<Department:0x00007fa6f330f768>
+
+
Let’s say our URL is /employees?include=departments. We’ve fetched all
+the Employees and all the Departments, 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 Departments for a given Employee:
+
+
has_many:departmentsdo
+ assign_eachdo|employee,departments|
+ departments.selectdo|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:
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:
This ensures that not only are we joining on the positions table,
+we’ll eagler load the positionsrelationship 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:
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.
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.
classPost
+ # 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')
+ definitialize(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' }
+ defattributes
+ {}.tapdo|attrs|
+ ATTRS.eachdo|name|
+ attrs[name]=send(name)
+ end
+ end
+ end
+end
+
+
And this in-memory datastore:
+
+
# 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'}
+]
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.
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,
+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)
+}.
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.
# Instantiate a model for #create
+defbuild(model_class)
+ model_class.new
+end
+
+# Used for create/update
+defassign_attributes(model,attributes)
+ attributes.each_pairdo|k,v|
+ model.send(:"#{k}=",v)
+ end
+end
+
+# Used for create/update
+defsave(model)
+ attrs=model.attributes.dup
+ attrs[:id]||=DATA.length+1
+ ifexisting=DATA.find{|d|d[:id].to_s==attrs[:id].to_s}
+ existing.merge!(attrs)
+ else
+ DATA<<attrs
+ end
+ model
+end
+
+# Used for destroy
+defdelete(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.
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
+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:
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.
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:
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.
+
+
defdestroy(model)
+ Post::DATA.reject!{|d|d[:id].to_s==model.id.to_s}
+ model
+end
+
+defsave(model)
+ attrs=model.attributes.dup
+ attrs[:id]||=Post::DATA.length+1
+ ifexisting=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
+deftransaction(*)
+ yield
+end
+ Whether you want to fill this paragraph with some text like I'm doing right now, this place is perfect to describe some features or anything you want - React has a complete solution for you.
+
+
+ You have complete control over the look & feel of your website, we offer the best quality so you take your site up and running in no time.
+
+
+
+
+
+
+
+
+
+
+
+
You don't need to have any advanced technical
+
+ Whether you want to fill this paragraph with some text like I'm doing right now, this place is perfect to describe some features or anything you want - React has a complete solution for you.
+
+
+
+
+
+
You don't need to have any advanced technical
+
+ Whether you want to fill this paragraph with some text like I'm doing right now, this place is perfect to describe some features or anything you want - React has a complete solution for you.
+
+
+ You have complete control over the look & feel of your website, we offer the best quality so you take your site up and running in no time.
+
+
+
+
+
+
+
+
+
+
+
+
You don't need to have any advanced technical
+
+ Whether you want to fill this paragraph with some text like I'm doing right now, this place is perfect to describe some features or anything you want - React has a complete solution for you.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/static/1.13/feed.xml b/website/static/1.13/feed.xml
new file mode 100644
index 00000000..7806ea83
--- /dev/null
+++ b/website/static/1.13/feed.xml
@@ -0,0 +1,106 @@
+Jekyll2026-08-06T14:52:03-05:00/1.13/feed.xmlGraphitiStylish Graph APIs
+Tutorial Write-Ups Are Out!2019-10-14T00:00:00-05:002019-10-14T00:00:00-05:00/1.13/2019/10/14/tutorialThough we’ve long had a sample application with step-by-step diffs, we’ve been missing a full walkthrough. That now exists! Start with Step 0: Bootstrapping.
+
+
These write-ups will tell you the relevant code and commands, but
+they’ll also give helpful context around the decisions Graphiti makes.
+Even if you’re already a Graphiti user, I suggest checking it out!
+
+
+
+
+
+
+
+
+
+
+
+
]]>Lee RichmondGraphiti 1.2 Released2019-05-20T00:00:00-05:002019-05-20T00:00:00-05:00/1.13/2019/05/20/graphiti-1-2I’m thrilled to announce much-needed, much-improved Rails integration,
+courtesy of the amazing Peter Wagenet and
+the new graphiti-rails gem.
+This gives us better error handling, tighter controllers, and a solid
+foundation for the codebase moving forward.
+
+
You’ll be especially happy about this release if you’ve ever tried to
+add error-handling middleware. Because prior versions of Graphiti used
+rescue_from in ApplicationController, we intercepted errors before
+other middleware had access to them. Now everything just works.
+
+
As part of this upgrade, we’re switching from GraphitiErrors to
+RescueRegistry. Think of
+RescueRegistry as a well-refactored version of GraphitiErrors, available to
+even non-Graphiti developers.
+
+
Though you don’t have to do anything to upgrade to Graphiti 1.2, this release will start throwing deprecation notices around ApplicationController. Don’t worry, the required changes are quite minor and spelled out in the graphiti-rails migration guide.
+
+
I’m quite happy with how this effort turned out. This release dramatically improves our integration with Rails internals, and I think everyone involved learned a lot. I’m honored to work with such talented developers in our open-source community ❤️
+
+
If you have any further questions, check out the #dev or #rails
+channels in our Discord Chat
+
+
+
+
+
+
+
+
+
+
+
+
]]>Lee RichmondGraphiti 1.1 Released2019-05-08T00:00:00-05:002019-05-08T00:00:00-05:00/1.13/2019/05/08/graphiti-1-1Our first minor version bump centers around an important update:
+better errors for clients. This is the first phase of work to allow us to provide more actionable errors to API users who might not also be developers of the API itself. This was brought to us by Graphiti maintainer Wade Tandy.
+
+
Previously, when a client sent a bad write request to the server -
+unwritable attributes/relationships, or bad types - we would render the
+same generic 500 error JSON. We now render helpful error messages, so
+third-party clients know how to correct their requests.
+
+
This is currently only affecting invalid writes, but the foundation laid
+will be extendable to reads as well.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
]]>Lee RichmondGraphiti 1.0 Released 🎉2019-03-31T00:00:00-05:002019-03-31T00:00:00-05:00/1.13/2019/03/31/graphiti-1-0This project has gone through a number of iterations over the past three years. I’m happy to say we’ve now released 1.0, committing to semantic versioning. Graphiti has now seen enough use, across a wide variety of scenarios, that we can commit to no backwards-incompatible changes for a long while, and a changelog for future releases.
+
+
We’ll be writing more in this blog about our future roadmap. Stay tuned!
A Resource queries and persists to a Backend. It returns
+Models from the Backend response, which get serialized. In this way, it is
+an implementation of the Repository Pattern.
+
+
This is best illustrated in code. Let’s say we have a Backend class
+that accepts a hash of options to perform a query:
classEmployeeResource<ApplicationResource
+ # We'll be coding the logic manually
+ self.adapter=Graphiti::Adapters::Null
+
+ attribute:name,:string
+
+ # The blank scope we start with
+ defbase_scope
+ {conditions: {},sort: {}}
+ end
+
+ # Merge filters into the hash based on request params
+ filter:namedo
+ eqdo|scope,value|
+ scope[:conditions].merge!(value)
+ scope
+ end
+ end
+
+ # Set sort based on request params
+ sort:namedo|scope,direction|
+ scope[:sort]={name: direction}
+ scope
+ end
+
+ # 'scope' here is our hash
+ # We pass it to Backend.query, and return Models
+ defresolve(scope)
+ results=Backend.query(scope)
+ results.map{|r|Employee.new(r)}
+ end
+end
+
+
As you see above, a scope can be anything from an
+ActiveRecord::Relation to a plain Ruby Hash. We want to adjust
+something based on the request parameters and pass it to our backend.
+From the raw backend results, we can instantiate Models. Note that we
+always return the full scope at the end of each block.
+
+
Of course, most Backends have predictable and consistent interfaces. It
+would be a pain to manually write this code for every Resource. So
+instead we could build an Adapter to DRY this logic:
Active Record was described by Martin Fowler in his book Patterns of Enterprise Application Architecture. In Active Record, objects carry both persistent data and behavior which operates on that data. Active Record takes the opinion that ensuring data access logic as part of the object will educate users of that object on how to write to and read from the database.
+
+
+
In other words, ActiveRecord combines a Backend and Model.
+Opinions on this vary,
+but Graphiti supports either approach: we can separate data and business layers, or
+combine them. See the ActiveRecord doppelgänger of the above at our
+Resource cheatsheet.
The only hard requirement of a Model is that it responds to id. We use
+model.id to determine uniqueness when rendering a JSONAPI response.
+You will get incorrect results if model.id is not unique.
+
+
Models should also respond to any readable attributes. Remember that:
+
+
attribute:name,:string
+
+
Is the same as
+
+
# @object is your Model instance
+attribute:name,:stringdo
+ @object.name
+end
+
+
If your Model does not respond to #name, either pass a block to attribute or
+look into aliasing.
Because our default is ActiveRecord, it may be unclear what other Models
+look like. Graphiti itself has no opinion about your Model layer, but
+below are a few examples.
Graphiti comes with a debugger that shows the queries executed for a
+given request. Remember that Resources have a query interface independent of a request or response. And Resources connect similar to ActiveRecord’s includes:
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:
+
+
# config/initializers/graphiti.rb
+Graphiti.configuredo|c|
+ c.debug=false
+end
+
+# Or use environment variable
+# GRAPHITI_DEBUG=false
+
+
Use config.debug_models to get additional (but verbose) output:
+
+
+
+
+
+
# config/initializers/graphiti.rb
+Graphiti.configuredo|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:
There are some common debugging scenarios that are possible to do
+manually, but their frequency warrants common patterns. For these, we
+have rake tasks.
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:
+
+
$ bin/rake graphiti:request[/employees]
+
+
Will execute the request and spit out the JSON response. You may want to
+run with the Debugger enabled:
+
+
$ 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.
If you have an Authorization scheme implemented (for example authenticate_or_request_with_http_token in rails) you can supply the Authorization http header value with the AUTHORIZATION_HEADER environment variable:
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 (or equivalent):
+
+
# Introspect the scope without firing a query
+# Call 'super' to fire the query
+defresolve(scope)
+ binding.pry
+end
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:
+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.
One common use case for endpoints is customizing the Resource
+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:
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).
+
+
Let’s instead say that if we’re entering the graph at /employees, the
+furthest we can go is Department:
ETags 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?
+
+
It’s important to note that 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.
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:
+
+
defindex
+ ifstale?(EmployeeIngestion.last)
+ employees=EmployeeResource.all(params)
+ respond_with(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 to mitigate this issue.
Whenever we have an application error, we want to respond with a
+JSONAPI-compliant errors payload.
+This way clients have a predictable response detailing information about
+the 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:
+
+
classApplicationController<ActionController::API
+ register_exceptionNotAuthorized,status: 403
+ register_exceptionShipmentDelayed,
+ 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 dependency.
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.
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:
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:
The final option register_exception accepts is handler. Here you can inject your own error handling class that customize RescueRegistry::ExceptionHandler. For example:
+
+
classMyCustomHandler<Graphiti::Rails::ExceptionHandler
+ # self.exception accessible within all instance methods
+
+ defstatus_code
+ # ...customize...
+ end
+
+ deferror_code
+ # ...customize...
+ end
+
+ deftitle
+ # ...customize...
+ end
+
+ defdetail
+ # ...customize...
+ end
+
+ defmeta
+ # ...customize...
+ end
+end
+
+register_exceptionFooError,handler: MyCustomHandler
+
+
If you would like to use the same custom handler for all errors, override default_exception_handler:
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:
Let’s say we’re loading a Post and its “Top Comments”. On Day One, we
+might eager load the data like so:
+
+
/posts/123?include=top_comments
+
+
This fetches all the data in a single request. But after some UI
+testing, we decide to add a “show comments” button. This way our
+page can load more quickly - it only needs to load the Post
+initially, and loading Top Comments can be deferred. We
+want to lazy load the relationship.
+
+
How would we do this? We could bake this logic into our next request:
But this requires the client to have knowledge of what a “Top Comment”
+is. If this logic ever changed, we’d have to update every client - our
+desktop app, mobile apps, reports, etc… Not to mention, third parties who
+just want to display Top Comments are required to have this knowledge
+and update their implementations as well.
+
+
Maybe we could hide what “Top Comment” means with a special endpoint:
+
+
/top_comments?filter[post_id]=123
+
+
But now clients need to know to hit this special endpoint instead of
+the normal /comments endpoint. How would they know? What if
+top_comments had special caching rules and shouldn’t be used for
+this purpose?
+
+
The main problem here is there is no way to guarantee our lazy-loaded data will
+match our eager loaded data. Whether we fetch the Post and its Top
+Comments in a single request, or lazy-load that data in a separate
+request, the same data should always be returned.
+
+
+
+
+
+
Links solve this problem. When we fetch the Post, the top_comments
+relationship will contain a URL. Clients can simply follow that URL to
+lazy-load the same data. We can now change the definition of a Top
+Comment - 500 upvotes, factor in recency, apply downvotes - and no
+clients need to change. They simple continue to follow a Link.
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:
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:
+
+
primary_endpoint'special_posts',[:index,:show]
+
+
Or to alter only the path:
+
+
self.endpoint[:path]='special_posts'
+
+
Or to alter only the actions supported:
+
+
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:
Requesting big collections can result into slow responses sometimes. In order to avoid this, you could use pagination. It’ll break your response into smaller pieces that will make your server responds faster. Paginations links can be present in your response in the following ways:
classPostResource<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
+
+
To generate a Relationship Link manually:
+
+
has_many:commentsdo
+ linkdo|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
Too often it seems the thinking is, “🤔 We want separate services,
+we’ll figure out our API contract along the way”. Graphiti approaches
+from the opposite angle - figure out the API contract, and you get separate services as a side-effect 😃💡
+
+
+
+
+
+
Resources have a defined query contract, and connect together with Links. Put two and two together, and you’ll see a Resource doesn’t need to be local to a single application. We can have Remote Resources as well:
You might want separate applications that are independently deployable,
+or you might want to break apart that slow test suite. The draw of
+isolated services is clear. Though you should be aware of the tradeoffs
+when breaking apart a Majestic Monolith,
+Graphiti helps you lessen those tradeoffs.
+
+
The most common service problem I see is a breakdown in cross-service
+communication:
+
+
+
🚫 No consistent or flexible query interface
+
🚫 No consistent error handling
+
🚫 No clear patterns on when and why to separate services
+
🚫 No types or backwards-compatibility checks (unless GraphQL)
Graphiti was built from the ground up to address all these points. We
+have a defined query contract, errors payload, a schema with types and
+backwards-compatibility checks, and organize code into RESTful
+Resources. Not only can we facilitate cross-service communication,
+we can automate it.
+
+
+
Note: Remote Resources are for read operations only. The exception
+is associating to an existing belongs_to remote entity.
+
+
+
+
Note: We use Faraday to hit
+the remote API. You must add faraday to your Gemfile to enable
+remote resources.
Critically, those same lazy-loading parameters are used when
+eager-loading:
+
+
# under the hood
+posts=PostResource.all.data
+CommentResource.all(filter: {post_id: 123})
+
+
OK, and we also know Resources support any backend, and we can build an Adapter 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
This Resource works as normal. We can execute queries:
+
+
comments=CommentResource.all({
+ sort: '-id',
+ filter: {active: true}
+})
+
+# The model instances are OpenStructs
+comments.data# => [#<OpenStruct>, #<OpenStruct>, ...]
+
+# 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:
+
+
classPostResource<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:
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, Comments
+and Authors in a single request.
+
+
But maybe only CommentResource is remote, and Authors are local.
+We need only define the association locally:
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:
+
+
classPostResource<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.
If the remote API has an error, we want to re-raise that same error. But
+unless you’ve enabled 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:
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.
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":
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
+Comments:
The same way a Model is an abstraction around a database table, a
+Resource is an abstraction around an API endpoint. It holds logic for
+querying, persisting, and serializing data.
+
+
+
For a condensed view of the Resource interface, see the
+cheatsheet.
A Resource is composed of Attributes. Each Attribute has a
+name (e.g. first_name) that corresponds to a JSON key, and a
+Type (e.g. string) that corresponds to a JSON value.
The schema flag is not affected by only/except options.
+This option determines if the attribute is exported to the schema.json.
+
+
You might want to allow behavior only if a certain condition is met.
+Pass a symbol to guard this behavior via corresponding method, only allowing the
+behavior if the method returns true:
:writable guards mirror this: the guard method (or proc) can optionally accept the model being written and the name of the attribute. On an update, the model is the persisted record being modified; on a create, it is a new unsaved instance. The model is only looked up when a guard actually declares a parameter for it, so zero-argument guards behave exactly as before.
By default, attributes are enabled for all behavior. You may want to
+disable certain behavior globally, for example a read-only API. Use
+these properties to affect all subclasses:
As for resource defined guards, you can pass a symbol to guard the
+behavior globally. This can be used to globally delegate access control to a
+dedicated system.
Each Attribute has a Type. Each Type defines behavior for
+
+
+
Reading
+
Writing
+
Filtering
+
+
+
For each of these, we’ll first attempt to coerce the given value to
+the correct type. If that fails, we will raise an error.
+
+
The implementation for each of these actions lives in a Dry Type. Take the :integer_id type: here we want to render a string, but query with an integer (this is the default for all Resource id attributes):
All but the last 3 have Array doppelgängers: array_of_integers,
+array_of_dates, etc.
+
+
The integer_id type says, “render as a string, but query as an
+integer” and is the default for the id attribute. The uuid type says
+“this is a string, but query me case-sensitive by default”.
Graphiti provides two built enum types, string_enum and integer_enum. These behave
+in exactly the same way as the string and integer types, respectively, except that
+when declaring them as either an attribute or a filter you are required to
+pass the allow option, which is the list of acceptable values for the field:
See the section on filter options for more details on allow behavior
+
+
Note: Graphiti does not currently do any value checking on enum fields when
+writing an attribute, and it still expects that your model layer will validate any
+data coming in.
Resources must be able to dynamically compose a query that can be run
+against an arbitrary backend (SQL, NoSQL, service calls, etc). They do
+this through the concept of scoping.
+
+
The best way to understand scoping is to take a look at what happens “under the hood”. Here’s the simple Resource, where most of the logic is hiding in the Adapter:
Now let’s show the long-hand version. This is completely runnable code (we’re just overriding the default behavior with an explicit version of the same):
+
+
classPostResource<ApplicationResource
+ filter:titledo|scope,value|
+ eqdo|scope,value|
+ scope.where(title: value)
+ end
+ end
+
+ sort:titledo|scope,dir|
+ scope.order(title: dir)
+ end
+
+ paginatedo|scope,current_page,per_page|
+ scope.page(current_page).per(per_page)
+ end
+
+ defbase_scope
+ Post.all
+ end
+
+ defresolve(scope)
+ scope.to_a
+ end
+end
+
+
Let’s break this down the key elements:
+
+
defbase_scope
+ Post.all
+end
+
+
Graphiti builds queries just like ActiveRecord: start with a base scope (Post.all), and alter that scope based on the incoming request. #base_scope defines our starting point.
+
+
filter:titledo|scope,value|
+ eqdo|scope,value|
+ scope.where(title: value)
+ end
+end
+
+
When the title query parameter is present, we alter the scope.
+
+
defresolve(scope)
+ scope.to_a
+end
+
+
The #resolve method is in charge of actually executing the query
+and returning model instances.
+
+
In other words, this code is roughly equivalent to:
Note: SomeResource.find returns a ResourceProxy. To access the model/record proper you will want to make sure you call .data on the result of find as shown above.
Override the #base_scope method whenever you have logic that should
+apply to every query. For example, if we only ever wanted to return
+active Positions:
+
+
defbase_scope
+ Position.where(active: true)
+end
+
+
This can be overridden by passing a second argument to Resource.all:
If you’ve already defined a corresponding attribute, you’ll be
+overriding that default behavior (and there is no need to pass a type as
+the second argument):
filter:name,:stringdo
+ eqdo|scope,value|
+ scope.where(first_name: value)
+ end
+
+ # prefix do ... end
+ # suffix do ... end
+ # etc
+end
+
+
The built-in operators for ActiveRecord are:
+
+
+
eq (case-insensitive)
+
eql (case-sensitive)
+
prefix
+
suffix
+
match
+
gt (greater-than)
+
gte (greater-than-or-equal-to)
+
lt (less-than)
+
lte (less-than-or-equal-to)
+
+
+
+
Note that Graphiti expects filters to support multiple values by
+default, so value will be an array. Pass single: true if you do
+not support multiple values.
+
+
+
+
To pass multiple values in a query string, comma-delimit:
+/employees?filter[name]=Jane,John
+
+
+
If you’ve already defined a corresponding attribute, you’ll be
+overriding that default behavior (and there is no need to pass a type as
+the second argument):
By default, all filters accept multiple values, causing the yielded
+value to always be an array. Pass single: true to only allow a
+single value:
+
+
# Default behavior
+filter:name,:stringdo
+ eqdo|scope,value|
+ value# => ["Jane"]
+ end
+end
+
+# With single: true
+filter:name,:string,single: truedo
+ eqdo|scope,value|
+ value# => "Jane"
+ end
+end
+
+
Filters can be required:
+
+
# Via attribute
+attribute:customer_id,:integer,filterable: :required
+
+# Via filter
+filter:customer_id,:string,required: true
+
+
Filters can also depend on other filters, requiring all criteria to be
+present:
+
+
# We query customers by id AND type, not one or the other
+filter:customer_id,:integer,dependent: [:customer_type]
+filter:customer_type,:string,dependent: [:customer_id]
By default, Graphiti parses a comma-delimited string as an array. There
+are times you may not want this - for instance a “keyword search” field
+that could contain a comma.
+
+
Wrap values in {{curlies}} to avoid parsing:
+
+
# GET /employees?filter[keywords]={{some,value}}
+
+filter:keywords,:stringdo
+ eqdo|scope,value|
+ value# => "some,value"
+ end
+end
+
+
You can also define arrays explicitly instead of delimiting on comma:
+
+
# GET /employees?filter[keywords]=[some,value]
+
+filter:keywords,:stringdo
+ eqdo|scope,value|
+ value# => ["some", "value"]
+ end
+end
+
+
If a filter is marked single: true, we’ll avoid any array parsing and
+escape the value for you, filtering on the string as given.
+
+
By default a value that comes in as null is treated as a string "null".
+To coerce null to a Ruby nil mark the filter with allow_nil: true.
+This can be changed for all attributes by setting filters_accept_nil_by_default
Statistics are useful and common. Consider a datagrid listing posts - we might want a “Total Posts” count displayed above the grid without firing an additional request. Notably, that statistic should take into account filtering, but should not take into account pagination.
+
+
All resources have a total count statistic by default:
+
+
PostResource.all({
+ stats: {total: 'count'}
+})
+
+
/posts?stats[total]=count
+
+
Would cause the meta section of the response to be:
Sometimes you have a field that is not always needed, and perhaps
+computationally expensive. In this case, you only want the field
+returned when explicitly requested by the client. To do this:
+
+
extra_attribute:net_worth
+
+
This works just like attribute, except the field is read-only and will
+only be returned when requested. The query parameter signature matches
+fields: ?extra_fields[employees]=net_worth.
+
+
You may want to adjust your scope to eager load data when a given extra
+field is requested. To do this:
classPostResource<ApplicationResource
+ self.model=Post
+ self.type='posts'
+
+ # Only used if you care about Links
+ primary_endpoint'/posts',[:index,:show,:create,:update,:destroy]
+
+ # default nil
+ self.default_sort=[{title: :asc}]
+
+ # default 20
+ self.default_page_size=10
+end
+
+
Typically you’d inherit from ApplicationResource. Here are some common higher-level customization options that will affect subclasses:
+
+
classApplicationResource<Graphiti::Resource
+ # Must be set when no corresponding model/query
+ self.abstract_class=true
+
+ # Subclasses can override if needed
+ self.adapter=Graphiti::Adapters::ActiveRecord
+
+ # Default attribute flags:
+ # attribute :title, :string,
+ # readable: default,
+ # writable: default,
+ # sortable: default,
+ # filterable: default
+ 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=Rails.application.routes.default_url_options[:host]
+ # Used for link generation
+ # Suggest referencing this config/routes.rb:
+ # scope path: ApplicationResource.endpoint_namespace do
+ # resources :posts
+ # end
+ self.endpoint_namespace='/api/v1'
+
+ # Will raise an error if a resource is being accessed from a URL it is not allowlisted for
+ # Helpful for link validation
+ self.validate_endpoints=false
+
+ # Automatically generate JSONAPI links?
+ self.autolink=true
+end
Polymorphic Resources are similar to ActiveRecord STI: when a single query can return multiple Resource instances. We may query /tasks, but return bugs, features, epics, etc.
+
+
For example, given the ActiveRecord models:
+
+
classEmployee<ApplicationRecord
+ has_many:tasks
+end
+
+# tasks table has a 'type' column
+classTask<ApplicationRecord
+ belongs_to:employee
+end
+
+classBug<Task
+end
+
+# ONLY Feature has #points
+classFeature<Task
+ defpoints
+ 5
+ end
+end
+
+# ONLY Epic has the milestones relationship
+classEpic<Task
+ has_many:milestones
+end
+
+classMilestone<ApplicationRecord
+ belongs_to:epic
+end
+
+
We could define the following Polymorphic Resources:
If we hit a /tasks endpoint, we’d get back JSONAPI types of bugs, features and epics. Only features would render the points attribute, and only epics would render the milestones relationship.
+
+
A query to /tasks?include=milestones would correctly only query
+and render Milestones for Epics.
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.
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:
+
+
classSalaryHistoryResource<ApplicationResource
+ # Any resource declaring a relationship to SalaryHistoryResource with
+ # readable: :admin? will use this method, unless it defines its own.
+ defadmin?
+ 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.
Occasionally this logic will be non-standard or more complex. Use
+assign_each to customize, returning all relevant children for the
+given parent:
+
+
has_many:positionsdo
+ assign_eachdo|employee,positions|
+ positions.select{|p|p.belongs_to?(employee)}
+ end
+end
+
+
Or if all else fails, use #assign to control all the logic:
+
+
has_many:positionsdo
+ assigndo|employees,positions|
+ employees.eachdo|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_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.
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:
+
+
# 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.
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”:
+
+
# 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.
To customize the foreign key, you will need to specify a hash rather
+than a symbol. The hash key is the relationship name, so the above is
+equivalent to
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:
+
+
# The API relationship is "teams", ActiveRecord has "groups"
+many_to_many:teams,as: :groups
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.
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:
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.
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. The
+“expanded” view of the ActiveRecord implementation is below:
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:
+
+
classPostResource<ApplicationResource
+ before_attributesdo|attributes|
+ # Before attributes have been assigned to the model
+ end
+
+ after_attributesdo|model|
+ # After attributes have been assigned to the model
+ end
+
+ around_attributes:do_around_attributes
+
+ defdo_around_attributes(attributes)
+ # before
+ model_instance=yieldattributes
+ # after
+ end
+
+ before_savedo|model|
+ # After attributes assigned, but before persisting
+ end
+
+ after_savedo|model|
+ # After model has been saved
+ end
+
+ around_save:do_around_save
+
+ defdo_around_save(model)
+ # before
+ yieldmodel
+ # after
+ end
+
+ # This is an *override*
+ # During #create, build a blank model instance
+ # By default, we'd call adapter.build(model_class)
+ defbuild(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)
+ defassign_attributes(model_instance,attributes)
+ attributes.each_pairdo|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)
+ defsave(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)
+ defdelete(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
+
+ defdo_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.
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:
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.
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 (combination of id and type
+that uniquely identifies a Resource).
+
+
To accomodate this, send an ephemeral temp-id (any UUID):
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:
When a persistence operation is attempted but the corresponding Resource
+is invalid, the transaction will be rolled back and an errors payload will be returned
+with a 422 response code:
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:
By default when using Rails, Graphiti will turn on concurrency when ::Rails.application.config.cache_classes is true (the default for staging and production environments). This will cause sibling sideloads to load concurrently. If a Post is sideloading Comments and Author, we’ll load both of those at the same time.
NOTE: Since this kicks off a new Thread, thread locals will be dropped. So if your code refers to Thread.current[:foo] you should set and get that on Graphiti.context:
+
+
# BAD:
+Thread.current[:foo]="bar"
+Thread.current[:foo]# => will be nil when sideloading!
+
+ # GOOD:
+Graphiti.context[:foo]="bar"
+Graphiti.context[:foo]# => "bar", even when sideloading
Common resource overrides can be packaged into an Adapter for code
+re-use. The most common example is using a different client/datastore
+than ActiveRecord/RelationalDB.
Even if you’re not a fan of TDD, Graphiti integration tests are simply 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:
+
+
RSpec.describeEmployeeResource,type: :resourcedo
+ 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
+ beforedo
+ params[:filter]={title: 'bar'}
+ end
+
+ # THEN the query results will be correct
+ it'works'do
+ expect(records.map(&:id)).toeq([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.
+
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.
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.
Note: Factories are not required, but they are considered a best
+practice used by the Graphiti test generator. Read thoughtbot’s
+Why Factories? for more
+information.
+
+
+
We need to seed data into our test database. To do this, we use Factory
+Bot and Faker.
+
+
When you generate a model, a stub factory will be created. It is highly
+recommended you edit that factory with randomized data:
+
+
# BEFORE
+FactoryBot.definedo
+ factory:employeedo
+ first_name{'MyString'}
+ end
+end
+
+# AFTER
+FactoryBot.definedo
+ factory:employeedo
+ 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 5 made belongs_to required by default. This means that if Employee belongs_to :department, then create(:employee) will fail. To ensure a relationship is always seeded:
+
+
FactoryBot.definedo
+ factory:employeedo
+ department
+ # OR association :department, factory: :department
+ end
+end
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:
Many teams use 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,
+we offer an additional set of DSL helpers via the
+guard-rspec-graphiti plugin.
+For more details, check out the project README.
We want to test that our attributes render correctly. We’ll do this by
+seeding a record, firing a basic query, and comparing the JSON result to
+the seeded data.
+
+
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:
+
+
expect(data.first_name).toeq(employee.first_name)
+
+
+
Note: Our schema validation test will ensure no attributes get
+removed or change types.
describe'filtering'do
+ let!(:employee1){create(:employee)}
+ let!(:employee2){create(:employee)}
+
+ context'by id'do
+ beforedo
+ params[:filter]={id: {eq: employee2.id}}
+ end
+
+ it'works'do
+ render
+ expect(d.map(&:id)).toeq([employee2.id])
+ end
+ end
+end
+
+
Here we seed data, set the filter parameter, and assert only records
+matching the given criteria are present in the response.
+
+
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.
describe'sorting'do
+ describe'by id'do
+ let!(:employee1){create(:employee)}
+ let!(:employee2){create(:employee)}
+
+ context'when ascending'do
+ beforedo
+ params[:sort]='id'
+ end
+
+ it'works'do
+ render
+ expect(d.map(&:id)).toeq([
+ employee1.id,
+ employee2.id
+ ])
+ end
+ end
+
+ context'when descending'do
+ beforedo
+ params[:sort]='-id'
+ end
+
+ it'works'do
+ render
+ expect(d.map(&:id)).toeq([
+ employee2.id,
+ employee1.id
+ ])
+ end
+ end
+ end
+end
+
+
Here we seed data, set the sort parameter, and assert the correct order
+of the rendered response.
+
+
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.
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
+
+ beforedo
+ params[:include]='current_position'
+ end
+
+ it'returns position with historical index == 1'do
+ render
+ sl=d[0].sideload(:current_position)
+ expect(sl.jsonapi_type).toeq('positions')
+ expect(sl.id).toeq(pos2.id)
+ end
+ end
+end
+
+
Here we seed data, set the sideload parameter, and assert the correct
+entity is present in the request. There is no need to test each
+attribute of the sideload - this should be tested in the Resource
+Test 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.
Rails 5 made belongs_to required 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:
it'works'do
+ # some assertion
+ email=ActionMailer::Base.deliveries.last
+ expect(email.subject).toeq('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.
Here we’re ensuring EmployeeResource is the correct resource to be
+called from this endpoint, we get a 200 status code, and the entities
+returned are expected.
Here we’re ensuring EmployeeResource is called, a record is correctly
+inserted, and the response code is 201.
+
+
You probably only want to add attributes required to pass validation,
+here - note that 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:
Here we’re ensuring EmployeeResource is called, attributes are updated,
+and we respond with a 201. Note that we don’t assert on specific
+attributes - save that for your Resource test.
+
+
Just like the prior section, you may want to leverage FactoryBot here to
+generate randomized attributes:
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:
+
+
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:
Testing standards vary from team to team, and there is no right answer
+when judging “the right level of testing”.
+
+
You could add tests for every attribute, validating every sort and
+filter. Or, you could consider logicless configuration tested as part of
+Graphiti itself (the same way we don’t tend to test a has_many
+ActiveRecord relationship). Though our guides favor the latter, the
+extra tests could prove useful when performing a major upgrade or
+swapping datastores.
+
+
You could do more API testing, particularly for high-value
+functionality. Testing fully end-to-end, from middleware to response
+codes, gives a high level of confidence. But it can also feel like
+duplicate tests across endpoints, which is why we have Resource tests.
+
+
Graphiti provides sensible defaults, but you’re encouraged to consider
+the tradeoffs and pick the right level of testing for you.
Integration testing is great: it gives a high level of confidence, and
+they’re typically the easiest tests to write. In fact, these tests are
+so powerful the value of unit testing sometimes comes up for debate.
+
+
Consider a custom filter powered by an ActiveRecord scope:
If we’re by-the-book, we should absolutely test .by_title on the
+Employee model. After all, we’re exposing a public interface that other
+developers might rely on in the future.
+
+
This can feel cumbersome, even duplicative. The Resource Test of the
+title filter will seed the same data as the corresponding unit test, and
+the assertion will be almost identical. But because Resource Tests are
+integration tests, we shouldn’t mock the code either.
+
+
The best practice here is to use RSpec shared_context to remove the duplication:
+
+
# spec/support/employees_helper.rb
+RSpec.shared_context'employees by title'do
+ let!(:employee1){create(:employee)}
+ let!(:employee2){create(:employee)}
+ let!(:employee3){create(:employee)}
+ let!(:position1)do
+ create(:position,title: 'foo',employee: employee1)
+ end
+ let!(:position2)do
+ create(:position,title: 'BAR',employee: employee2)
+ end
+ let!(:position3)do
+ create(:position,title: 'bar',employee: employee3)
+ end
+end
+
+# spec/models/employee.rb
+describe'.by_title'do
+ include_context'employees by title'
+
+ it'returns employees matching the given title'do
+ expect(Employee.by_title('bar'))
+ .toeq([employee2,employee3])
+ end
+end
+
+# spec/resources/employee_resource.rb
+describe'filtering'do
+ describe'by title'do
+ include_context'employees by title'
+
+ beforedo
+ params[:filter]={title: 'bar'}
+ end
+
+ it'returns employees matching the given title'do
+ expect(records).toeq([employee2,employee3])
+ end
+ end
+end
+
+
This allows our by_title scope to be re-used by future developers
+outside of the Resource context. It also keeps code clean and
+isolated.
+
+
But it’s not unreasonable to think the overhead here isn’t worth it. If
+you’re of this mind, we recommend testing the Resource and marking the
+method as not re-usable:
+
+
# @api private
+scope:by_title,->(value){...}
+
+
This way future developers know the scope is only an implementation
+detail and not considered part of this object’s public API. Writing the
+unit test can be deferred until the use case actually arises.
$ 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.
This process is straightforward; you can add Graphiti to an existing
+Rails app alongside JBuilder or ActiveModelSerializers.
+
+
Start with gems:
+
+
# The only strictly-required gem
+gem'graphiti'
+
+# For automatic ActiveRecord pagination
+gem'kaminari'
+
+# Test-specific gems
+group:development,:testdo
+ gem'rspec-rails'
+ gem'factory_bot_rails'
+ gem'faker'
+end
+
+group:testdo
+ gem'database_cleaner'
+end
+
+
You’ll be up-and-running at this point. Verify with a simple standalone
+Resource:
+
+
# Assuming you already have a Post ActiveRecord Model
+classPostResource<Graphiti::Resource
+ self.adapter=Graphiti::Adapters::ActiveRecord
+ attribute:title,:string
+end
+
+PostResource.all.data# => [#<Post>, #<Post>, ...]
+
+
Now we just need to integrate with Rails endpoints (to give us things
+like #context):
# app/controllers/application_controller.rb
+# When #show action does not find record, return 404
+register_exceptionGraphiti::Errors::RecordNotFound,
+ status: 404
+
+rescue_fromExceptiondo|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.
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”:
+
+
---
+namespace: /my_api/v1
+
+
If this file doesn’t exist you may get unexpected errors - make sure to
+create it!
Keep in mind, we’ll always show detailed exceptions in development
+mode, per Rails conventions. If this is confusing or not desirable,
+set config.consider_all_requests_local = false in
+config/environments/development.rb
+
+
+
+
If you’re not running Rails in API-only mode, be sure to set
+config.debug_exception_response_format = :api in
+config/application.rb
+
+
+
Testing
+
+
Add config.include Graphiti::Rails::TestHelpers to
+spec/rails_helper.rb.
Graphiti makes RESTful Resources a first-class concept. This enables
+reading and writing a graph of data in a single request, a schema with
+backwards-compatible guarantee, end-to-end integration test
+patterns, seamless microservices and much more.
+
+
If you just want to get a 5-minute feel for Graphiti code and all the
+functionality that comes out-of-the box, head to the
+Quickstart. If you want a birds-eye view of Graphiti’s
+moving pieces, check out our Intro to Graphiti video or The Lifecycle of a Graphiti
+Request. Or to understand Resources,
+Graphiti’s core concept, see the Resource
+API.
The key concept here is a Resource. Resources sit between a Request and a Backend, defining how to query, persist, and serialize data. They are accessible through Endpoints, which customize Resource behavior based on context.
+
+
+
+
+
+
+
+
Each Resource is comprised of Attributes. Each Attribute corresponds to behavior for:
+
+
+
Reading: The fields rendered in the response, e.g. { "first_name": "Jane" }
+
Writing: The fields accepted in the payload, e.g. a POST or PUT request.
+
Filtering: The fields we can query, e.g. /employees?filter[first_name]=Jane
+
Sorting: The fields we can sort on, e.g. /employees?sort=age
+
+
+
Each Attribute has a Type, which will be coerced and checked at runtime.
+
+
+
+
+
+
A Resource does not mean “a database table”, though the two have a lot in common and often match. A Resource is a generic interface wrapping a Backend. That Backend could be a relational database, a No-SQL database, or even a third-party service call. And you can use whichever client or ORM you’d like for a given Backend (ActiveRecord, Sequel, Mongoid, Net::HTTP, etc).
+
+
Resources define an interface for querying from and persisting to a given Backend. The generic, common, cross-Resource logic for connecting a Resource to a Backend is defined in an Adapter; individual Resources can override Adapter logic. The default adapter is ActiveRecord (Graphiti::Adapters::ActiveRecord).
+
+
From the raw backend results, the Resource builds Models, which hold
+business logic. In other words you might query using elasticsearch-ruby, but return POROs as the result of that query. In the case of ActiveRecord, Backend and Model are the same thing.
+
+
Finally, these Models are serialized when we actually render a response. You’ll still use Rails, Sinatra, or whatever-else to manage routing, HTTP codes, etc.
+
+
+
+
+
+
Critically, each Resource can connect to other Resources. This can occur through Sideloading (“fetch the employee, her positions, and the departments for those positions in a single request”), Sideposting (“save the employee and her positions/departments in a single request), and Links (“here’s a URL to lazy-load positions in a separate request”). Because Resources connect to each other, this is why we often refer to a Resource as a “node in the graph”.
+
+
Any logic used for fetching a single Resource can be re-used when fetching multiple Resources. In other words, you can say “fetch me the Employee, and her last three Positions ordered by created_at”. Applying query logic to nested levels of the graph is called Deep Querying.
+
+
Graphiti does not depend on Rails, but it is a first-class citizen.
+Given the code:
We can now fetch Posts and Comments in a single request - including sorting the comments, filtering, fieldsets and everything else a Resource supports.
Graphiti 2.0 requires Ruby 3.2+ and Rails 7.1+, and folds graphiti-rails, graphiti_spec_helpers and graphiti_errors into graphiti itself. Those three gems are retired.
+
+
For most apps the upgrade is three things:
+
+
+
Remove graphiti-rails, graphiti_spec_helpers and graphiti_errors from your Gemfile.
+
Add include Graphiti::Rails::Controller to the controllers serving your resources.
+
Update around_persistence hooks, if you have any — they now receive the model rather than the attributes hash.
+
+
+
Old names still resolve and warn, so nothing breaks halfway through. They are removed in 3.0.
+
+
The full upgrade guide lists every rename, what was removed outright, and the migration path for apps not running Rails.
The early version of Graphiti was JSONAPI Suite. If you are a JSONAPI
+Suite user, here’s how to upgrade your existing API.
+
+
The work here is mostly consolidating logic that currently lives in
+multiple files into a single Resource, and rewriting specs. The good
+news is that, because we emphasize full-stack integration tests, we can
+perform the upgrade with confidence by ensuring our tests pass.
There is no Swagger UI equivalent for Graphiti. Swagger is a
+poor fit for graph APIs, and we instead rely on a custom Graphiti
+Schema. Instead, check out Vandal.
+
+
Setup
+
+
Start by removing the gems jsonapi_suite, jsonapi-rails,
+jsonapi_swagger_helpers, and swagger-diff. You’ll eventually remove
+jsonapi_spec_helpers, but keep it for now.
+
+
Add gems graphiti, graphiti_spec_helpers and responders. See the
+Sample App Gemfile.
+
+
Move spec/api to spec/legacy.
+
+
Remove config/initializers/strong_resources.rb,
+config/initializers/jsonapi.rb, app/controllers/docs_controller.rb,
+and the Swagger UI that lives under public.
+
+
Remove swagger helpers from Rakefile.
+
+
Grep for JsonapiErrorable and change to GraphitiErrors.
+
+
Make spec/rails_helper.rb correct (though keep jsonapi_spec_helpers
+for now). See sample.
+
+
At this point, running your specs should give you a lot of errors like
+"index" not found.
Begin rewriting your Resources. Go through spec/payloads and add these
+attributes/types to the Resource. Remember to mark these as only:
+[:readable], or writable: false, etc. Look at
+config/initializers/strong_resources.rb to see if an attribute should
+be writable.
+
+
Rewrite custom allow_filters. If it’s a one-liner, just make sure
+there is a corresponding attribute. If there is custom logic:
Two things about filters: by default, value is now always array. Pass
+single: true if your logic only supports a single value. Also: a
+filter with a given type now comes with operators - string gets
+prefix, suffix, etc. If you don’t support these, limit operations
+with only:.
+
+
Note that we now support multiple content types for read requests:
+.json and .xml. If you have any clients explicitly putting .json
+at the end of the URL, they are now going to get a simple JSON response
+instead of JSONAPI. Avoid the responders gem if you don’t want this.
+
+
You may want to move some persistence logic to before_commit.
+
+
Resources now have #base_scope. If you previously were using default_filter or passing in a custom scope in your controller, consider moving to base_scope:
If you have manual sideloading logic with scope, it is highly
+recommended you rewriting using params - see relationship docs. If you do still need scope, it now yields the parent ids as the first argument and the actual parent models as the second.
+
+
Finally, you are encouraged to avoid overriding create/update/destroy
+directly. Instead, override build, save, and delete, and use
+Persistence Lifecycle
+Hooks.
+
+
At this point, get all your spec/legacy specs passing.
+
+
When you’re done, generate the new Resource and API Specs. Note that much of this is syntax changes, you can copy/paste large amounts of logic from spec/legacy. To ease this process, try rails g graphiti:api_test PostResource and rails g graphiti:resource_test PostResource.
+
+
You should now have the upgraded and legacy test suite working. We
+can now remove the legacy specs:
+
+
+
Remove jsonapi_spec_helpers gem
+
rm -rf spec/payloads
+
rm -rf spec/legacy
+
+
+
And you’re done! Deploy to a staging environment and verify your API
+supports all your real-world scenarios.
+
+
Persistence
+
+
Though you can get specs passing with your existing create, update,
+etc, try to rewrite them using hooks. It’s no longer considered a best
+practice to override these methods because you’ll be bypassing hooks.
+Instead, add hooks and override def save(model) if you need to.
+
+
Gotchas
+
+
We use respond_with in read operations and render jsonapi: in write
+operations. This is because the responders gem bypasses renderers for
+PUT, and a few other minor issues.
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 (initial load may take a second).
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.
I’ve written GraphQL and had a pleasant experience. I have enormous respect for GraphQL. I also believe there is a fundamental flaw in GraphQL’s design.
+
+
Let’s remember why people like GraphQL in the first place - because it addressed common frustrations with REST APIs:
+
+
+
[We] heard from integrators that our REST API also wasn’t very flexible. It sometimes required two or three separate calls to assemble a complete view of a resource. It seemed like our responses simultaneously sent too much data and didn’t include data that consumers needed.
GraphQL solves real problems. Its flaw is that it solved these
+problems using zero-sum thinking: we must
+abandon the existing paradigm and forge a new one. It’s GraphQL
+versus REST, one or the other. REST is dead, long live GraphQL.
Positive-sum thinking is how we embrace pluralism while retaining a coherent vision and set of values…A zero-sum view would assume that apparent oppositions are fundamental, e.g., that appealing to the JS crowd inherently hurts the C++ one. A positive-sum view starts by seeing different perspectives and priorities as legitimate and worthwhile, with a faith thatby respecting each other in this way, we can find strictly better solutions than had we optimized solely for one perspective.
GraphQL optimized around REST’s shortcomings, and in doing so it dropped
+REST’s advantages. There is no need for such a zero-sum tradeoff. We can take
+everything great about GraphQL and build it on top of REST (and
+HTTP!), instead of replacing it altogether. We can have our cake and eat it too.
You’ll find plenty of GraphQL posts that describe REST as an inflexible paradigm of a bygone era, a lack of granularity that necessarily leads to data under- and over-fetching.
+
+
It’s true that many REST APIs work this way, but this is not REST. While there’s
+endless debate around which APIs are considered “RESTful”, I don’t think
+we need to look much further than what the letters actually stand for:
+
+
+
+
+
Representational State Transfer. This sentence is not only what REST stands for, it is also the tiniest possible description of what REST actually means…It is not a standard, rather a style describing the act of transfering a state of something by its representation.
+
+
Let’s consider this:
+
+
Marcus is a farmer. He has a ranch with 4 pigs, 12 chickens and 3 cows. He is now simulating a REST API while I am the client. If I want to request the current state of his farm using REST I just ask him: “State?”
+
+
Marcus answers: “4 pigs, 12 chickens, 3 cows”.
+This is the most simple example of Representional State Transfer. Marcus transfered the state of his farm to me using a representation. The representation of the farm is the plain sentence: “4 pigs, 12 chickens, 3 cows”.
+
+
So lets get to the next level. How would I tell Marcus to add 2 cows to his farm the REST way?
+Maybe tell him: “Marcus, please add 2 cows to your farm”.
+
+
Do you think this was REST? Are we transfering state by its representation here? NO! This was calling a remote procedure. The procedure of adding 2 cows to the farm.
+
+
Marcus sadly answers: “400, Bad Request. What do you mean?”
+
+
So lets try this again. How would we do this the REST way? What was the representation again? It was “4 pigs, 12 chickens, 3 cows”. Ok. so let’s try this again transfering the representation…
+
+
me: “Marcus, … 4 pigs, 12 chickens, 5 cows … please!”.
+Marcus: “Alright !”.
+me: “Marcus, … what is your state now?”.
+Marcus: “4 pigs, 12 chickens, 5 cows”.
+me: “Ahh, great!”
+See? It was really not that hard and it was REST.
We’re moving an object from the server to the client, possibly modifying
+that object, then moving back to the server.
+
+
The other important aspect of REST is Links. Because all objects are
+addressable at a URL, we can use HTTP links to connect Resources. This
+allows for (among other things) lazy-loading - the logic connecting Resources can be
+changed server-side without breaking clients. If we have a “Top
+Comments” relationship, we can redefine “Top” without disturbing
+clients.
+
+
+
+
+
+
+
+
Pretty simple right? Here’s how we might implement this in GraphQL:
The first thing to note is that GraphQL is super badass at describing
+fields and types. The next thing to note is that fields and types are
+the wrong abstraction.
+
+
Defining a schema like this allows bespoke, fine-grained detail. If we
+wanted, the CreateEmployeePayload could be different than the
+UpdateEmployeePayload - same for inputs like CreateEmployeeInput and
+UpdateEmployeeInput. If we wanted other actions, like
+promoteEmployee or deactivateEmployee, they would be easy to add and
+follow the same basic constructs.
+
+
This is RPC - hand-crafted, custom requests. We have a high level of
+configuration but a low level of convention. Not only will
+developers have to spend more time hand-crafting these requests, but
+patterns are likely to diverge from one API to the next, from team to
+team, as time moves on. In fact, the above is really a best-case
+scenario with common naming convention of create/update/destroy - the
+Github API adds verbs like add, remove, lock, move and more.
+
+
Oh, and we dropped support for lazy-loading along the way.
The benefit of REST over RPC is conventions. Conventions cause increased
+productivity and consistency (leading to fewer misunderstandiings and
+chances for bugs). Let’s start thinking in REST, and see where it takes
+us.
+
+
In REST, we know the input and output is always the Resource:
Getting there. OK, and we know we’re dealing with an Employee, and we
+know we won’t have custom verbs like promote or remove - we’re
+moving objects here, and nothing else (if that throws you for a mental
+loop, see this presentation by Derek Prior, Engineering Manager at
+GitHub).
By adopting conventions, we not only removed boilerplate - we removed
+the chance of subtle inconsistencies. This is better for both providers
+and consumers of the API.
+
+
We’re just getting started.
+
+
The above schema covers basic CRUD. But we probably want to filter data, right? Let’s say we want to return all employees with a given name:
+
+
employee(id:ID,name:String)
+
+
Again, we’re seeing chances for inconsistency. What’s the name
+parameter - straight equality? Case sensitive? Contains? I guess we
+could throw a bunch of suffixes at it:
We have divergent APIs right off the bat, and neither one supports
+multisort.
+
+
Rethinking REST
+
+
+
+
+
+
+
Earlier, we covered RESTfully moving an object between the server and
+the client. Those objects connected together with Links, which allowed
+for lazy-loading.
+
+
Instead of throwing away this paradigm, what if we just added
+eager-loading?
+
+
+
+
+
+
+
+
REST doesn’t have a query
+specification or built-in schema, but it does have this Resource concept. Instead of
+thinking of a bag of fields and types, what if we thought in Resources?:
+
+
Resources have Attributes. An Employee has a first_name which is
+a string, an age which is an integer, and so forth.
+
+
We’d probably want to filter and sort by these attributes right? We might add
+some additional filters and sorts, we might want to opt-out of others,
+but querying a Resource by its attributes serves as a reasonable
+baseline.
+
+
If we have an attribute and it’s a string, we know we’re
+talking about operators like suffix and prefix, but an integer
+attribute would want operators like greater_than and less_than.
+
+
Resources also have relationships to other Resources. We should be
+able to lazy-load those relationships (with Links), or eager-load
+those relationships. Whether lazy or eager, the same logic should apply.
+
+
If we defined these Resources, with their attributes and relationships,
+then the input and the output wouldn’t actually matter. Or more
+accurately: we can accept and render whatever payload we want. Maybe
+we’ll even dynamically serve different payloads based on Content-Type.
+
+
OK, so really we don’t need to define inputs and outputs - those can
+be assumed by convention, swapped on-demand. What we really need to define is the Resource.
With nothing but this Resource definition and some assumed conventions,
+we get all this behavior out of the box:
+
+
+
Create
+
Update
+
Delete
+
Read
+
+
Filter
+
+
String (name)
+
+
eq (case sensitive)
+
eql (case insensitive)
+
prefix
+
suffix
+
match
+
not_* (not_eq, not_prefix, etc)
+
+
+
Dates and Numbers (age)
+
+
eq
+
gt (greater than)
+
lt (less than)
+
gte (greater than/equal to)
+
lte (less than/equal to)
+
+
+
+
+
Sort / Multisort
+
Paginate
+
Fieldsets
+
+
+
+
+
Not just an API contract, but out-of-the-📦 behavior. If we know
+the configuration of a Resource, we can automate query and persistence
+operations. Obviously we’ll need lots of ways to customize and
+override, and support for any number of datastores and clients. But if
+we have the configuration, a defined contract for querying and
+persisting data, we can build patterns around it.
+
+
There’s more to this than a bunch of out-of-the-📦 standards and
+behavior. If we thought only in Fields and Types, we’d use GraphiQL to
+see something like:
+
+
+
+
+
+
+
+
+
+
But if we thought in Resources, it’s not just a big bag of fields. We’re
+able to organize those fields around meaningful concepts:
Because we started with a better abstraction, we ended with a more
+intuitive
+UI. As a marketer-turned-programmer myself, I really
+appreciate when data exploration tools like this are friendly to
+less-technical users. A user of Vandal doesn’t need to know about Connections
+or Edges, they just need to click around. I like that my product owner and I can walk
+through the domain together, validating concepts and solidifying a shared
+understanding.
Second, adding endpoints allows for customization. Maybe we want to send
+a welcome email when we POST /employees but not when we POST
+/admin/employees. Maybe /exemplary_employees applies additional query
+logic by default, but wants to re-use everything else. In other words,
+Resources form your graph, but Endpoints expose that graph to the
+outside world (and hold relevant logic around exposure). Read more in
+our Endpoints Guide.
+
+
The third reason is lazy-loading with Links, which we’ll cover in a bit.
We even get schema benefits. Schemas are great for tooling and
+backwards-compatibility checks…but when they are oriented around
+Fields and Types, they can only tell you so much. When they are oriented
+around Resources, they can expose less-obvious concepts. Maybe we sort
+Employees by created_at by default:
Because this is specified in the schema, not only are clients more
+informed, but changing this default would raise a backwards-compatibility
+error:
+
+
EmployeeResource: default sort changed from [{:created_at=>"desc"}] to [{:last_name=>"asc"}].
+
+
When developing in Graphiti, we introspect your Resources and
+automatically generate the schema for you. Backwards-compatibility
+checks can be done with a command-line task, or whenever your tests run.
You may be thinking, “OK, but REST only works for a single object. I’ll
+have to make multiple requests, and be right back where I started. I
+need GraphQL to solve this problem”.
+
+
Not true.
+
+
Years before GraphQL came out, respected developers from different
+companies and backgrounds came together and began the discussion on how to improve REST
+APIs. This wasn’t a project pushed by a hundred-billion dollar
+company; it was an organic, community-driven effort. The result was the JSON:API standard,
+which tackled granular queries long ago:
You can absolutely develop in Graphiti this way, but you’d be giving up
+some smart things JSON:API does. One example is de-duplicating each node
+in the graph: if we’re listing 100 Posts and they all have the same
+Author, you’ll have to render that Author 100 times. JSON:API would
+only render it once.
+
+
Another is the type/id combo:
+
+
+
+
Namely, those constraints are that all entities must be addressable top level by type and ID. (Very similar to JSON:API in this respect.)
There’s a bunch of reasons this is important, but one I’ve always been
+partial to is websockets. This type/id combo allows us to uniquely
+identify records once they’ve been loaded in JS memory. So, every time a
+Resource is saved we can push its state (JSON representation) to clients
+with a websocket. Here I am randomly updating a bunch of backend data,
+and watching the UI update in real-time.
+
+
+
+
+
+
+
This took only a handful of lines of code, all of which could be
+packaged into a library to make this automatic.
+
+
Another example is lazy-loading data with Links:
+
+
+
+
A GraphQL response is going to be as slow as the slowest subquery it has to execute to build the response.
Instead of loading everything up-front, we want to defer loading for
+performance reasons. Maybe we want to render our Employee detail page
+super quick, and we don’t need to list the Positions until the user
+clicks something.
+
+
You can do this in GraphQL, but you need to bake logic into the client,
+which means changing the logic would break clients (read more about
+this in the Links Guide).
+Luckily, REST and JSON:API are optimized for lazy-loading:
Graphiti generates these Links between Resources automatically. If you
+change the logic connecting Resources (which applies to eager loading as
+well), we’ll update the Link - clients can simply follow the
+link, and we can change logic server-side with no breakage.
+
+
There’s a bunch of other great stuff about JSON:API, but that is a
+topic for another day.
+
+
+
(Quick aside: I’ve always found the name of this project hilariously
+awkward, easy to confuse with any API outputting JSON. But you could say
+the same about GraphQL! Just as JSON:API isn’t the only API standard
+outputting JSON, GraphQL isn’t the only Graph Query Language - in fact,
+you could call JSON:API a Graph Query Language as well!)
Anything you could do with a single Resource, you can do with multiple
+Resources. In other words, we can fetch an Employee, and their
+Positions - but only active positions where the title starts with
+Eng, ordered by created_at. This is called Deep Querying.
+
+
The point here is that REST doesn’t mean multiple requests. Sure, our
+state is now a graph of objects instead of a single object, but there’s
+no need for a wholesale revamp. In fact, we don’t need to change much at
+all.
+
+
+
+
+
+
Just as we can query multiple Resources at once, we can also
+persist multiple objects at once.
+
+
When persisting in REST, we send the same object back to the server
+alongside a verb. That verb tells us if we’re creating, updating (part
+or whole), or deleting. Same thing here. Let’s specify the verb
+alongside the relationship:
Earlier, we covered how REST was optimized for lazy-loading. Graphiti
+builds on top of REST to add eager-loading, and “eager-persisting”. In
+other words, we can both read and write a graph of data in a single
+request.
You should think hard before breaking up a Majestic Monolith; beware the tradeoffs. Still, if you need it, Graphiti has your back.
+
+
+
We now have a consistent interface for queries and relationships. We
+also covered how Resources connect together with Links. Put two and
+two together, and you’ll see a Resource doesn’t need to be local to same
+application. We can have cross-API, remote Resources as well.
+
+
The popular GraphQL platform Apollo does something similar with Schema Stitching:
+
+
+
+
+
+
+
+
You won’t need to write code like this in Graphiti. Because we have
+conventions, we can automate this stuff. Just supply a URL:
That’s it. Everything works the same. We can fetch an Employee and her Positions in a single
+request, add additional local or remote Resources to the request, and
+Deep Query. If you use Vandal, you’ll think it’s all the same API.
DDD is about trying to make your software a model of a real-world system or process. In using DDD, you are meant to work closely with a domain expert who can explain how the real-world system works. For example, if you’re developing a system that handles the placing of bets on horse races, your domain expert might be an experienced bookmaker.
+
+
Between yourself and the domain expert, you build a ubiquitous language (UL), which is basically a conceptual description of the system. The idea is that you should be able to write down what the system does in a way that the domain expert can read it and verify that it is correct. In our betting example, the ubiquitous language would include the definition of words such as ‘race’, ‘bet’, ‘odds’ and so on.
+
+
The concepts described by the UL will form the basis of your object-oriented design. DDD provides some clear guidance…recommends several patterns…
You can use any HTTP client with Graphiti. If you’re using JS, a simple
+fetch will do.
+
+
But you may be looking for a more robust client, one that takes
+advantage of Graphiti conventions. Look no further than
+Spraypaint, our
+official client heavily inspired by ActiveRecord. Query your API the
+same way you query your database:
+
+
// All of this is chainable
+
+let{data}=awaitEmployee
+ .where({name:{prefix:"Jane"}})
+ .order({created_at:"desc"})
+ .page(2).per(10)
+ .select(['name','age'])
+ .stat({total:'count'})
+ .includes({positions:'department'})
+ .all()
+
+letrecord=data[0]
+record.positions[0].department.name='Updated!'
+awaitrecord.save({with:{positions:'department'}})
+
+
By relying on conventions we can move the URL, request, and response
+under the hood, allowing you to focus on what matters - your domain.
OK, let’s come full circle. Let’s say some of these conventions resonate
+with you, but nevertheless you’d like to develop a GraphQL API. I still think
+Graphiti is your best bet, because Graphiti supports GraphQL.
+
+
Remember, we started with this long-hand RPC code:
Graphiti does not need all this boilerplate. But
+if we have the short-hand, that means we can automatically generate
+the long-hand. We can introspect the Graphiti Resources, and use
+graphql-ruby to automatically generate GraphQL code.
+
+
That project is graphiti-graphql. While still more of an experiment at this stage, it’s shaping up nicely. The main blockers are the conventions missing from any GraphQL API - how should we render validation errors, which sorting standard should we adopt, etc. But we have proof that if there’s a target to hit, we can autogenerate it.
+
+
So we can generate GraphQL via Graphiti, but should we? Maybe! Not
+an unreasonable pursuit. If you’d like to go down this route, I’d love
+to hear from you!
+
+
Still, let’s be clear what we’re missing: HTTP caching, error codes, lazy-loading, and more.
Having that level of consistency, and working with that for
+just a little while means that you can start to forget about it. And
+that’s the power of conventions in general…it used to be something
+you had to think about and make a decision. Well, decisions are bad.
+Decisions take up your brain power, and it requires brain cycles to
+consider which or the other. The more decisions you can take out of
+the whole thing, the more brain power you can free up to consider the
+really important things.
+
+
If everybody is doing the same thing in the same way, it means that
+you can easily go from one application to the other, and expect the
+same things to happen.
When conventions form your programming foundation, you end up with these
+high-level abstractions where a few lines of code hide the underlying
+complexity. Often, this is flippantly referred to as ✨”Magic“🔮
+
+
The thing about magic is, once you learn the trick it often comes down
+to something simple: a mirror, a trick deck, a quick hand.
+
+
An object, moving back and forth.
+
+
+
+
+
+
Simple concepts can often be the most powerful, and I will never get
+tired of seeing this trick performed.
I can’t believe I just wrote all that. I swear, I never set out to do any of this.
+
+
There are a lot of big ideas here, but this project is not some ideological crusade. Three years ago I had a bug in my application, and I found it surprisingly tricky to fix. I kept pulling that string, I connected with other developers pulling their own strings, one thing led to another, and here we are. This project is nothing more than a good-faith effort to build quality software, based on my own personal experience.
+
+
Let’s be clear: GraphQL is awesome. It solves real problems in a
+user-friendly manner, has sexy tooling and amazing libraries created
+by kickass developers. There are many advantages of GraphQL not covered
+here, and plenty more great ideas I’d like to steal. If you find some of
+the framing here annoying, if you find yourself thinking “…but what
+about X and Y?”, just know I feel the same way whenever a GraphQL
+post talks about REST. It will be a continual goal of this project to
+raise the level of debate.
+
+
Same for RPC. I work for a company that made billions off of RPC. I tend
+to think REST is a better fit for the web, and RPC is a better fit for
+things like financial trading systems or video games, but plenty of
+reasonable people can disagree. Conventions and abstractions come with
+their own cost: a learning curve, indirection, lack of flexibility. If
+you prefer something more low-level, I completely understand. I’ll
+continue trying to learn from you.
+
+
Beware projects and ideologies that claim to be a solution to all your
+problems. Everything has tradeoffs. The best we can do is to avoid
+zero-sum thinking, and instead think about how we can take the best
+ideas from a variety of solutions and experiences, striving to
+continually improve. It is hard, it is exhausting, it is the right thing
+to do.
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).
It’s a popular pattern 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 from React to Ember.
+
+
To follow this pattern, use #dup() when passing down to your component:
+
+
<my-component something="model.dup()" />
+
+
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 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.
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.
+
+
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
+
+
+ Javascript
+
+
+
+
+
+
@Model
+ exportclassApplicationRecordextendsSpraypaintBase{
+ staticwithDebug<TextendsApplicationRecord>():Scope<T>{
+ returnthis.extraParams({debug:true})as Scope<T>;
+ }
+ }
+ // unfortunately you will need to pass in the
+ // implementing class' type as a generic
+ UserRecord.withDebug<UserRecord>().all()
+
This is due to Strict Class Initialization. For the purposes of Spraypaint, we don’t need this. Remove the need for
+! (as the rest of these guides do) by setting
+
+
"strictPropertyInitialization": false
+
+
in tsconfig.json.
+
+
Defining Models
+
+
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):
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:
With the above configuration, all Person endpoints will begin
+http://my-api.com/api/v1/people.
+
+
+
TIP: Avoid CORS and use relative paths by simply 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.
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.
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:
Saved in a single request .save({ with: 'dogs' }) (see
+writes)
+
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
letdog=newDog({name:"Fido"})
+letperson=newPerson({dogs:[dog]})
+person.dogs[0].name// "Fido"
+
+letperson=newPerson()
+person.dogs=[dog]
+person.dogs[0].name// "Fido"
+
+// Will auto-create Dog instance
+letperson=newPerson({dogs:[{name:"Scooby"}]})
+person.dogs[0].name// "Scooby"
+
+letperson=(awaitPerson.includes('dogs')).data
+person.dogs// array of Dog instances from the server
+
+
+
vardog=newDog({name:"Fido"})
+varperson=newPerson({dogs:[dog]})
+person.dogs[0].name// "Fido"
+
+letperson=newPerson()
+person.dogs=[dog]
+person.dogs[0].name// "Fido"
+
+// Will auto-create Dog instance
+varperson=newPerson({dogs:[{name:"Scooby"}]})
+person.dogs[0].name// "Scooby"
+
+Person.includes('dogs').then((response)=>{
+ varperson=response.data
+ person.dogs// array of Dog instances from the server
+})
+
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.
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:
Use #select() to limit the fields returned by the server:
+
+
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:
#where() clauses can be chained together. If the same key is seen
+twice, it will be overridden:
+
+
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:
+
+
// 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']})
The interface for read operations is a simpler version of the
+ActiveRecord Query Interface.
+Instead of generating SQL, we’ll be generating JSONAPI requests.
+
+
Basic Finders
+
+
Execute queries with .all(), find(), or .first():
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
letresponse=awaitPost.all()
+response.data// array of Post instances
+
+
+
Post.all().then(function(response){
+ response.data// array of Post instances
+});
+
+
+
+
+
GET /posts
+
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
letresponse=awaitPost.find(123)
+response.data// Post instance
+
+
+
Post.find(123).then(function(response){
+ response.data// Post instance
+});
+
+
+
+
+
GET /posts/123
+
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
letresponse=awaitPost.first()
+response.data// Post instance
+
+
+
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 Scopes (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:
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
+
+
+ Javascript
+
+
+
+
+
+
classPostextendsApplicationRecord{
+ // ... code ...
+ staticsuperImportant(){
+ returnthis
+ .where({ranking_gt:8})
+ .order({ranking:'desc'})
+ .stats({total'count'})
+ }
+}
+
+// get 10 super important posts
+letscope=Post.superImportant().per(10)
+scope.all()// fire query
+
+
+
constPost=ApplicationRecord.extend({
+ // ... code ...
+ static:{
+ superImportant(){
+ returnthis
+ .where({ranking_gt:8})
+ .order({ranking:'desc'})
+ .stats({total'count'})
+ }
+ }
+})
+
+// get 10 super important posts
+varscope=Post.superImportant().per(10);
+scope.all()// fire query
+
The result of all(), first() or find is a Promise. 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 returned by the API (mostly used for statistics in our case). raw is only used to introspect the raw response document.
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
Post.all().then((response)=>{
+ response.data// array of Post instances
+ response.meta// js object from the server
+ response.raw// js response document
+})
+
+
+
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. This makes things even easier:
+
+
let{data}=awaitPost.all()
+data// array of Post instances
+
+// alternatively
+
+letposts=(awaitPost.all()).data
+posts// array of Post instances
We can nest all read operations at any level of the graph. Let’s say we wanted to
+fetch all Posts and their Comments…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:
Any number of scopes can be merged in. Just remember to #include()
+and #merge() relationship names as the server understands them:
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
classDogextendsApplicationRecord{
+ @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()})
+
+
constDog=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()})
Use #stats() to request statistics. Access stats within meta:
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
let{data}=awaitPost.stats({total:"count"}).all()
+data.meta.stats.total.count// the total count
+
+
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 - simply request 0 results per page:
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
let{data}=awaitPost.per(0)stats({total:"count"}).all()
+data.meta.stats.total.count// the total count
+
+
Post
+ .per(0)
+ .stats({total:"count"})
+ .all().then(function(response){
+ response.meta.stats.total.count// the total count
+ })
You may have encountered state management libraries like Flux,
+Redux or Vuex. 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:
+
+
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:
Note that 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:
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:
+
+
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
+to your application.
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.
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.
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.
Similar to ActiveRecord, you can simply call #save() on a model
+instance. Spraypaint will create (POST) or update (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). As always, anything else will
+reject the promise.
After saving, the instance will automatically pick up any
+server-assigned attributes:
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
letpost=newPost()
+ awaitpost.save()
+ post.id// server-assigned value
+ post.createdAt// server-assigned value
+
+
+
varpost=newPost();
+ 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
+
+
+ Javascript
+
+
+
+
+
+
letblog=newBlog({id:123})
+ blog.isPersisted// false
+ awaitblog.save()// POST /blogs
+ blog.isPersisted// true
+ blog.id// 123
+
+ // Manually mark an instance as persisted
+ blog=newBlog({id:123})
+ blog.isPersisted=true
+ awaitblog.save()// PUT /blogs/123
+
+
+
varblog=newBlog({id:123});
+ blog.isPersisted// false
+ // POST /blogs
+ blog.save().then(function(response){
+ blog.isPersisted// true
+ blog.id// 123
+ });
+
+ // Manually mark an instance as persisted
+ varblog=newBlog({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
+
+
+ Javascript
+
+
+
+
+
+
letpost=(awaitPost.first())
+ post.title="updated"
+ // ONLY title sent to the server
+ awaitpost.save()
+ // Title is now synced with the server
+ post.description="updated"
+ // ONLY description sent to the server
+ awaitpost.save()
+
+
+
Post.first().then(function(response){
+ varpost=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();
+ });
+ });
+
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.
Use model.isMarkedForDestruction = true to delete the associated
+object. Use model.isMarkedForDisassociation = true to remove the association
+without deleting the underlying object:
+
+
+
+ Typescript
+
+
+ Javascript
+
+
+
+
+
+
letpost=(awaitPost.includes("comments").first()).data
+ post.comments[0].isMarkedForDestruction=true
+ post.comments[1].isMarkedForDisassociation=true
+
+ // destroys the first comment
+ // disassociates the second comment
+ awaitpost.save({with:"comments"})
+
+
+
Post.includes("comments").first().then(function(response){
+ varpost=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:
JSONAPI Suite is already set up to return validation errors with a
+422 response code and JSONAPI-compliant errors payload. Those errors will be automatically assigned, and removed on subsequent requests:
This quickstart will use Rails with ActiveRecord to give an overview of
+Graphiti functionality on familiar ground. For a more in-depth breakdown, head to the
+Guides.
+
+
If the below seems too “magical”, don’t worry - we’re just applying some
+sensible defaults to get started quickly.
Let’s start with a classic Rails blog. We’ll use a template to handle some of the boilerplate. Just run this command and accept all the defaults for now:
+
+
$ rails new blog --api-m /1.13/template
+
+
Feel free to run git diff if you’re interested in the
+particulars; this is mostly just 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
A Resource defines how to query and persist your Model. In other
+words: a Model is to the database as Resource is to the API. So
+first, let’s define our Model:
+
+
$ bundle exec rails generate model Post title:string upvotes:integer active:boolean
+$ bundle exec rails db:migrate
Now that we’ve defined our Resource and seeded some data, let’s see
+what query functionality we have. We’ve listed all Posts at
+http://localhost:3000/api/v1/posts. Let’s see what we can do:
+
+
+
Sort
+
+
By title, ascending:
+
+
URL: /api/v1/posts?sort=title
+
SQL: SELECT * FROM posts ORDER BY title ASC
+
+
+
By title, descending:
+
+
URL: /api/v1/posts?sort=-title
+
SQL: SELECT * FROM posts ORDER BY title DESC
+
+
+
+
+
Paginate:
+
+
2 Per page:
+
+
URL: /api/v1/posts?page[size]=2
+
SQL: SELECT * FROM posts LIMIT 2
+
+
+
2 Per page, second page:
+
+
URL: /api/v1/posts?page[size]=2&page[number]=2
+
SQL: SELECT * FROM posts LIMIT 2 OFFSET 2
+
+
+
+
+
Sparse Fieldsets:
+
+
Only render title, not active:
+
+
URL: /api/v1/posts?fields[posts]=title
+
SQL: SELECT * from posts (optimizing this query is on the roadmap)
+
+
+
+
+
Filter:
+
+
Simple:
+
+
URL: /api/v1/posts?filter[title]=my title
+
SQL: SELECT * FROM posts WHERE title = "My title!"
+
+
+
Case Insensitive:
+
+
URL: /api/v1/posts?filter[title][eql]=My title
+
SQL: SELECT * FROM posts WHERE lower(title) = "my title!"
+
+
+
Prefix:
+
+
URL: /api/v1/posts?filter[title][prefix]=my
+
SQL: SELECT * FROM posts WHERE lower(title) LIKE 'my%'
+
+
+
Suffix:
+
+
URL: /api/v1/posts?filter[title][suffix]=title
+
SQL: SELECT * FROM posts WHERE lower(title) LIKE '%title!'
+
+
+
Contains:
+
+
URL: /api/v1/posts?filter[title][match]=itl
+
SQL: SELECT * FROM posts WHERE lower(title) LIKE '%itl%'
+
+
+
Greater Than:
+
+
URL: /api/v1/posts?filter[upvotes][gt]=20
+
SQL: SELECT * FROM posts WHERE upvotes > 20
+
+
+
Greater Than or Equal To:
+
+
URL: /api/v1/posts?filter[upvotes][gte]=20
+
SQL: SELECT * FROM posts WHERE upvotes >= 20
+
+
+
Less Than:
+
+
URL: /api/v1/posts?filter[upvotes][lt]=20
+
SQL: SELECT * FROM posts WHERE upvotes < 20
+
+
+
Less Than or Equal To:
+
+
URL: /api/v1/posts?filter[upvotes][lte]=20
+
SQL: SELECT * FROM posts WHERE upvotes <= 20
+
+
+
Any filter not whitelisted will raise JsonapiCompliable::BadFilter
+error.
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:
Vandal is the Graphiti UI. It introspects your schema to make data
+exploration a breeze. The above screenshot shows Vandal fetching posts
+and comments from our blog.
+
+
To run Vandal, hit http://localhost:3000/api/v1/vandal. Click a
+relationship once to include it in the response, click a second time to
+edit deep query logic for the associated Resource.
+
+
If you’ve included an association, you can click a table row to view associated data.
+The below screenshot is hitting /posts, and including only comments
+with the text “two”:
We have a full CRUD API with robust querying functionality, and the
+ability to combine relationships for both reads and writes. But what
+happens when you need to customize the sorting logic? What about replacing
+ActiveRecord with an alternate persistence layer, or avoiding Rails
+altogether?
+
+
These are important topics that Graphiti was built to address. To
+learn more about advanced usage and customization, we suggest following
+the Tutorial and reading through the
+Guides.
This Quickstart is meant to get you up-and-running quickly, so we didn’t
+write tests. But in Graphiti testing is the easiest, most pleasant way
+to develop your application.
+
+
Even if you’re not normally a TDDer, we highly recommend reading through
+our Integration Testing Guide.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/static/1.13/template b/website/static/1.13/template
new file mode 100644
index 00000000..8c092660
--- /dev/null
+++ b/website/static/1.13/template
@@ -0,0 +1,161 @@
+
+Thor::Base.shell = Thor::Shell::Color
+require 'yaml'
+
+def truthy?(statement)
+ val = ask(statement)
+ ['y', 'yes', ''].include?(val)
+end
+
+def eval_template(name)
+ instance_eval(File.read(File.dirname(__FILE__) + "/#{name}.rb"))
+end
+
+def update_config!(attrs)
+ config = File.exists?('.graphiticfg.yml') ? YAML.load_file('.graphiticfg.yml') : {}
+ config.merge!(attrs)
+ File.open('.graphiticfg.yml', 'w') { |f| f.write(config.to_yaml) }
+end
+
+def api_namespace
+ @api_namespace ||= begin
+ ns = prompt \
+ header: "What is your API namespace?",
+ description: "This will be used as a route prefix, e.g. if you want the route '/books_api/v1/authors' your namespace would be '/books_api/v1'",
+ default: '/api/v1'
+ update_config!('namespace' => ns)
+ ns
+ end
+end
+
+def prompt(header: nil, description: nil, default: nil)
+ say(set_color("\n#{header}", :magenta, :bold)) if header
+ say("\n#{description}") if description
+ answer = ask(set_color("\n(default: #{default}):", :magenta, :bold))
+ answer = default if answer.blank? && default != 'nil'
+ say(set_color("\nGot it!\n", :white, :bold))
+ answer
+end
+
+welcome = <<-STR
+\n
+Welcome to the Graphiti generator!
+=======================================
+
+This will take care of some boilerplate for you, like adding gem dependencies and rspec helpers.
+
+If you're worried there might be too much magic here, feel free to run 'git diff' at the end to see what happened. You can also learn more at our documentation website, https://jsonapi-suite.github.io/jsonapi_suite
+STR
+
+say(set_color(welcome.rstrip, :cyan, :bold))
+api_namespace
+
+require 'rails/version'
+
+gem 'graphiti'
+gem 'graphiti-rails'
+gem 'vandal_ui'
+gem 'kaminari', '~> 1.1'
+
+if Rails::VERSION::MAJOR == 5
+ gem 'responders', '~> 2.4'
+else
+ gem 'responders', '~> 3.0'
+end
+
+gem_group :development, :test do
+ if Rails::VERSION::MAJOR == 5
+ gem 'rspec-rails', '~> 3.5.2'
+ else
+ gem 'rspec-rails', '~> 4.0.0beta2'
+ end
+
+ gem 'factory_bot_rails', '~> 5.0'
+ gem 'faker', '~> 2.5' # keep here for seeds.rb
+ gem 'graphiti_spec_helpers'
+end
+
+gem_group :test do
+ gem 'database_cleaner', '~> 1.7'
+end
+
+after_bundle do
+unless Rails::VERSION::MAJOR > 6
+run 'bin/spring stop'
+end
+
+git :init
+git add: '.'
+run "bundle binstub rspec-core"
+rails_command "generate rspec:install"
+run "rm -rf test"
+
+insert_into_file "spec/rails_helper.rb", :after => "require 'rspec/rails'\n" do
+ "require 'graphiti_spec_helpers/rspec'\n"
+end
+
+insert_into_file "spec/rails_helper.rb", :after => "RSpec.configure do |config|\n" do
+ <<-STR
+
+ # bootstrap database cleaner
+ 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
+
+ STR
+end
+
+insert_into_file "spec/rails_helper.rb", :after => "RSpec.configure do |config|\n" do
+ <<-STR
+
+ config.before :each do
+ handle_request_exceptions(false)
+ end
+ STR
+end
+
+insert_into_file "spec/rails_helper.rb", :after => "RSpec.configure do |config|\n" do
+ " config.include Graphiti::Rails::TestHelpers\n"
+end
+
+insert_into_file "spec/rails_helper.rb", :after => "RSpec.configure do |config|\n" do
+ " config.include GraphitiSpecHelpers::Sugar\n"
+end
+
+insert_into_file "spec/rails_helper.rb", :after => "RSpec.configure do |config|\n" do
+ " config.include GraphitiSpecHelpers::RSpec\n"
+end
+
+insert_into_file "spec/rails_helper.rb", :after => "RSpec.configure do |config|\n" do
+ " config.include FactoryBot::Syntax::Methods\n"
+end
+
+gsub_file "spec/rails_helper.rb", 'config.fixture_path = "#{::Rails.root}/spec/fixtures"' do |match|
+ "# #{match}"
+end
+
+gsub_file "spec/rails_helper.rb", 'config.use_transactional_fixtures = true' do |match|
+ "# #{match}"
+end
+
+run "mkdir spec/factories"
+
+rails_command('generate graphiti:install')
+run 'bundle binstubs bundler --force'
+rake('vandal:install')
+say(set_color("\nYou're all set!
+", :green, :bold))
+end
+
+
diff --git a/website/static/1.13/tutorial/index.html b/website/static/1.13/tutorial/index.html
new file mode 100644
index 00000000..adfff827
--- /dev/null
+++ b/website/static/1.13/tutorial/index.html
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Graphiti
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
A core concept of Graphiti is Test-First - the most pleasant way to
+develop Graphiti is by starting with an integration test. 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.
Let’s start by creating a new Rails project. For help with an existing
+project, check out Installation: From
+Scratch.
+
+
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.
+
+
$ rails new employee_directory --api-m /1.13/template
+$ 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, or continue reading to better understand the code.
+
+
Digging Deeper 🧐
+
+
You’ll see some boilerplate in config/routes.rb:
+
+
scopepath: ApplicationResource.endpoint_namespace,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.
+
+
Let’s look at the above ApplicationResource:
+
+
classApplicationResource<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=Rails.application.routes
+ .default_url_options[:host]
+ self.endpoint_namespace='/api/v1'
+end
When deriving and validating Links, we’ll use the HOST variable if
+present, falling back to the Rails development default of
+http://localhost:3000. This means our Links will look like:
We’ll be working with a single database table, employees:
+
+
+
+
+
id
+
first_name
+
last_name
+
age
+
created_at
+
updated_at
+
+
+
+
+
1
+
Homer
+
Simpson
+
39
+
2018-09-04
+
2018-09-04
+
+
+
2
+
Waylon
+
Smithers
+
65
+
2018-09-04
+
2018-09-04
+
+
+
3
+
Monty
+
Burns
+
123
+
2018-09-04
+
2018-09-04
+
+
+
+
+
The Rails Stuff 🚂
+
+
Use the built-in generator to create the database table
+and corresponding ActiveRecord model:
+
+
$ 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 (which was installed in Step 0):
+
+
# db/seeds.rb
+Employee.delete_all# Ensure the DB is cleaned each run
+
+100.timesdo
+ Employee.create!first_name: Faker::Name.first_name,
+ last_name: Faker::Name.last_name,
+ age: rand(20..80)
+end
+
+
Run this seed file with
+
+
$ 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:
+
+
$ 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:
Let’s start with simple display, turning first_name into all capital
+letters:
+
+
# app/resources/employee_resource.rb
+attribute:first_name,:stringdo
+ # @object is your model instance
+ @object.first_name.upcase
+end
+
+
Which gives us:
+
+
+
+
+
+
This is the most important thing to understand about Resources: they are
+just a collection of defaults, all of which can be overridden. In other
+words:
+
+
attribute:first_name
+
+# is the same as
+
+attribute:first_namedo
+ @object.first_name
+end
+
+
We’ll go into further Resource customizations over the course of this
+tutorial. For now, let’s just verify our out-of-the-box defaults:
+
+
+
Sort by first_name ascending: http://localhost:3000/api/v1/employees?sort=first_name
+
Sort by first_name descending: http://localhost:3000/api/v1/employees?sort=-first_name
+
Return only age and created_at in the response: http://localhost:3000/api/v1/employees?fields[employees]=age,created_at
Greater Than or Equal To: http://localhost:3000/api/v1/employees?filter[age][gte]=39
+
Less Than: http://localhost:3000/api/v1/employees?filter[age][lt]=65
+
Less Than or Equal To: http://localhost:3000/api/v1/employees?filter[age][lte]=65
+
+
+
Paginate
+
+
10 per page: http://localhost:3000/api/v1/employees?page[size]=10
+
5 per page, third page:
+http://localhost:3000/api/v1/employees?page[number]=3
+
+
+
+
+
Write operations are easiest to verify with integration tests, which
+were created when we generated our Resource. Let’s take a look at the
+test for creating Employees:
+
+
# spec/api/v1/employees/create_spec.rb
+
+RSpec.describe"employees#create",type: :requestdo
+ subject(:make_request)do
+ jsonapi_post"/api/v1/employees",payload
+ end
+
+ describe'basic create'do
+ let(:payload)do
+ {
+ data: {
+ type: 'employees',
+ attributes: {
+ # ... your attrs here
+ }
+ }
+ }
+ end
+
+ it'works'do
+ expect(EmployeeResource).toreceive(:build).and_call_original
+ expect{
+ make_request
+ }.tochange{Employee.count}.by(1)
+ expect(response.status).toeq(201)
+ end
+ end
+end
+
+
This is an API Spec, which tests high-level end-to-end functionality. We
+know that if our API receives a POST with the given payload, an
+Employee will be created and a 201 response code will be returned.
+
+
API specs are high-level - often they won’t be changed past this initial
+boilerplate. For testing logic, use a Resource Spec. These
+integration tests hit the database and run logic, but operate without a
+specific request or response:
In other words: API specs test Endpoints (request, response, middleware,
+etc), Resource specs test only the Resource (actual application logic).
+Read more in our Testing Guide.
+
+
Before we run these specs, we need to edit our factories to ensure
+dynamic, randomized data. Let’s change this:
Now undo the capitalization change to attribute :first_name, and run the generated specs:
+
+
$ bin/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.
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:
+
+
$ 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:
If you visit /api/v1/employees, you’ll see a number of HTTP
+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:
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:
+
+
# spec/factories/position.rb
+FactoryBot.definedo
+ factory:positiondo
+ employee
+
+ title{Faker::Job.title}
+
+ after(:create)do|position|
+ unlessposition.historical_index
+ scope=Position
+ .where(employee_id: position.employee.id)
+ .order(created_at: :desc)
+ scope.each_with_indexdo|p,index|
+ p.update_attribute(:historical_index,index+1)
+ end
+ end
+ end
+ end
+end
+
+
Let’s associate an Employee. Start by seeding the data:
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 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:
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:
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:
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 to dig even deeper.
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:
And review the end of Step 2 to get all your specs
+passing (add the department to the request payload). Practice makes perfect!
+
+
Digging Deeper 🧐
+
+
Note that 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:
+
+
/departments?filter[id]=1
+
+
Which we get out-of-the-📦
+
+
But remember, you can customize these relationships just like the
+previous has_many section.
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:
+
+
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
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.
But if we’re only sorting, this is actually redundant. Whenever we
+use the sort or filter DSL, we’re just creating a sort-only or
+filter-only attribute under the hood. So let’s define everything in one
+shot:
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 simply “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.
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 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.
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:
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:
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.
+
+
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 simply wire them up.
Let’s add a Team relationship: a Team can have many Employees, an
+Employee can have many Teams. Let’s also say a Team belongs to a
+Department.
+
+
+
+
+
id
+
department_id
+
name
+
+
+
+
+
1
+
1
+
The A Team
+
+
+
2
+
1
+
The B Team
+
+
+
3
+
2
+
The C Team
+
+
+
+
+
To satisfy this many-to-many use case, we’ll need a join model,
+TeamMembership:
+
+
+
+
+
id
+
team_id
+
employee_id
+
+
+
+
+
1
+
1
+
1
+
+
+
2
+
2
+
1
+
+
+
3
+
3
+
2
+
+
+
+
+
The Rails Stuff 🚂
+
+
$ 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
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 our Hopping
+Relationships Cookbook.
+
+
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.
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.
+
+
+
+
+
id
+
notable_id
+
notable_type
+
body
+
+
+
+
+
1
+
1
+
Employee
+
A Sample Note!
+
+
+
2
+
1
+
Department
+
Another Sample Note!
+
+
+
3
+
1
+
Team
+
A Third Sample Note!
+
+
+
+
+
The Rails Stuff 🚂
+
+
$ rails generate model Note notable:references{polymorphic}:index
+$ bin/rails db:migrate
+
+
Make sure to add the corresponding model relationships:
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:
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.
+
+
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.
+
+
+
+
+
id
+
milestone_id
+
type
+
title
+
+
+
+
+
1
+
null
+
Bug
+
Incorrect Value!
+
+
+
2
+
null
+
Feature
+
Build great stuff!
+
+
+
3
+
1
+
Epic
+
Build TONS of great stuff!
+
+
+
+
+
Why not just stick with a single Task model? Because each of these
+types has specific behavior: only Features have a points attribute,
+and only Epics have a milestones relationship.
+
+
The Rails Stuff 🚂
+
+
Let’s create our Task model:
+
+
$ bin/rails g model Task employee:belongs_to team:belongs_to type:string
+title:string
+$ bin/rails db:migrate
The point of this was to show how responses could be specific to type,
+so let’s customize Features:
+
+
classFeatureResource<TaskResource
+ attribute:points,:integerdo
+ rand(20)
+ end
+end
+
+
Only Epics have milestones, but let’s support those as well:
+
+
$ bin/rails g graphiti:resource Milestone name:string
+
+
classMilestoneResource<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:epicdo
+ linkdo|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 Epics.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/static/CNAME b/website/static/CNAME
new file mode 100644
index 00000000..b91624f3
--- /dev/null
+++ b/website/static/CNAME
@@ -0,0 +1 @@
+graphiti.dev
diff --git a/website/static/assets/img/error_payload.png b/website/static/assets/img/error_payload.png
new file mode 100644
index 00000000..da986058
Binary files /dev/null and b/website/static/assets/img/error_payload.png differ
diff --git a/website/static/assets/img/legacy/legacy-0378a3bb39.png b/website/static/assets/img/legacy/legacy-0378a3bb39.png
new file mode 100644
index 00000000..40562b6d
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-0378a3bb39.png differ
diff --git a/website/static/assets/img/legacy/legacy-05bbd3e5fd.png b/website/static/assets/img/legacy/legacy-05bbd3e5fd.png
new file mode 100644
index 00000000..fe88aa86
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-05bbd3e5fd.png differ
diff --git a/website/static/assets/img/legacy/legacy-07aa104495.png b/website/static/assets/img/legacy/legacy-07aa104495.png
new file mode 100644
index 00000000..1b2d361b
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-07aa104495.png differ
diff --git a/website/static/assets/img/legacy/legacy-0c75a16b3a.gif b/website/static/assets/img/legacy/legacy-0c75a16b3a.gif
new file mode 100644
index 00000000..b081e25f
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-0c75a16b3a.gif differ
diff --git a/website/static/assets/img/legacy/legacy-3076df6209.png b/website/static/assets/img/legacy/legacy-3076df6209.png
new file mode 100644
index 00000000..1ce5c394
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-3076df6209.png differ
diff --git a/website/static/assets/img/legacy/legacy-7f6889bc89.png b/website/static/assets/img/legacy/legacy-7f6889bc89.png
new file mode 100644
index 00000000..d98e74af
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-7f6889bc89.png differ
diff --git a/website/static/assets/img/legacy/legacy-a2cc4363c3.png b/website/static/assets/img/legacy/legacy-a2cc4363c3.png
new file mode 100644
index 00000000..2243be0b
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-a2cc4363c3.png differ
diff --git a/website/static/assets/img/legacy/legacy-f67cfa89ab.png b/website/static/assets/img/legacy/legacy-f67cfa89ab.png
new file mode 100644
index 00000000..081f20c1
Binary files /dev/null and b/website/static/assets/img/legacy/legacy-f67cfa89ab.png differ
diff --git a/website/static/assets/img/meta_total_count.png b/website/static/assets/img/meta_total_count.png
new file mode 100644
index 00000000..1cfcaf7c
Binary files /dev/null and b/website/static/assets/img/meta_total_count.png differ
diff --git a/website/static/img/docusaurus-social-card.jpg b/website/static/img/docusaurus-social-card.jpg
new file mode 100644
index 00000000..ffcb4482
Binary files /dev/null and b/website/static/img/docusaurus-social-card.jpg differ
diff --git a/website/static/img/docusaurus.png b/website/static/img/docusaurus.png
new file mode 100644
index 00000000..f458149e
Binary files /dev/null and b/website/static/img/docusaurus.png differ
diff --git a/website/static/img/favicon.ico b/website/static/img/favicon.ico
new file mode 100644
index 00000000..c00f6af7
Binary files /dev/null and b/website/static/img/favicon.ico differ
diff --git a/website/static/img/logo.png b/website/static/img/logo.png
new file mode 100644
index 00000000..5ed5ca09
Binary files /dev/null and b/website/static/img/logo.png differ
diff --git a/website/static/img/logo.svg b/website/static/img/logo.svg
new file mode 100644
index 00000000..9db6d0d0
--- /dev/null
+++ b/website/static/img/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/website/static/img/undraw_docusaurus_mountain.svg b/website/static/img/undraw_docusaurus_mountain.svg
new file mode 100644
index 00000000..af961c49
--- /dev/null
+++ b/website/static/img/undraw_docusaurus_mountain.svg
@@ -0,0 +1,171 @@
+
diff --git a/website/static/img/undraw_docusaurus_react.svg b/website/static/img/undraw_docusaurus_react.svg
new file mode 100644
index 00000000..94b5cf08
--- /dev/null
+++ b/website/static/img/undraw_docusaurus_react.svg
@@ -0,0 +1,170 @@
+
diff --git a/website/static/img/undraw_docusaurus_tree.svg b/website/static/img/undraw_docusaurus_tree.svg
new file mode 100644
index 00000000..d9161d33
--- /dev/null
+++ b/website/static/img/undraw_docusaurus_tree.svg
@@ -0,0 +1,40 @@
+