├── demo-app ├── .dockerignore ├── boot.rb ├── classes │ ├── base.rb │ ├── tag.rb │ ├── author.rb │ ├── comment.rb │ └── post.rb ├── database.rb ├── Gemfile ├── Dockerfile ├── Rakefile ├── app.rb └── README.md ├── lib ├── sinja │ ├── version.rb │ ├── helpers │ │ ├── nested.rb │ │ ├── relationships.rb │ │ └── serializers.rb │ ├── method_override.rb │ ├── relationship_routes │ │ ├── has_one.rb │ │ └── has_many.rb │ ├── errors.rb │ ├── resource_routes.rb │ ├── resource.rb │ └── config.rb ├── sinatra │ └── jsonapi.rb ├── jsonapi │ └── ember_serializer.rb └── sinja.rb ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── Gemfile ├── .travis.yml ├── test ├── helpers │ ├── test_helper.rb │ ├── core_test.rb │ └── serializers_test.rb ├── integration │ ├── test_helper.rb │ ├── munson_test.rb │ ├── test_client.rb │ ├── weird_test.rb │ ├── tag_test.rb │ ├── rack_test.rb │ ├── author_test.rb │ └── post_test.rb ├── utility_classes │ ├── roles_test.rb │ ├── roles_config_test.rb │ ├── errors_test.rb │ └── config_test.rb ├── middleware │ └── method_override_test.rb └── test_helper.rb ├── contrib ├── bench.sh └── generate-posts ├── LICENSE.txt ├── sinja.gemspec └── README.md /demo-app/.dockerignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | -------------------------------------------------------------------------------- /demo-app/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'bundler/setup' 3 | -------------------------------------------------------------------------------- /lib/sinja/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Sinja 3 | VERSION = '1.3.0' 4 | end 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /test*.rb 11 | .ruby-version 12 | -------------------------------------------------------------------------------- /lib/sinatra/jsonapi.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'sinatra' unless defined?(Sinatra) 3 | require 'sinja' 4 | 5 | module Sinatra 6 | register JSONAPI = Sinja 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'bundler/gem_tasks' 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new do |t| 6 | t.test_files = FileList[File.expand_path('test/**/*_test.rb', __dir__)] 7 | t.warning = false 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'munson', :require=>false, 6 | :git=>'https://github.com/mwpastore/munson.git', :branch=>'develop' 7 | gem 'sinja-sequel', :require=>false, 8 | :git=>'https://github.com/mwpastore/sinja-sequel.git', :branch=>'master' 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.4 5 | - 2.4.1 6 | - ruby-head 7 | - jruby-9.1.7.0 8 | - jruby-head 9 | jdk: 10 | - oraclejdk8 11 | before_install: 12 | - gem install bundler 13 | matrix: 14 | allow_failures: 15 | - rvm: ruby-head 16 | - rvm: jruby-head 17 | -------------------------------------------------------------------------------- /lib/sinja/helpers/nested.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Sinja 3 | module Helpers 4 | module Nested 5 | def defer(msg=nil) 6 | halt DEFER_CODE, msg 7 | end 8 | 9 | def relationship_link? 10 | !params[:r].nil? 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /demo-app/classes/base.rb: -------------------------------------------------------------------------------- 1 | # require_string_literal: true 2 | require_relative '../boot' 3 | require_relative '../database' 4 | 5 | class BaseSerializer 6 | include JSONAPI::Serializer 7 | end 8 | 9 | Sequel::Model.plugin :tactical_eager_loading 10 | Sequel::Model.plugin :validation_helpers 11 | Sequel::Model.plugin :whitelist_security 12 | -------------------------------------------------------------------------------- /test/helpers/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | 4 | # It's somewhat challenging to isolate Sinatra helpers for testing. We'll 5 | # create a "shell" application with some custom routes to get at them. 6 | 7 | class MyAppBase < Sinatra::Base 8 | register Sinja 9 | 10 | before do 11 | content_type :json 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'sinja' 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require 'pry' 11 | # Pry.start 12 | 13 | require 'irb' 14 | IRB.start 15 | -------------------------------------------------------------------------------- /demo-app/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'logger' 3 | require_relative 'boot' 4 | 5 | Sequel.single_threaded = true # WEBrick is single-threaded 6 | 7 | DB = Sequel.connect ENV.fetch 'DATABASE_URL', 8 | defined?(JRUBY_VERSION) ? 'jdbc:sqlite::memory:' : 'sqlite:/' 9 | 10 | DB.extension(:freeze_datasets) 11 | DB.extension(:pagination) 12 | 13 | DB.loggers << Logger.new($stderr) if Sinatra::Base.development? 14 | -------------------------------------------------------------------------------- /test/integration/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | require 'minitest/hooks/test' 4 | 5 | class SequelTest < Minitest::Test 6 | include Minitest::Hooks 7 | 8 | def around 9 | Sequel::Model.db.transaction(:rollback=>:always, :savepoint=>true, :auto_savepoint=>true) { super } 10 | end 11 | 12 | def around_all 13 | Sequel::Model.db.transaction(:rollback=>:always) { super } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /demo-app/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'jdbc-sqlite3', '~> 3.8', :platform=>:jruby 4 | gem 'json', '~> 2.0' 5 | gem 'sequel', '~> 4.46' 6 | gem 'sinatra', '>= 2.0.0.beta2', '< 3' 7 | gem 'sinatra-contrib', '>= 2.0.0.beta2', '< 3' 8 | gem 'sinja', 9 | git: 'https://github.com/mwpastore/sinja.git', branch: 'master' 10 | gem 'sinja-sequel', 11 | git: 'https://github.com/mwpastore/sinja-sequel.git', branch: 'master' 12 | gem 'sqlite3', '~> 1.3', :platform=>[:ruby, :mswin] 13 | -------------------------------------------------------------------------------- /lib/sinja/method_override.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Sinja 3 | class MethodOverride 4 | def initialize(app) 5 | @app = app 6 | end 7 | 8 | def call(env) 9 | env['REQUEST_METHOD'] = env['HTTP_X_HTTP_METHOD_OVERRIDE'] \ 10 | if env.key?('HTTP_X_HTTP_METHOD_OVERRIDE') \ 11 | && env['REQUEST_METHOD'] == 'POST' \ 12 | && env['HTTP_X_HTTP_METHOD_OVERRIDE'].tap(&:upcase!) == 'PATCH' 13 | 14 | @app.call(env) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/integration/munson_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | require_relative 'test_client' 4 | 5 | class DemoAppTest < SequelTest 6 | def before_all 7 | super 8 | # foo 9 | end 10 | 11 | def after_all 12 | # bar 13 | super 14 | end 15 | 16 | def test_it_checks_accept_header 17 | posts = TestClient::Post.fetch 18 | refute posts.any? 19 | end 20 | 21 | def test_it_checks_content_type_header 22 | post = TestClient::Post.new 23 | refute post.id 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/utility_classes/roles_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | 4 | require 'sinatra/base' 5 | require 'sinja/config' 6 | 7 | class TestRoles < Minitest::Test 8 | def setup 9 | @roles = Sinja::Roles[:a, :a, :b] 10 | end 11 | 12 | def test_it_is_setlike 13 | assert_equal [:a, :b], @roles.to_a 14 | end 15 | 16 | def test_it_is_switchable 17 | assert_respond_to @roles, :=== 18 | assert_operator @roles, :===, :a 19 | assert_operator @roles, :===, [:a, :b] 20 | refute_operator @roles, :===, :c 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/integration/test_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../../demo-app/app' 3 | require 'munson' 4 | 5 | Munson.configure \ 6 | adapter: [:rack, Sinatra::Application], 7 | response_key_format: :dasherize 8 | 9 | module TestClient 10 | class Author < Munson::Resource 11 | self.type = :authors 12 | end 13 | 14 | class Comment < Munson::Resource 15 | self.type = :comments 16 | end 17 | 18 | class Post < Munson::Resource 19 | self.type = :posts 20 | end 21 | 22 | class Tag < Munson::Resource 23 | self.type = :tags 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /contrib/bench.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eou pipefail 4 | 5 | port=3333 6 | posts=100 7 | 8 | echo "Starting Rack..." 9 | pushd ../demo-app 10 | APP_ENV=test bundle exec ruby app.rb -p $port -e test -q & 11 | ruby_pid=$! 12 | popd 13 | echo "Done." 14 | 15 | function cleanup { 16 | kill $ruby_pid 17 | wait $ruby_pid 18 | } 19 | trap cleanup EXIT 20 | 21 | sleep 15 22 | echo "Generating Posts..." 23 | ./generate-posts -count=$posts -url="http://0.0.0.0:$port/posts" 24 | echo "Done." 25 | 26 | sleep 15 27 | ab -n 10000 -c 1 -k -H 'Accept: application/vnd.api+json' \ 28 | "http://0.0.0.0:$port/authors/1/posts?page[size]=5&page[number]=3&page[record-count]=$posts&include=tags" 29 | -------------------------------------------------------------------------------- /demo-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.4-alpine 2 | ARG container_port=4567 3 | ENV container_port=$container_port 4 | 5 | RUN apk --no-cache upgrade 6 | RUN apk --no-cache add \ 7 | sqlite-libs 8 | 9 | RUN gem update --system 10 | 11 | COPY Gemfile /app/ 12 | RUN apk --no-cache add --virtual build-dependencies \ 13 | build-base \ 14 | git \ 15 | ruby-dev \ 16 | sqlite-dev \ 17 | && cd /app \ 18 | && bundle install --jobs=4 \ 19 | && apk del build-dependencies 20 | 21 | COPY . /app 22 | RUN chown -R nobody:nogroup /app 23 | 24 | USER nobody 25 | WORKDIR /app 26 | CMD bundle exec ruby app.rb -o 0.0.0.0 -p $container_port 27 | 28 | EXPOSE $container_port 29 | -------------------------------------------------------------------------------- /test/integration/weird_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | 4 | class MyWeirdApp < Sinatra::Base 5 | register Sinja 6 | 7 | get '/' do 8 | not_found 9 | end 10 | 11 | resource :foos do 12 | get '/bar' do 13 | content_type :text 14 | 'hello' 15 | end 16 | end 17 | end 18 | 19 | class MyWeirdAppTest < Minitest::Test 20 | include MyAppTest 21 | include Rack::Test::Methods 22 | 23 | def app 24 | MyWeirdApp.new 25 | end 26 | 27 | def test_not_found 28 | get '/' 29 | assert_error 404 30 | end 31 | 32 | def test_custom_route 33 | get '/foos/bar' 34 | assert last_response.ok? 35 | assert_equal 'hello', last_response.body 36 | assert_match %r{^text/plain}, last_response['Content-Type'] 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /demo-app/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | namespace :docker do 3 | require 'securerandom' 4 | 5 | IMAGE = 'mwpastore/sinja-demo-app' 6 | NAME = SecureRandom.hex(6) 7 | PORT = 4567 8 | 9 | def port 10 | %x{ 11 | docker inspect --format '{{ (index ( index .NetworkSettings.Ports "#{PORT}/tcp") 0).HostPort }}' #{NAME} 12 | }.chomp 13 | end 14 | 15 | task :build do 16 | sh "docker build --build-arg container_port=#{PORT} --no-cache -t #{IMAGE}:latest #{__dir__}" 17 | end 18 | 19 | task :test do 20 | sh "docker run --rm -d -P --name #{NAME} #{IMAGE}" 21 | sleep 5 22 | begin 23 | sh "curl -sf -H 'Accept: application/vnd.api+json' -I :#{port}/authors" 24 | ensure 25 | sh "docker stop #{NAME} >/dev/null || true" 26 | end 27 | end 28 | 29 | task push: [:build, :test] do 30 | sh "docker push #{IMAGE}:latest" 31 | end 32 | 33 | task :run do 34 | sh "docker run --rm -it -p #{PORT}:#{PORT} #{IMAGE}" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/jsonapi/ember_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'jsonapi-serializers' 3 | 4 | module JSONAPI 5 | module EmberSerializer 6 | def self.included(base) 7 | base.class_eval do 8 | include Serializer 9 | 10 | alias type_for_link type 11 | alias format_name_for_link format_name 12 | 13 | include InstanceMethods 14 | end 15 | end 16 | 17 | module InstanceMethods 18 | def type 19 | object.class.name.demodulize.underscore.dasherize 20 | end 21 | 22 | def format_name(attribute_name) 23 | attribute_name.to_s.underscore.camelize(:lower) 24 | end 25 | 26 | def self_link 27 | "#{base_url}/#{type_for_link}/#{id}" 28 | end 29 | 30 | def relationship_self_link(attribute_name) 31 | "#{self_link}/relationships/#{format_name_for_link(attribute_name)}" 32 | end 33 | 34 | def relationship_related_link(attribute_name) 35 | "#{self_link}/#{format_name_for_link(attribute_name)}" 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /contrib/generate-posts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -s 2 | # frozen_string_literal: true 3 | 4 | require 'json' 5 | require 'net/http' 6 | require 'pp' 7 | require 'securerandom' 8 | require 'uri' 9 | 10 | abort "usage: $0 -count= -url=" \ 11 | unless $count && $url 12 | 13 | uri = URI($url) 14 | http = Net::HTTP.new(uri.host, uri.port) 15 | request = Net::HTTP::Post.new(uri.request_uri) 16 | request.initialize_http_header( 17 | 'Accept'=>'application/vnd.api+json', 18 | 'Content-Type'=>'application/vnd.api+json', 19 | 'X-Email'=>'all@yourbase.com' 20 | ) 21 | 22 | Array.new(Integer($count)) do 23 | request.body = JSON.generate(data: { 24 | type: :posts, 25 | id: SecureRandom.urlsafe_base64, 26 | attributes: { 27 | title: SecureRandom.base64(32), 28 | body: SecureRandom.base64(500) 29 | }, 30 | relationships: { 31 | author: { 32 | data: { 33 | id: 1 34 | } 35 | } 36 | } 37 | }) 38 | 39 | response = http.request(request) 40 | 41 | if response.code.to_i != 201 42 | pp JSON.parse(response.body) 43 | abort 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/sinja/relationship_routes/has_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Sinja 3 | module RelationshipRoutes 4 | module HasOne 5 | def self.registered(app) 6 | app.def_action_helper(app, :pluck, :roles) 7 | app.def_action_helper(app, :prune, %i[roles sideload_on]) 8 | app.def_action_helper(app, :graft, %i[roles sideload_on]) 9 | 10 | app.options '' do 11 | unless relationship_link? 12 | allow :get=>:pluck 13 | else 14 | allow :get=>:show, :patch=>[:prune, :graft] 15 | end 16 | end 17 | 18 | app.get '', :on=>proc { relationship_link? }, :actions=>:show do 19 | serialize_linkage 20 | end 21 | 22 | app.get '', :qparams=>%i[include fields], :actions=>:pluck do 23 | serialize_model(*pluck) 24 | end 25 | 26 | app.patch '', :on=>proc { data.nil? }, :actions=>:prune do 27 | serialize_linkage?(*prune) 28 | end 29 | 30 | app.patch '', :actions=>:graft do 31 | serialize_linkage?(*graft(data)) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mike Pastore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo-app/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'sinatra' 3 | require 'sinatra/jsonapi' 4 | 5 | # Load the Sequel helpers without registering the extension. 6 | require 'sinja/sequel/helpers' 7 | 8 | require_relative 'classes/author' 9 | require_relative 'classes/comment' 10 | require_relative 'classes/post' 11 | require_relative 'classes/tag' 12 | 13 | [Author, Comment, Post, Tag].tap do |model_classes| 14 | model_classes.each(&:finalize_associations) 15 | model_classes.each(&:freeze) 16 | end 17 | 18 | DB.freeze 19 | 20 | configure :development, :test do 21 | set :server_settings, AccessLog: [] # avoid WEBrick double-logging issue 22 | end 23 | 24 | helpers Sinja::Sequel::Helpers do 25 | def current_user 26 | # TESTING/DEMO PURPOSES ONLY -- DO NOT DO THIS IN PRODUCTION 27 | @current_user ||= Author.first_by_email(env['HTTP_X_EMAIL']) if env.key?('HTTP_X_EMAIL') 28 | end 29 | 30 | def role 31 | return unless current_user 32 | 33 | [:logged_in].tap do |a| 34 | a << :superuser if current_user.admin? 35 | end 36 | end 37 | end 38 | 39 | resource :authors, &AuthorController 40 | resource :comments, &CommentController 41 | resource :posts, pkre: /[\w-]+/, &PostController 42 | resource :tags, &TagController 43 | 44 | freeze_jsonapi 45 | -------------------------------------------------------------------------------- /test/middleware/method_override_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | 4 | require 'rack/test' 5 | require 'sinatra/base' 6 | require 'sinja/method_override' 7 | 8 | class MyPatchlessApp < Sinatra::Base 9 | use Sinja::MethodOverride 10 | 11 | %i[get post patch].each do |meth| 12 | send(meth, '/') { "#{meth} called" } 13 | end 14 | end 15 | 16 | class TestMyPatchlessApp < Minitest::Test 17 | include Rack::Test::Methods 18 | 19 | def app 20 | MyPatchlessApp.new 21 | end 22 | 23 | def test_normal_post 24 | post '/' 25 | assert last_response.ok? 26 | assert_match %r{post}, last_response.body 27 | end 28 | 29 | def test_normal_patch 30 | patch '/' 31 | assert last_response.ok? 32 | assert_match %r{patch}, last_response.body 33 | end 34 | 35 | def test_post_to_patch 36 | header 'x-http-method-override', 'patch'.dup 37 | post '/' 38 | assert last_response.ok? 39 | assert_match %r{patch}, last_response.body 40 | end 41 | 42 | def test_ignore_post_to_get 43 | header 'x-http-method-override', 'get'.dup 44 | post '/' 45 | assert last_response.ok? 46 | assert_match %r{post}, last_response.body 47 | end 48 | 49 | def test_ignore_get_to_patch 50 | header 'x-http-method-override', 'patch'.dup 51 | get '/' 52 | assert last_response.ok? 53 | assert_match %r{get}, last_response.body 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/utility_classes/roles_config_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | 4 | require 'sinja/config' 5 | 6 | class TestRolesConfig < Minitest::Test 7 | def setup 8 | @config = Sinja::RolesConfig.new([:create]) 9 | end 10 | 11 | def test_it_inits_and_delegates 12 | assert_kind_of Sinja::Roles, @config[:create] 13 | assert_empty @config[:create] 14 | assert_nil @config[:unknown] 15 | end 16 | end 17 | 18 | class TestRolesConfig1 < Minitest::Test 19 | def setup 20 | @config = Sinja::RolesConfig.new([:create]) 21 | @config.merge!(:create=>:user) 22 | @config.merge!(:create=>:admin) 23 | end 24 | 25 | def test_it_inits_and_delegates 26 | assert_kind_of Sinja::Roles, @config[:create] 27 | assert_equal [:admin], @config[:create].to_a 28 | end 29 | 30 | def test_it_whitelists_keys 31 | assert_raises SystemExit do 32 | capture_io { @config.merge!(:ignore_me=>:admin) } 33 | end 34 | end 35 | 36 | def test_it_copies_deeply 37 | @other = @config.dup 38 | assert_equal @config[:create], @other[:create] 39 | refute_same @config[:create], @other[:create] 40 | end 41 | end 42 | 43 | class TestRolesConfig2 < Minitest::Test 44 | def setup 45 | @config = Sinja::RolesConfig.new([:create]).freeze 46 | end 47 | 48 | def test_it_freezes_deeply 49 | assert_raises(RuntimeError) { @config.merge!(:create=>:admin) } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/sinja/helpers/relationships.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'json' 3 | 4 | module Sinja 5 | module Helpers 6 | module Relationships 7 | def dispatch_relationship_request(id, path, **opts) 8 | path_info = request.path_info.dup 9 | path_info << "/#{id}" unless path_info.end_with?("/#{id}") 10 | path_info << "/relationships/#{path}" 11 | path_info.freeze 12 | 13 | fakenv = env.merge 'PATH_INFO'=>path_info 14 | fakenv['REQUEST_METHOD'] = opts[:method].to_s.tap(&:upcase!) if opts[:method] 15 | fakenv['rack.input'] = StringIO.new(JSON.fast_generate(opts[:body])) if opts.key?(:body) 16 | fakenv['sinja.passthru'] = opts.fetch(:from, :unknown).to_s 17 | fakenv['sinja.resource'] = resource if resource 18 | 19 | call(fakenv) 20 | end 21 | 22 | def dispatch_relationship_requests!(id, methods: {}, **opts) 23 | rels = data.fetch(:relationships, {}).to_a 24 | rels.each do |rel, body, rel_type=nil, count=0| 25 | rel_type ||= settings._resource_config[:has_one].key?(rel) ? :has_one : :has_many 26 | code, _, *json = dispatch_relationship_request id, rel, 27 | opts.merge(:body=>body, :method=>methods.fetch(rel_type, :patch)) 28 | 29 | if code == DEFER_CODE && count == 0 30 | rels << [rel, body, rel_type, count + 1] 31 | 32 | next 33 | end 34 | 35 | # TODO: Gather responses and report all errors instead of only first? 36 | # `halt' was called (instead of raise); rethrow it as best as possible 37 | raise SideloadError.new(code, json) unless (200...300).cover?(code) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/sinja/relationship_routes/has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Sinja 3 | module RelationshipRoutes 4 | module HasMany 5 | def self.registered(app) 6 | app.def_action_helper(app, :fetch, %i[roles filter_by sort_by]) 7 | app.def_action_helper(app, :clear, %i[roles sideload_on]) 8 | app.def_action_helper(app, :replace, %i[roles sideload_on]) 9 | app.def_action_helper(app, :merge, %i[roles sideload_on]) 10 | app.def_action_helper(app, :subtract, :roles) 11 | 12 | app.options '' do 13 | unless relationship_link? 14 | allow :get=>:fetch 15 | else 16 | allow :get=>:show, :patch=>[:clear, :replace], :post=>:merge, :delete=>:subtract 17 | end 18 | end 19 | 20 | app.get '', :on=>proc { relationship_link? }, :actions=>:show do 21 | serialize_linkage 22 | end 23 | 24 | app.get '', :qparams=>%i[include fields filter sort page], :actions=>:fetch do 25 | fsp_opts = filter_sort_page?(:fetch) 26 | collection, opts = fetch 27 | collection, pagination = filter_sort_page(collection, fsp_opts.to_h) 28 | serialize_models(collection, opts, pagination) 29 | end 30 | 31 | app.patch '', :on=>proc { data.empty? }, :actions=>:clear do 32 | serialize_linkages?(*clear) 33 | end 34 | 35 | app.patch '', :actions=>:replace do 36 | serialize_linkages?(*replace(data)) 37 | end 38 | 39 | app.post '', :actions=>:merge do 40 | serialize_linkages?(*merge(data)) 41 | end 42 | 43 | app.delete '', :actions=>:subtract do 44 | serialize_linkages?(*subtract(data)) 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | ENV['RACK_ENV'] = ENV['APP_ENV'] = 'test' 3 | 4 | require 'bundler/setup' 5 | require 'minitest/autorun' 6 | require 'rack/test' 7 | 8 | # TODO: Don't do this. 9 | Bundler.require :default 10 | Bundler.require Sinatra::Base.environment 11 | Bundler.require :development if Sinatra::Base.test? 12 | 13 | module MyAppTest 14 | def setup 15 | header 'Accept', Sinja::MIME_TYPE 16 | header 'Content-Type', Sinja::MIME_TYPE 17 | end 18 | 19 | def register(email, real_name, display_name=nil) 20 | attr = { 21 | :email=>email, 22 | 'real-name'=>real_name 23 | }.tap do |h| 24 | h[:'display-name'] = display_name if display_name 25 | end 26 | 27 | post '/authors', JSON.generate(:data=>{ :type=>'authors', :attributes=>attr }) 28 | json.dig(:data, :id) 29 | end 30 | 31 | def login(email) 32 | header 'X-Email', email 33 | end 34 | 35 | def json 36 | @json ||= {} 37 | @json[last_request.request_method] ||= {} 38 | @json[last_request.request_method][last_request.url] ||= 39 | if last_response.body.size > 0 40 | JSON.parse(last_response.body, :symbolize_names=>true) 41 | else 42 | {} 43 | end 44 | end 45 | 46 | def assert_ok 47 | assert last_response.successful? 48 | assert_equal Sinja::MIME_TYPE, last_response.content_type 49 | unless last_request.options? 50 | assert_equal({ :version=>'1.0' }, json[:jsonapi]) 51 | end 52 | end 53 | 54 | def assert_error(status, re=nil) 55 | assert_equal status, last_response.status 56 | assert_equal Sinja::MIME_TYPE, last_response.content_type 57 | unless last_request.head? 58 | assert_kind_of Array, json[:errors] 59 | refute_empty json[:errors] 60 | assert_equal status, json[:errors].first[:status].to_i 61 | assert_match re, json[:errors].first[:detail] if re 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /demo-app/classes/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'base' 3 | require_relative 'post' # make sure we create the posts table before the join table 4 | 5 | DB.create_table?(:tags) do 6 | primary_key :id 7 | String :name, null: false, unique: true 8 | String :description 9 | end 10 | 11 | DB.create_table?(:posts_tags) do 12 | foreign_key :post_slug, :posts, type: String, null: false, on_delete: :cascade, on_update: :cascade 13 | foreign_key :tag_id, :tags, null: false, on_delete: :cascade 14 | primary_key [:post_slug, :tag_id] 15 | index [:tag_id, :post_slug] 16 | end 17 | 18 | class Tag < Sequel::Model 19 | plugin :auto_validations, not_null: :presence 20 | 21 | set_allowed_columns :name, :description 22 | 23 | many_to_many :posts, right_key: :post_slug 24 | end 25 | 26 | class TagSerializer < BaseSerializer 27 | attributes :name, :description 28 | 29 | has_many :posts 30 | end 31 | 32 | TagController = proc do 33 | helpers do 34 | def find(id) 35 | Tag.with_pk(id.to_i) 36 | end 37 | end 38 | 39 | show 40 | 41 | index(sort_by: :name, filter_by: [:name, :description]) do 42 | Tag.dataset 43 | end 44 | 45 | create(roles: :logged_in) do |attr| 46 | tag = Tag.new(attr) 47 | tag.save(validate: false) 48 | next_pk tag 49 | end 50 | 51 | destroy(roles: :superuser) do 52 | resource.destroy 53 | end 54 | 55 | has_many :posts do 56 | fetch do 57 | resource.posts_dataset 58 | end 59 | 60 | replace(roles: :logged_in) do |rios| 61 | add_remove(:posts, rios, :to_s) do |post| 62 | role?(:superuser) || post.author == current_user 63 | end 64 | end 65 | 66 | merge(roles: :logged_in) do |rios| 67 | add_missing(:posts, rios, :to_s) do |post| 68 | role?(:superuser) || post.author == current_user 69 | end 70 | end 71 | 72 | subtract(roles: :logged_in) do |rios| 73 | remove_present(:posts, rios, :to_s) do |post| 74 | role?(:superuser) || post.author == current_user 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/sinja/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'json' 3 | 4 | module Sinja 5 | class SinjaError < StandardError 6 | end 7 | 8 | class ActionHelperError < SinjaError 9 | end 10 | 11 | class HttpError < SinjaError 12 | attr_reader :http_status 13 | 14 | def initialize(http_status, message=nil) 15 | @http_status = http_status 16 | super(message) 17 | end 18 | end 19 | 20 | class SideloadError < HttpError 21 | attr_reader :error_hashes 22 | 23 | def initialize(http_status, json) 24 | @error_hashes = JSON.parse(json, :symbolize_names=>true).fetch(:errors) 25 | super(http_status) 26 | end 27 | end 28 | 29 | class BadRequestError < HttpError 30 | HTTP_STATUS = 400 31 | 32 | def initialize(*args) super(HTTP_STATUS, *args) end 33 | end 34 | 35 | class ForbiddenError < HttpError 36 | HTTP_STATUS = 403 37 | 38 | def initialize(*args) super(HTTP_STATUS, *args) end 39 | end 40 | 41 | class NotFoundError < HttpError 42 | HTTP_STATUS = 404 43 | 44 | def initialize(*args) super(HTTP_STATUS, *args) end 45 | end 46 | 47 | class MethodNotAllowedError < HttpError 48 | HTTP_STATUS = 405 49 | 50 | def initialize(*args) super(HTTP_STATUS, *args) end 51 | end 52 | 53 | class NotAcceptableError < HttpError 54 | HTTP_STATUS = 406 55 | 56 | def initialize(*args) super(HTTP_STATUS, *args) end 57 | end 58 | 59 | class ConflictError < HttpError 60 | HTTP_STATUS = 409 61 | 62 | def initialize(*args) super(HTTP_STATUS, *args) end 63 | end 64 | 65 | class UnsupportedTypeError < HttpError 66 | HTTP_STATUS = 415 67 | 68 | def initialize(*args) super(HTTP_STATUS, *args) end 69 | end 70 | 71 | class UnprocessibleEntityError < HttpError 72 | HTTP_STATUS = 422 73 | 74 | attr_reader :tuples 75 | 76 | def initialize(tuples=[]) 77 | @tuples = Array(tuples) 78 | 79 | fail 'Tuples not properly formatted' \ 80 | unless @tuples.any? && @tuples.all? { |t| t.instance_of?(Array) && t.length.between?(2, 3) } 81 | 82 | super(HTTP_STATUS) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /demo-app/classes/author.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'base' 3 | 4 | DB.create_table?(:authors) do 5 | primary_key :id 6 | String :email, null: false, unique: true 7 | String :real_name 8 | String :display_name 9 | TrueClass :admin, default: false 10 | Float :created_at 11 | Float :updated_at 12 | end 13 | 14 | class Author < Sequel::Model 15 | plugin :auto_validations, not_null: :presence 16 | plugin :boolean_readers 17 | plugin :finder 18 | plugin :timestamps 19 | 20 | set_allowed_columns :email, :real_name, :display_name, :admin 21 | 22 | finder def self.by_email(arg) 23 | where(email: arg) 24 | end 25 | 26 | one_to_many :comments 27 | one_to_many :posts 28 | end 29 | 30 | # We have to create an admin user here, otherwise we have no way to create one. 31 | Author.create(email: 'all@yourbase.com', admin: true) if Author.where(admin: true).empty? 32 | 33 | class AuthorSerializer < BaseSerializer 34 | attribute(:display_name) { object.display_name || 'Anonymous Coward' } 35 | 36 | has_many :comments 37 | has_many :posts 38 | end 39 | 40 | AuthorController = proc do 41 | helpers do 42 | def before_create(attr) 43 | halt 403, 'Only admins can admin admins' if attr.key?(:admin) && !role?(:superuser) 44 | end 45 | 46 | alias before_update before_create 47 | 48 | def find(id) 49 | Author.with_pk(id.to_i) 50 | end 51 | 52 | def role 53 | Array(super).tap do |a| 54 | a << :self if resource == current_user 55 | end 56 | end 57 | end 58 | 59 | show 60 | 61 | show_many do |ids| 62 | Author.where_all(id: ids.map!(&:to_i)) 63 | end 64 | 65 | index do 66 | Author.dataset 67 | end 68 | 69 | create do |attr| 70 | author = Author.new(attr) 71 | author.save(validate: false) 72 | next_pk author 73 | end 74 | 75 | update(roles: %i[self superuser]) do |attr| 76 | resource.set(attr) 77 | resource.save_changes(validate: false) 78 | end 79 | 80 | destroy(roles: %i[self superuser]) do 81 | resource.destroy 82 | end 83 | 84 | has_many :comments do 85 | fetch(roles: :logged_in) do 86 | resource.comments_dataset 87 | end 88 | end 89 | 90 | has_many :posts do 91 | fetch do 92 | resource.posts_dataset 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /demo-app/classes/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'base' 3 | 4 | DB.create_table?(:comments) do 5 | primary_key :id 6 | foreign_key :author_id, :authors, index: true, on_delete: :cascade 7 | foreign_key :post_slug, :posts, type: String, index: true, on_delete: :cascade, on_update: :cascade 8 | String :body, text: true, null: false 9 | Float :created_at 10 | Float :updated_at 11 | end 12 | 13 | class Comment < Sequel::Model 14 | plugin :auto_validations, not_null: :presence 15 | plugin :timestamps 16 | 17 | set_allowed_columns :body 18 | 19 | many_to_one :author 20 | many_to_one :post 21 | 22 | def validate 23 | super 24 | validates_not_null [:author, :post] 25 | end 26 | end 27 | 28 | class CommentSerializer < BaseSerializer 29 | attribute :body 30 | 31 | has_one :author 32 | has_one :post 33 | end 34 | 35 | CommentController = proc do 36 | helpers do 37 | def find(id) 38 | Comment.with_pk(id.to_i) 39 | end 40 | 41 | def role 42 | Array(super).tap do |a| 43 | a << :owner if resource&.author == current_user 44 | end 45 | end 46 | end 47 | 48 | show do 49 | next resource, include: 'author' 50 | end 51 | 52 | create(roles: :logged_in) do |attr| 53 | comment = Comment.new(attr) 54 | comment.save(validate: false) 55 | next_pk comment 56 | end 57 | 58 | update(roles: %i[owner superuser]) do |attr| 59 | resource.set(attr) 60 | resource.save_changes(validate: false) 61 | end 62 | 63 | destroy(roles: %i[owner superuser]) do 64 | resource.destroy 65 | end 66 | 67 | has_one :post do 68 | pluck do 69 | resource.post 70 | end 71 | 72 | graft(roles: :superuser, sideload_on: :create) do |rio| 73 | resource.post = Post.with_pk!(rio[:id].to_i) 74 | resource.save_changes(validate: !sideloaded?) 75 | end 76 | end 77 | 78 | has_one :author do 79 | pluck do 80 | resource.author 81 | end 82 | 83 | graft(roles: :superuser, sideload_on: :create) do |rio| 84 | halt 403, 'You may only assign yourself as comment author!' \ 85 | unless role?(:superuser) || rio[:id].to_i == current_user.id 86 | 87 | resource.author = Author.with_pk!(rio[:id].to_i) 88 | resource.save_changes(validate: !sideloaded?) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /sinja.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'sinja/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'sinja' 8 | spec.version = Sinja::VERSION 9 | spec.authors = ['Mike Pastore'] 10 | spec.email = ['mike@oobak.org'] 11 | 12 | spec.summary = 'RESTful, {json:api}-compliant web services in Sinatra' 13 | spec.description = <<~'EOF' 14 | Sinja is a Sinatra extension for quickly building RESTful, 15 | {json:api}-compliant web services, leveraging the excellent 16 | JSONAPI::Serializers gem for payload serialization. It enhances Sinatra's 17 | DSL to enable resource-, relationship-, and role-centric API development, 18 | and it configures Sinatra with the proper settings, MIME-types, filters, 19 | conditions, and error-handling. 20 | 21 | There are many parsing (deserializing), rendering (serializing), and other 22 | "JSON API" libraries available for Ruby, but relatively few that attempt to 23 | correctly implement the entire {json:api} server specification, including 24 | routing, request header and query parameter checking, and relationship 25 | side-loading. Sinja lets you focus on the business logic of your 26 | applications without worrying about the specification, and without pulling 27 | in a heavy framework like Rails. It's lightweight, ORM-agnostic, and 28 | Ember.js-friendly! 29 | EOF 30 | spec.homepage = 'http://sinja-rb.org' 31 | spec.license = 'MIT' 32 | 33 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 34 | f.match(%r{^(test|spec|features)/}) 35 | end 36 | spec.require_paths = %w[lib] 37 | 38 | spec.required_ruby_version = '>= 2.3.0' 39 | 40 | spec.add_dependency 'activesupport', '>= 4.2.8', '< 6' 41 | spec.add_dependency 'json', '>= 1.8.3', '< 3' 42 | spec.add_dependency 'jsonapi-serializers', '>= 0.16.2', '< 2' 43 | spec.add_dependency 'sinatra', '~> 2.0' 44 | spec.add_dependency 'sinatra-contrib', '~> 2.0' 45 | 46 | spec.add_development_dependency 'bundler', '~> 1.11' 47 | spec.add_development_dependency 'jdbc-sqlite3', '~> 3.8' if defined?(JRUBY_VERSION) 48 | spec.add_development_dependency 'minitest', '~> 5.9' 49 | spec.add_development_dependency 'minitest-hooks', '~> 1.4' 50 | #spec.add_development_dependency 'munson', '~> 0.4' # in Gemfile 51 | spec.add_development_dependency 'rack-test', '~> 0.7.0' 52 | spec.add_development_dependency 'rake', '~> 12.0' 53 | spec.add_development_dependency 'sequel', '>= 4.49', '< 6' 54 | #spec.add_development_dependency 'sinja-sequel', '~> 0.1' # in Gemfile 55 | spec.add_development_dependency 'sqlite3', '~> 1.3' if !defined?(JRUBY_VERSION) 56 | end 57 | -------------------------------------------------------------------------------- /demo-app/classes/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'base' 3 | 4 | DB.create_table?(:posts) do 5 | String :slug, primary_key: true 6 | foreign_key :author_id, :authors, index: true, on_delete: :cascade 7 | String :title, null: false 8 | String :body, text: true, null: false 9 | Float :created_at 10 | Float :updated_at 11 | end 12 | 13 | class Post < Sequel::Model 14 | plugin :auto_validations, not_null: :presence 15 | plugin :timestamps 16 | plugin :update_primary_key 17 | 18 | set_allowed_columns :slug, :title, :body 19 | 20 | unrestrict_primary_key # allow client-generated slugs 21 | 22 | # jdbc-sqlite3 reports unexpected record counts with cascading updates, which 23 | # breaks Sequel (https://github.com/jeremyevans/sequel/issues/1275) 24 | self.require_modification = !defined?(JRUBY_VERSION) 25 | 26 | many_to_one :author 27 | one_to_many :comments 28 | many_to_many :tags, left_key: :post_slug 29 | 30 | def validate 31 | super 32 | validates_not_null :author 33 | end 34 | end 35 | 36 | class PostSerializer < BaseSerializer 37 | def id 38 | object.slug 39 | end 40 | 41 | attributes :title, :body 42 | 43 | has_one :author 44 | has_many :comments 45 | has_many :tags 46 | end 47 | 48 | PostController = proc do 49 | helpers do 50 | def find(slug) 51 | Post.with_pk(slug.to_s) 52 | end 53 | 54 | def role 55 | Array(super).tap do |a| 56 | a << :owner if resource&.author == current_user 57 | end 58 | end 59 | end 60 | 61 | show do 62 | next resource, include: %w[author comments tags] 63 | end 64 | 65 | show_many do |slugs| 66 | next Post.where_all(slug: slugs.map!(&:to_s)), include: %i[author tags] 67 | end 68 | 69 | index do 70 | Post.dataset 71 | end 72 | 73 | create(roles: :logged_in) do |attr, slug| 74 | attr[:slug] = slug 75 | 76 | post = Post.new(attr) 77 | post.save(validate: false) 78 | next_pk post 79 | end 80 | 81 | update(roles: %i[owner superuser]) do |attr| 82 | resource.set(attr) 83 | resource.save_changes(validate: false) 84 | end 85 | 86 | destroy(roles: %i[owner superuser]) do 87 | resource.destroy 88 | end 89 | 90 | has_one :author do 91 | pluck do 92 | resource.author 93 | end 94 | 95 | graft(roles: :superuser, sideload_on: :create) do |rio| 96 | halt 403, 'You may only assign yourself as post author!' \ 97 | unless role?(:superuser) || rio[:id].to_i == current_user.id 98 | 99 | resource.author = Author.with_pk!(rio[:id].to_i) 100 | resource.save_changes(validate: !sideloaded?) 101 | end 102 | end 103 | 104 | has_many :comments do 105 | fetch do 106 | next resource.comments_dataset, include: 'author' 107 | end 108 | end 109 | 110 | has_many :tags do 111 | fetch do 112 | resource.tags_dataset 113 | end 114 | 115 | clear(roles: %i[owner superuser], sideload_on: :update) do 116 | resource.remove_all_tags 117 | end 118 | 119 | replace(roles: %i[owner superuser], sideload_on: :update) do |rios| 120 | add_remove(:tags, rios) 121 | end 122 | 123 | merge(roles: %i[owner superuser], sideload_on: :create) do |rios| 124 | add_missing(:tags, rios) 125 | end 126 | 127 | subtract(roles: %i[owner superuser]) do |rios| 128 | remove_present(:tags, rios) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/helpers/core_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | 4 | class HelpersApp < MyAppBase 5 | before do 6 | env['sinja.passthru'] = true # let exceptions propagate 7 | end 8 | 9 | configure_jsonapi do |c| 10 | %i[code body roles].each do |sym| 11 | c.query_params[sym] = nil 12 | end 13 | end 14 | 15 | post '/attributes' do 16 | attributes 17 | end 18 | 19 | post '/content' do 20 | halt(content? ? 200 : 204) 21 | end 22 | 23 | post '/data' do 24 | data 25 | end 26 | 27 | get '/halt' do 28 | halt params[:code].to_i, params[:body] 29 | end 30 | 31 | get '/normalize_params' do 32 | params 33 | end 34 | 35 | get('/role') {{ :role=>role }} 36 | 37 | get('/role_q') {{ :role_q=>role?(params[:roles].split(',')) }} 38 | 39 | get('/sideloaded') {{ :sideloaded=>sideloaded? }} 40 | 41 | get('/transaction') {{ :yielded=>transaction { 11 } }} 42 | end 43 | 44 | class TestHelpers < Minitest::Test 45 | include MyAppTest 46 | include Rack::Test::Methods 47 | 48 | def app 49 | HelpersApp.new 50 | end 51 | 52 | def test_allow 53 | pass 'tested in integration' 54 | end 55 | 56 | def test_attributes 57 | post '/attributes', JSON.generate(:data=>{ :attributes=>{ :foo=>'bar' } }) 58 | assert last_response.ok? 59 | assert_equal({ :foo=>'bar' }, json) 60 | end 61 | 62 | def test_can 63 | pass 'tested in integration' 64 | end 65 | 66 | def test_content 67 | post '/content', JSON.generate(true) 68 | assert_equal 200, last_response.status 69 | end 70 | 71 | def test_no_content 72 | post '/content' 73 | assert_equal 204, last_response.status 74 | end 75 | 76 | def test_data 77 | post '/data', JSON.generate(:data=>{ :foo=>'bar' }) 78 | assert last_response.ok? 79 | assert_equal({ :foo=>'bar' }, json) 80 | end 81 | 82 | def test_halt 83 | e = assert_raises(Sinja::HttpError) do 84 | get '/halt', :code=>418, :body=>"I'm a teapot" 85 | end 86 | assert_equal 418, e.http_status 87 | assert_equal "I'm a teapot", e.message 88 | end 89 | 90 | def test_halt_400 91 | assert_raises(Sinja::BadRequestError) do 92 | get '/halt', :code=>400 93 | end 94 | end 95 | 96 | def test_not_found 97 | assert_raises(Sinja::NotFoundError) do 98 | get '/halt', :code=>404 99 | end 100 | end 101 | 102 | def test_normalize_params 103 | get '/normalize_params' 104 | assert last_response.ok? 105 | assert_kind_of Hash, json.delete(:filter) 106 | assert_kind_of Hash, json.delete(:fields) 107 | assert_kind_of Hash, json.delete(:page) 108 | assert_kind_of Array, json.delete(:include) 109 | assert_kind_of Hash, json.delete(:sort) 110 | json.delete(:captures) # Sinatra 2.0 adds this to every request 111 | assert_empty json 112 | end 113 | 114 | def test_role 115 | get '/role' 116 | assert last_response.ok? 117 | assert_nil json[:role] 118 | end 119 | 120 | def test_role_q 121 | get '/role_q', :roles=>'any' 122 | assert last_response.ok? 123 | refute json[:role_q] 124 | end 125 | 126 | def test_sanity_check 127 | pass 'tested in integration' 128 | end 129 | 130 | def test_sideload 131 | pass 'tested in integration' 132 | end 133 | 134 | def test_sideloaded 135 | get '/sideloaded' 136 | assert last_response.ok? 137 | assert_equal true, json[:sideloaded] 138 | end 139 | 140 | def test_transaction 141 | get '/transaction' 142 | assert last_response.ok? 143 | assert_equal 11, json[:yielded] 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /demo-app/README.md: -------------------------------------------------------------------------------- 1 | ## Demo App 2 | 3 | This is the demo app for Sinja, provided both as an example of and for testing 4 | Sinja. It uses [Sequel ORM](http://sequel.jeremyevans.net) with an in-memory 5 | SQLite database and demonstrates the [Sequel 6 | extension](https://github.com/mwpastore/sinja-sequel) for Sinja. It works under 7 | both MRI/YARV 2.3+ and JRuby 9.1+. It is a very simplistic blog-like 8 | application with [database 9 | tables](http://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html), 10 | [models](http://sequel.jeremyevans.net/rdoc/files/README_rdoc.html#label-Sequel+Models), 11 | [serializers](https://github.com/fotinakis/jsonapi-serializers), and 12 | [controllers](/) for [authors](/demo-app/classes/author.rb), 13 | [posts](/demo-app/classes/post.rb), [comments](/demo-app/classes/comment.rb), 14 | and [tags](/demo-app/classes/tag.rb). 15 | 16 | ### Usage 17 | 18 | Assuming you have a working, [Bundler](http://bundler.io)-enabled Ruby 19 | environment, simply clone this repo, `cd` into the `demo-app` subdirectory, and 20 | run the following commands: 21 | 22 | ``` 23 | $ bundle install 24 | $ bundle exec ruby app.rb [-p ] 25 | ``` 26 | 27 | The web server will report the port it's listening on (most likely 4567), or 28 | you can specify a port with the `-p` option. 29 | 30 | Alternatively, if you don't want to clone this repo and set up a Ruby 31 | environment just for a quick demo, it's available on Docker Cloud as 32 | [mwpastore/sinja-demo-app](https://cloud.docker.com/app/mwpastore/repository/docker/mwpastore/sinja-demo-app): 33 | 34 | ``` 35 | $ docker run -it -p 4567:4567 --rm mwpastore/sinja-demo-app 36 | ``` 37 | 38 | It will respond to {json:api}-compliant requests (don't forget to set an 39 | `Accept` header) to `/authors`, `/posts`, `/comments`, and `/tags`, although 40 | not every endpoint is implemented. Log in by setting the `X-Email` header on 41 | the request to the email address of a registered user; the email address for 42 | the default admin user is all@yourbase.com. **This is clearly extremely 43 | insecure and should not be used as-is in production. Caveat emptor.** 44 | 45 | You can point it at a different database by setting `DATABASE_URL` in the 46 | environment before executing `app.rb`. See the relevant [Sequel 47 | documentation](http://sequel.jeremyevans.net/rdoc/files/doc/opening_databases_rdoc.html) 48 | for more information. It (rather naïvely) migrates the database and 49 | creates the default admin user at startup. 50 | 51 | ### Productionalizing 52 | 53 | You can certainly use this as a starting point for a production application, 54 | but you will at least want to: 55 | 56 | - [ ] Use a persistent database 57 | - [ ] Remove or change the default admin user 58 | - [ ] Separate the class files (e.g. `author.rb`, `post.rb`) into separate 59 | files for migrations, models, serializers, and Sinja controllers 60 | - [ ] Create a Gemfile using the dependencies in the top-level 61 | [gemspec](/sinja.gemspec) as a starting point 62 | - [ ] Add authentication and rewrite the `role` helper to enable the 63 | authorization scheme. You can use the existing roles as defined or rename 64 | them (e.g. use `:admin` instead of `:superuser`) 65 | - [ ] Use a real application server such as [Puma](http://puma.io) or 66 | [Passenger](https://www.phusionpassenger.com) instead of Ruby's 67 | stdlib (WEBrick) 68 | - [ ] Configure Sequel's connection pool (i.e. `:max_connections`) to match the 69 | application server's thread pool (if any) size, e.g. 70 | `Puma.cli_config.options[:max_threads]` 71 | - [ ] Add caching directives (i.e. `cache_control`, `expires`, `last_modified`, 72 | and `etag`) as appropriate 73 | 74 | And probably a whole lot more! 75 | -------------------------------------------------------------------------------- /lib/sinja/resource_routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Sinja 3 | module ResourceRoutes 4 | def self.registered(app) 5 | app.def_action_helper(app, :show, :roles) 6 | app.def_action_helper(app, :show_many) 7 | app.def_action_helper(app, :index, %i[roles filter_by sort_by]) 8 | app.def_action_helper(app, :create, :roles) 9 | app.def_action_helper(app, :update, :roles) 10 | app.def_action_helper(app, :destroy, :roles) 11 | 12 | app.options '', :qcaptures=>{ :filter=>:id } do 13 | allow :get=>:show 14 | end 15 | 16 | app.get '', :qcaptures=>{ :filter=>:id }, :qparams=>%i[include fields], :actions=>:show do 17 | ids = @qcaptures.first # TODO: Get this as a block parameter? 18 | ids = ids.split(',') if ids.instance_of?(String) 19 | ids = Array(ids).tap(&:uniq!) 20 | 21 | collection, opts = 22 | if respond_to?(:show_many) 23 | show_many(ids) 24 | else 25 | finder = 26 | if respond_to?(:find) 27 | method(:find) 28 | else 29 | proc { |id| show(id).first } 30 | end 31 | 32 | [ids.map!(&finder).tap(&:compact!), {}] 33 | end 34 | 35 | raise NotFoundError, "Resource(s) not found" \ 36 | unless ids.length == collection.length 37 | 38 | serialize_models(collection, opts) 39 | end 40 | 41 | app.options '' do 42 | allow :get=>:index, :post=>:create 43 | end 44 | 45 | app.get '', :qparams=>%i[include fields filter sort page], :actions=>:index do 46 | fsp_opts = filter_sort_page?(:index) 47 | collection, opts = index 48 | collection, pagination = filter_sort_page(collection, fsp_opts.to_h) 49 | serialize_models(collection, opts, pagination) 50 | end 51 | 52 | app.post '', :qparams=>%i[include fields], :actions=>:create do 53 | sanity_check! 54 | 55 | opts = {} 56 | transaction do 57 | id, self.resource, opts = 58 | begin 59 | create(*[attributes].tap { |a| a << data[:id] if data.key?(:id) }) 60 | rescue ArgumentError 61 | kind = data.key?(:id) ? 'supported' : 'provided' 62 | 63 | raise ForbiddenError, "Client-generated ID not #{kind}" 64 | end 65 | 66 | dispatch_relationship_requests!(id, :from=>:create, :methods=>{ :has_many=>:post }) 67 | validate! if respond_to?(:validate!) 68 | end 69 | 70 | if resource 71 | content = serialize_model(resource, opts) 72 | if content.respond_to?(:dig) && self_link = content.dig(*%w[data links self]) 73 | headers 'Location'=>self_link 74 | end 75 | [201, content] 76 | elsif data.key?(:id) 77 | 204 78 | else 79 | raise ActionHelperError, "Unexpected return value(s) from `create' action helper" 80 | end 81 | end 82 | 83 | pkre = app._resource_config[:route_opts][:pkre] 84 | 85 | app.options %r{/#{pkre}} do 86 | allow :get=>:show, :patch=>:update, :delete=>:destroy 87 | end 88 | 89 | app.get %r{/(#{pkre})}, :qparams=>%i[include fields], :actions=>:show do |id| 90 | tmp, opts = show(*[].tap { |a| a << id unless respond_to?(:find) }) 91 | raise NotFoundError, "Resource '#{id}' not found" unless tmp 92 | serialize_model(tmp, opts) 93 | end 94 | 95 | app.patch %r{/(#{pkre})}, :qparams=>%i[include fields], :actions=>:update do |id| 96 | sanity_check!(id) 97 | tmp, opts = transaction do 98 | update(attributes).tap do 99 | dispatch_relationship_requests!(id, :from=>:update) 100 | validate! if respond_to?(:validate!) 101 | end 102 | end 103 | serialize_model?(tmp, opts) 104 | end 105 | 106 | app.delete %r{/#{pkre}}, :actions=>:destroy do 107 | _, opts = destroy 108 | serialize_model?(nil, opts) 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/sinja/resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'set' 3 | 4 | require 'active_support/inflector' 5 | require 'sinatra/base' 6 | require 'sinatra/namespace' 7 | 8 | require 'sinja/helpers/nested' 9 | require 'sinja/helpers/relationships' 10 | require 'sinja/relationship_routes/has_many' 11 | require 'sinja/relationship_routes/has_one' 12 | require 'sinja/resource_routes' 13 | 14 | module Sinja 15 | module Resource 16 | ARITIES = { 17 | :create=>2, 18 | :index=>-1, 19 | :fetch=>-1, 20 | :show_many=>-1 21 | }.tap { |h| h.default = 1 }.freeze 22 | 23 | def self.registered(app) 24 | app.helpers Helpers::Relationships do 25 | attr_accessor :resource 26 | end 27 | 28 | app.register ResourceRoutes 29 | end 30 | 31 | def def_action_helper(context, action, allow_opts=[]) 32 | abort "Action helper names can't overlap with Sinatra DSL" \ 33 | if Sinatra::Base.respond_to?(action) 34 | 35 | context.define_singleton_method(action) do |**opts, &block| 36 | abort "Unexpected option(s) for `#{action}' action helper" \ 37 | unless (opts.keys - Array(allow_opts)).empty? 38 | 39 | resource_config[action].each do |k, v| 40 | v.replace(Array(opts[k])) if opts.key?(k) 41 | end 42 | 43 | return unless block ||= 44 | case !method_defined?(action) && action 45 | when :show 46 | proc { resource } if method_defined?(:find) 47 | end 48 | 49 | required_arity = ARITIES[action] 50 | 51 | define_method(action) do |*args| 52 | raise ArgumentError, "Unexpected argument(s) for `#{action}' action helper" \ 53 | unless args.length == block.arity 54 | 55 | public_send("before_#{action}", *args.take(method("before_#{action}").arity.abs)) \ 56 | if respond_to?("before_#{action}") 57 | 58 | case result = instance_exec(*args, &block) 59 | when Array 60 | opts = {} 61 | if result.last.instance_of?(Hash) 62 | opts = result.pop 63 | elsif required_arity < 0 && !result.first.is_a?(Array) 64 | result = [result] 65 | end 66 | 67 | raise ActionHelperError, "Unexpected return value(s) from `#{action}' action helper" \ 68 | unless result.length == required_arity.abs 69 | 70 | result.push(opts) 71 | when Hash 72 | Array.new(required_arity.abs).push(result) 73 | else 74 | [result, nil].take(required_arity.abs).push({}) 75 | end 76 | end 77 | 78 | define_singleton_method("remove_#{action}") do 79 | remove_method(action) if respond_to?(action) 80 | end 81 | end 82 | end 83 | 84 | %i[has_one has_many].each do |rel_type| 85 | define_method(rel_type) do |rel, &block| 86 | rel = rel.to_s 87 | .send(rel_type == :has_one ? :singularize : :pluralize) 88 | .dasherize 89 | .to_sym 90 | 91 | config = _resource_config[rel_type][rel] # trigger default proc 92 | 93 | namespace %r{/#{_resource_config[:route_opts][:pkre]}(?/relationships)?/#{rel}(?![^/])} do 94 | define_singleton_method(:resource_config) { config } 95 | 96 | helpers Helpers::Nested do 97 | define_method(:can?) do |action, *args| 98 | parent = sideloaded? && env['sinja.passthru'].to_sym 99 | 100 | roles, sideload_on = config.fetch(action, {}).values_at(:roles, :sideload_on) 101 | roles.nil? || roles.empty? || roles.intersect?(role) || 102 | parent && sideload_on.include?(parent) && super(parent, *args) 103 | end 104 | 105 | define_method(:serialize_linkage) do |*args| 106 | super(resource, rel, *args) 107 | end 108 | end 109 | 110 | register RelationshipRoutes.const_get(rel_type.to_s.camelize) 111 | 112 | instance_eval(&block) if block 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/integration/tag_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | require_relative '../../demo-app/app' 4 | 5 | class TagTest < SequelTest 6 | include MyAppTest 7 | include Rack::Test::Methods 8 | 9 | def app 10 | Sinatra::Application.new 11 | end 12 | 13 | def test_uncoalesced_find_options 14 | options '/tags' 15 | assert_ok 16 | assert_equal 'GET,POST', last_response.headers['Allow'] 17 | end 18 | 19 | def test_uncoalesced_find 20 | DB[:tags].multi_insert [ 21 | { :name=>'teapots' }, 22 | { :name=>'sassafrass' }, 23 | { :name=>'whimsy' }, 24 | { :name=>'horseshoe' } 25 | ] 26 | get '/tags' 27 | assert_ok 28 | vals = json[:data].map { |t| { :id=>t[:id].to_i, :name=>t[:attributes][:name] } } 29 | assert_equal DB[:tags].select(:id, :name).order(:id).all, vals 30 | end 31 | 32 | def test_coalesced_find_options 33 | options '/tags?filter[id]=1,2' 34 | assert_ok 35 | assert_equal 'GET', last_response.headers['Allow'] 36 | end 37 | 38 | def test_coalesced_find 39 | DB[:tags].multi_insert [ 40 | { :name=>'teapots' }, 41 | { :name=>'sassafrass' }, 42 | { :name=>'whimsy' }, 43 | { :name=>'horseshoe' } 44 | ] 45 | get '/tags?filter[id]=2,3' 46 | assert_ok 47 | vals = json[:data].map { |t| { :id=>t[:id].to_i, :name=>t[:attributes][:name] } } 48 | assert_equal DB[:tags].select(:id, :name).where(:id=>[2, 3]).all, vals 49 | end 50 | 51 | def test_sort_denied 52 | get '/tags?sort=id' 53 | assert_error 400 54 | end 55 | 56 | def test_sort 57 | DB[:tags].multi_insert [ 58 | { :name=>'teapots' }, 59 | { :name=>'sassafrass' }, 60 | { :name=>'whimsy' }, 61 | { :name=>'horseshoe' } 62 | ] 63 | get '/tags?sort=-name' 64 | assert_ok 65 | assert_equal 'whimsy', json[:data].first[:attributes][:name] 66 | assert_equal 'horseshoe', json[:data].last[:attributes][:name] 67 | end 68 | 69 | def test_filter_denied 70 | get '/tags?filter[foo]=bar' 71 | assert_error 400 72 | end 73 | 74 | def test_filter 75 | DB[:tags].multi_insert [ 76 | { :name=>'teapots' }, 77 | { :name=>'sassafrass' }, 78 | { :name=>'whimsy' }, 79 | { :name=>'horseshoe' } 80 | ] 81 | get '/tags?filter[name]=sassafrass' 82 | assert_ok 83 | assert_equal DB[:tags].first(:name=>'sassafrass')[:id], json[:data].first[:id].to_i 84 | assert_equal 1, json[:data].length 85 | end 86 | 87 | def test_page_denied 88 | get '/tags?page[offset]=100' 89 | assert_error 400 90 | end 91 | 92 | def test_page 93 | DB[:tags].multi_insert [ 94 | { :name=>'teapots' }, 95 | { :name=>'sassafrass' }, 96 | { :name=>'whimsy' }, 97 | { :name=>'horseshoe' } 98 | ] 99 | 100 | get '/tags?page[size]=1&sort=name' 101 | assert_ok 102 | assert_equal 1, json[:data].length 103 | assert_equal 4, json[:data].first[:id].to_i 104 | assert_equal 1, json[:meta][:pagination][:self][:number] 105 | 106 | get json[:links][:next] 107 | assert_ok 108 | assert_equal 1, json[:data].length 109 | assert_equal 2, json[:data].first[:id].to_i 110 | assert_equal 2, json[:meta][:pagination][:self][:number] 111 | 112 | get json[:links][:last] 113 | assert_ok 114 | assert_equal 1, json[:data].length 115 | assert_equal 3, json[:data].first[:id].to_i 116 | assert_equal 4, json[:meta][:pagination][:self][:number] 117 | end 118 | 119 | def test_create 120 | login 'all@yourbase.com' 121 | post '/tags', JSON.generate(:data=>{ 122 | :type=>'tags', :attributes=>{ :name=>'sassafrass' } 123 | }) 124 | assert_ok 125 | end 126 | 127 | def test_create_with_unknown_fields 128 | login 'all@yourbase.com' 129 | post '/tags', JSON.generate(:data=>{ 130 | :type=>'tags', :attributes=>{ :name=>'sassafrass', :banana=>'apple' } 131 | }) 132 | assert_error 500 133 | end 134 | 135 | def test_create_with_restricted_fields 136 | login 'all@yourbase.com' 137 | post '/tags', JSON.generate(:data=>{ 138 | :type=>'tags', :attributes=>{ :name=>'sassafrass', :id=>42 } 139 | }) 140 | assert_error 500 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/integration/rack_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | require_relative '../../demo-app/app' 4 | 5 | # Munson doesn't support error-handling (yet) so let's poke at the demo app 6 | # until it breaks and examine the pieces. 7 | 8 | class DemoAppTest1 < Minitest::Test 9 | include MyAppTest 10 | include Rack::Test::Methods 11 | 12 | def app 13 | Sinatra::Application.new 14 | end 15 | 16 | def test_it_passes_accept_header 17 | head '/' 18 | assert_error 404 19 | end 20 | 21 | def test_it_fails_accept_header 22 | header 'Accept', 'application/json' 23 | get '/' 24 | assert_error 406 25 | end 26 | 27 | def test_it_ignores_accept_header 28 | header 'Accept', '*/*' 29 | options '/' 30 | assert_error 404 31 | end 32 | 33 | def test_it_fails_content_type_header 34 | header 'Content-Type', 'application/json' 35 | post '/comments', JSON.generate(:data=>{ :type=>'comments' }) 36 | assert_error 415 37 | end 38 | 39 | def test_it_denies_access 40 | post '/comments', JSON.generate(:data=>{ :type=>'comments' }) 41 | assert_error 403 42 | end 43 | 44 | def test_it_sanity_checks_create 45 | post '/authors', JSON.generate(:data=>{ :type=>'bea_arthurs' }) 46 | assert_error 409 47 | end 48 | 49 | def test_it_sanity_checks_update 50 | login 'all@yourbase.com' 51 | patch '/authors/1', JSON.generate(:data=>{ :type=>'authors', :id=>11 }) 52 | assert_error 409 53 | end 54 | 55 | def test_it_handles_malformed_json 56 | post '/authors', '{"foo":}}' 57 | assert_error 400 58 | end 59 | 60 | def test_it_handles_missing_routes 61 | get '/teapots' 62 | assert_error 404 63 | end 64 | 65 | def test_it_handles_missing_resources 66 | get '/authors/8675309' 67 | assert_error 404 68 | end 69 | 70 | def test_it_handles_unimplemented_routes 71 | get '/comments' 72 | assert_error 405 73 | end 74 | 75 | def test_it_handles_empty_collections 76 | get '/posts' 77 | assert_ok 78 | assert_empty json[:data] 79 | end 80 | 81 | def test_it_returns_a_resource 82 | get '/authors/1' 83 | assert_ok 84 | assert_equal 'authors', json[:data][:type] 85 | assert_equal '1', json[:data][:id] 86 | end 87 | 88 | def test_it_returns_a_collection 89 | get '/authors' 90 | assert_ok 91 | assert_equal 'authors', json[:data].first[:type] 92 | assert_equal '1', json[:data].first[:id] 93 | end 94 | 95 | def test_it_returns_linkage_for_restricted_relationship 96 | get '/authors/1/relationships/comments' 97 | assert_ok 98 | assert_equal '/authors/1/comments', json[:links][:related] 99 | end 100 | 101 | def test_it_denies_access_to_restricted_relationship 102 | get '/authors/1/comments' 103 | assert_error 403 104 | end 105 | 106 | def test_it_returns_related_objects 107 | get '/authors/1/posts' 108 | assert_ok 109 | assert_kind_of Array, json[:data] 110 | end 111 | 112 | def test_it_handles_relationships_for_missing_resources 113 | get '/authors/8675309/relationships/posts' 114 | assert_error 404 115 | end 116 | 117 | def test_it_handles_missing_relationships_for_resources 118 | get '/authors/1/relationships/teapots' 119 | assert_error 404 120 | end 121 | 122 | def test_it_handles_related_for_missing_resources 123 | get '/authors/8675309/posts' 124 | assert_error 404 125 | end 126 | 127 | def test_it_handles_missing_related_for_resources 128 | get '/authors/1/teapots' 129 | assert_error 404 130 | end 131 | 132 | def test_options_for_a_collection 133 | options '/authors' 134 | assert_ok 135 | assert_equal 'GET,POST', last_response.headers['Allow'] 136 | end 137 | 138 | def test_options_for_a_resource 139 | options '/authors/1' 140 | assert_ok 141 | assert_equal 'GET,PATCH,DELETE', last_response.headers['Allow'] 142 | end 143 | 144 | def test_options_for_related 145 | options '/authors/1/posts' 146 | assert_ok 147 | assert_equal 'GET', last_response.headers['Allow'] 148 | end 149 | 150 | def test_options_for_relationship 151 | options '/authors/1/relationships/posts' 152 | assert_ok 153 | assert_equal 'GET', last_response.headers['Allow'] 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /test/helpers/serializers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | 4 | class SerializersApp < MyAppBase 5 | before do 6 | env['sinja.passthru'] = true # let exceptions propagate 7 | end 8 | 9 | configure_jsonapi do |c| 10 | %i[str sym _include _exclude].each do |sym| 11 | c.query_params[sym] = nil 12 | end 13 | end 14 | 15 | get '/dedasherize' do 16 | res = dedasherize(params.fetch('str') { params.fetch('sym').to_sym }) 17 | { :class_name=>res.class.name, :output=>res } 18 | end 19 | 20 | post '/dedasherize_names' do 21 | dedasherize_names(deserialize_request_body) 22 | end 23 | 24 | get '/include_exclude', :qparams=>:include do 25 | opts = { 26 | :include=>params.delete('_include'), 27 | :exclude=>params.delete('_exclude') 28 | } 29 | 30 | include_exclude!(opts) 31 | end 32 | 33 | post '/serialize_model' do 34 | serialize_model?(*deserialize_request_body.values_at(:model, :options)) 35 | end 36 | 37 | post '/serialize_models' do 38 | serialize_models?(*deserialize_request_body.values_at(:models, :options)) 39 | end 40 | 41 | post '/serialize_linkage' do 42 | serialize_linkage?(false, deserialize_request_body[:options]) 43 | end 44 | 45 | post '/serialize_linkages' do 46 | serialize_linkages?(false, deserialize_request_body[:options]) 47 | end 48 | 49 | post '/error_hash' do 50 | error_hash(deserialize_request_body) 51 | end 52 | end 53 | 54 | class TestSerializers < Minitest::Test 55 | include MyAppTest 56 | include Rack::Test::Methods 57 | 58 | def app 59 | SerializersApp.new 60 | end 61 | 62 | def test_dedasherize 63 | get '/dedasherize', :str=>"hello-world" 64 | assert last_response.ok? 65 | assert_equal 'String', json[:class_name] 66 | assert_equal 'hello_world', json[:output] 67 | end 68 | 69 | def test_dedasherize_sym 70 | get '/dedasherize', :sym=>"hello-world" 71 | assert last_response.ok? 72 | assert_equal 'Symbol', json[:class_name] 73 | assert_equal 'hello_world', json[:output] 74 | end 75 | 76 | def test_dedasherize_names 77 | post '/dedasherize_names', JSON.generate('foo-bar'=>{'bar-qux'=>{'qux-frob'=>11}}) 78 | assert last_response.ok? 79 | assert_equal 11, json.dig(:foo_bar, :bar_qux, :qux_frob) 80 | end 81 | 82 | def test_deserialize_request_body 83 | pass 'tested implicitly' 84 | end 85 | 86 | def test_serialize_response_body 87 | pass 'tested implicitly' 88 | end 89 | 90 | def test_include_exclude_none 91 | get '/include_exclude' 92 | assert last_response.ok? 93 | assert_empty json 94 | end 95 | 96 | def test_include_exclude_default 97 | get '/include_exclude', :_include=>'foo,bar' 98 | assert last_response.ok? 99 | assert_equal %w[foo bar], json 100 | end 101 | 102 | def test_include_exclude_param 103 | get '/include_exclude', :_include=>'foo,bar', :include=>'bar,qux' 104 | assert last_response.ok? 105 | assert_equal %w[bar qux], json 106 | end 107 | 108 | def test_include_exclude_full 109 | get '/include_exclude', :_exclude=>'qux', :include=>'bar,qux' 110 | assert last_response.ok? 111 | assert_equal %w[bar], json 112 | end 113 | 114 | def test_include_exclude_partial 115 | get '/include_exclude', :_exclude=>'bar,qux.foos', :include=>'bar,qux,qux.foos.bar' 116 | assert last_response.ok? 117 | assert_equal %w[qux], json 118 | end 119 | 120 | def test_serialize_model 121 | pass 'tested in integration' 122 | end 123 | 124 | def test_serialize_model_meta 125 | post '/serialize_model', JSON.generate(:options=>{ :meta=>{ :foo=>'bar' } }) 126 | assert last_response.ok? 127 | assert_equal({ :foo=>'bar' }, json[:meta]) 128 | end 129 | 130 | def test_serialize_model_no_content 131 | post '/serialize_model', JSON.generate(:options=>{}) 132 | assert_equal 204, last_response.status 133 | assert_empty last_response.body 134 | end 135 | 136 | def test_serialize_models 137 | pass 'tested in integration' 138 | end 139 | 140 | def test_serialize_models_meta 141 | post '/serialize_models', JSON.generate(:models=>[], :options=>{ :meta=>{ :foo=>'bar' } }) 142 | assert last_response.ok? 143 | assert_equal({ :foo=>'bar' }, json[:meta]) 144 | end 145 | 146 | def test_serialize_models_no_content 147 | post '/serialize_models', JSON.generate(:models=>[], :options=>{}) 148 | assert_equal 204, last_response.status 149 | assert_empty last_response.body 150 | end 151 | 152 | def test_serialize_linkage 153 | pass 'tested in integration' 154 | end 155 | 156 | def test_serialize_linkage_meta 157 | post '/serialize_linkage', JSON.generate(:options=>{ :meta=>{ :foo=>'bar' } }) 158 | assert last_response.ok? 159 | assert_equal({ :foo=>'bar' }, json[:meta]) 160 | end 161 | 162 | def test_serialize_linkages_meta 163 | post '/serialize_linkages', JSON.generate(:options=>{ :meta=>{ :foo=>'bar' } }) 164 | assert last_response.ok? 165 | assert_equal({ :foo=>'bar' }, json[:meta]) 166 | end 167 | 168 | def test_error_hash 169 | post '/error_hash', JSON.generate({}) 170 | assert last_response.ok? 171 | assert_equal 1, json.length 172 | assert_equal [:id, :status], json.first.keys 173 | assert_match %r{[a-z0-9-]{10,}}, json.first[:id] 174 | assert_equal '200', json.first[:status] 175 | end 176 | 177 | def test_error_hash_keywords 178 | post '/error_hash', JSON.generate(:detail=>'foo bar') 179 | assert last_response.ok? 180 | assert_equal 1, json.length 181 | assert_equal [:id, :detail, :status], json.first.keys 182 | assert_match %r{[\w-]{10,}}, json.first[:id] 183 | assert_equal '200', json.first[:status] 184 | assert_equal 'foo bar', json.first[:detail] 185 | 186 | assert_raises ArgumentError do 187 | post '/error_hash', JSON.generate(:nonsense=>'ignore') 188 | end 189 | end 190 | 191 | def test_serialize_errors 192 | pass 'tested in integration' 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/utility_classes/errors_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | 4 | require 'sinja/errors' 5 | require 'json' 6 | 7 | class TestHttpError < Minitest::Test 8 | def setup 9 | @error = Sinja::HttpError.new(418, "I'm a little teapot!") 10 | end 11 | 12 | def test_it_is_an_error 13 | assert_kind_of StandardError, @error 14 | end 15 | 16 | def test_it_has_attributes 17 | assert_equal 418, @error.http_status 18 | assert_match %r{teapot}, @error.message 19 | end 20 | end 21 | 22 | class TestOtherHttpError < Minitest::Test 23 | def setup 24 | @error = Sinja::HttpError.new(418) 25 | end 26 | 27 | def test_it_is_an_error 28 | assert_kind_of StandardError, @error 29 | end 30 | 31 | def test_it_has_attributes 32 | assert_equal 418, @error.http_status 33 | assert_match @error.class.name, @error.message 34 | end 35 | end 36 | 37 | class TestBadHttpError < Minitest::Test 38 | def setup 39 | Sinja::HttpError.new 40 | rescue Exception=>e 41 | @error = e 42 | end 43 | 44 | def test_it_raises_an_error 45 | assert_kind_of ArgumentError, @error 46 | refute_kind_of Sinja::HttpError, @error 47 | end 48 | end 49 | 50 | class TestSideloadError < Minitest::Test 51 | def setup 52 | @error = Sinja::SideloadError.new(418, '{"errors":[{"foo":"bar"}]}') 53 | end 54 | 55 | def test_it_is_an_error 56 | assert_kind_of Sinja::HttpError, @error 57 | end 58 | 59 | def test_it_has_attributes 60 | assert_equal 418, @error.http_status 61 | assert_equal [{:foo=>'bar'}], @error.error_hashes 62 | assert_equal @error.class.name, @error.message 63 | end 64 | end 65 | 66 | class TestBadSideloadError < Minitest::Test 67 | def setup 68 | Sinja::SideloadError.new(418, '{"this is bad json":') 69 | rescue Exception=>e 70 | @error = e 71 | end 72 | 73 | def test_it_raises_an_error 74 | assert_kind_of JSON::ParserError, @error 75 | refute_kind_of Sinja::HttpError, @error 76 | end 77 | end 78 | 79 | class TestBadSideloadError1 < Minitest::Test 80 | def setup 81 | Sinja::SideloadError.new(418, '{"this is ok json but with no errors key":null}') 82 | rescue Exception=>e 83 | @error = e 84 | end 85 | 86 | def test_it_raises_an_error 87 | assert_kind_of KeyError, @error 88 | refute_kind_of Sinja::HttpError, @error 89 | end 90 | end 91 | 92 | class TestBadSideloadError2 < Minitest::Test 93 | def setup 94 | Sinja::SideloadError.new(418, '["this is ok json but not a hash"]') 95 | rescue Exception=>e 96 | @error = e 97 | end 98 | 99 | def test_it_raises_an_error 100 | assert_kind_of TypeError, @error 101 | refute_kind_of Sinja::HttpError, @error 102 | end 103 | end 104 | 105 | class TestUnprocessibleEntityError < Minitest::Test 106 | def setup 107 | @error = Sinja::UnprocessibleEntityError.new([[:a, 1], [:b, 2], [:c, 3]]) 108 | end 109 | 110 | def test_it_is_an_error 111 | assert_kind_of Sinja::HttpError, @error 112 | end 113 | 114 | def test_it_has_attributes 115 | assert_equal 422, @error.http_status 116 | assert_equal [[:a, 1], [:b, 2], [:c, 3]], @error.tuples 117 | assert_equal @error.class.name, @error.message 118 | end 119 | end 120 | 121 | class TestBadUnprocessibleEntityError < Minitest::Test 122 | def setup 123 | Sinja::UnprocessibleEntityError.new([[:a, 1], [:b, 2], [:c]]) 124 | rescue Exception=>e 125 | @error = e 126 | end 127 | 128 | def test_it_is_an_error 129 | assert_kind_of RuntimeError, @error 130 | refute_kind_of Sinja::HttpError, @error 131 | end 132 | end 133 | 134 | 135 | class TestBadRequestError < Minitest::Test 136 | def setup 137 | @error = Sinja::BadRequestError.new('baba booey') 138 | end 139 | 140 | def test_it_is_an_error 141 | assert_kind_of Sinja::HttpError, @error 142 | end 143 | 144 | def test_it_has_attributes 145 | assert_equal 400, @error.http_status 146 | assert_equal 'baba booey', @error.message 147 | end 148 | end 149 | 150 | class TestForbiddenError < Minitest::Test 151 | def setup 152 | @error = Sinja::ForbiddenError.new('baba booey') 153 | end 154 | 155 | def test_it_is_an_error 156 | assert_kind_of Sinja::HttpError, @error 157 | end 158 | 159 | def test_it_has_attributes 160 | assert_equal 403, @error.http_status 161 | assert_equal 'baba booey', @error.message 162 | end 163 | end 164 | 165 | class TestNotFoundError < Minitest::Test 166 | def setup 167 | @error = Sinja::NotFoundError.new('baba booey') 168 | end 169 | 170 | def test_it_is_an_error 171 | assert_kind_of Sinja::HttpError, @error 172 | end 173 | 174 | def test_it_has_attributes 175 | assert_equal 404, @error.http_status 176 | assert_equal 'baba booey', @error.message 177 | end 178 | end 179 | 180 | class TestMethodNotAllowedError < Minitest::Test 181 | def setup 182 | @error = Sinja::MethodNotAllowedError.new('baba booey') 183 | end 184 | 185 | def test_it_is_an_error 186 | assert_kind_of Sinja::HttpError, @error 187 | end 188 | 189 | def test_it_has_attributes 190 | assert_equal 405, @error.http_status 191 | assert_equal 'baba booey', @error.message 192 | end 193 | end 194 | 195 | class TestNotAcceptableError < Minitest::Test 196 | def setup 197 | @error = Sinja::NotAcceptableError.new('baba booey') 198 | end 199 | 200 | def test_it_is_an_error 201 | assert_kind_of Sinja::HttpError, @error 202 | end 203 | 204 | def test_it_has_attributes 205 | assert_equal 406, @error.http_status 206 | assert_equal 'baba booey', @error.message 207 | end 208 | end 209 | 210 | class TestConflictError < Minitest::Test 211 | def setup 212 | @error = Sinja::ConflictError.new('baba booey') 213 | end 214 | 215 | def test_it_is_an_error 216 | assert_kind_of Sinja::HttpError, @error 217 | end 218 | 219 | def test_it_has_attributes 220 | assert_equal 409, @error.http_status 221 | assert_equal 'baba booey', @error.message 222 | end 223 | end 224 | 225 | class TestUnsupportedTypeError < Minitest::Test 226 | def setup 227 | @error = Sinja::UnsupportedTypeError.new('baba booey') 228 | end 229 | 230 | def test_it_is_an_error 231 | assert_kind_of Sinja::HttpError, @error 232 | end 233 | 234 | def test_it_has_attributes 235 | assert_equal 415, @error.http_status 236 | assert_equal 'baba booey', @error.message 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /lib/sinja/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'forwardable' 3 | require 'set' 4 | 5 | require 'sinatra/base' 6 | 7 | require 'sinja/resource' 8 | require 'sinja/relationship_routes/has_many' 9 | require 'sinja/relationship_routes/has_one' 10 | require 'sinja/resource_routes' 11 | 12 | module Sinja 13 | module ConfigUtils 14 | def deep_copy(c) 15 | Marshal.load(Marshal.dump(c)) 16 | end 17 | 18 | def deep_freeze(c) 19 | if c.respond_to?(:default_proc) 20 | c.default_proc = nil 21 | end 22 | 23 | if c.respond_to?(:values) 24 | c.each_value do |i| 25 | if i.is_a?(Hash) 26 | deep_freeze(i) 27 | else 28 | i.freeze 29 | end 30 | end 31 | end 32 | 33 | c.freeze 34 | end 35 | end 36 | 37 | class Config 38 | include ConfigUtils 39 | extend Forwardable 40 | 41 | DEFAULT_SERIALIZER_OPTS = { 42 | :jsonapi=>{ :version=>'1.0' }.freeze 43 | }.freeze 44 | 45 | DEFAULT_OPTS = { 46 | :json_generator=>(Sinatra::Base.development? ? :pretty_generate : :generate), 47 | :json_error_generator=>(Sinatra::Base.development? ? :pretty_generate : :generate) 48 | }.freeze 49 | 50 | attr_reader \ 51 | :query_params, 52 | :error_logger, 53 | :resource_config, 54 | :conflict_exceptions, 55 | :not_found_exceptions, 56 | :validation_exceptions, 57 | :validation_formatter, 58 | :page_using, 59 | :serializer_opts 60 | 61 | def initialize 62 | @query_params = { 63 | :include=>Array, # passthru to JAS 64 | :fields=>Hash, # passthru to JAS 65 | :filter=>Hash, 66 | :page=>Hash, 67 | :sort=>Array 68 | } 69 | 70 | @error_logger = ->(h) { logger.error('sinja') { h } } 71 | 72 | @default_roles = { 73 | :resource=>RolesConfig.new(%i[show show_many index create update destroy]), 74 | :has_many=>RolesConfig.new(%i[fetch clear replace merge subtract]), 75 | :has_one=>RolesConfig.new(%i[pluck prune graft]) 76 | } 77 | 78 | action_proc = proc { |type, hash, action| hash[action] = { 79 | :roles=>@default_roles[type][action].dup, 80 | :sideload_on=>Set.new, 81 | :filter_by=>Set.new, 82 | :sort_by=>Set.new 83 | }}.curry 84 | 85 | @resource_config = Hash.new { |h, k| h[k] = { 86 | :route_opts=>{ :pkre=>/\d+/ }, 87 | :resource=>Hash.new(&action_proc[:resource]), 88 | :has_many=>Hash.new { |rh, rk| rh[rk] = Hash.new(&action_proc[:has_many]) }, 89 | :has_one=>Hash.new { |rh, rk| rh[rk] = Hash.new(&action_proc[:has_one]) } 90 | }} 91 | 92 | @conflict_exceptions = Set.new 93 | @not_found_exceptions = Set.new 94 | @validation_exceptions = Set.new 95 | @validation_formatter = ->{ Array.new } 96 | 97 | @opts = DEFAULT_OPTS.dup 98 | @page_using = Hash.new 99 | @serializer_opts = deep_copy(DEFAULT_SERIALIZER_OPTS) 100 | end 101 | 102 | def error_logger=(f) 103 | fail "Invalid error formatter #{f}" \ 104 | unless f.respond_to?(:call) || f.nil? 105 | 106 | fail "Can't modify frozen proc" \ 107 | if @error_logger.frozen? 108 | 109 | @error_logger = f 110 | end 111 | 112 | def conflict_exceptions=(e=[]) 113 | @conflict_exceptions.replace(Array(e)) 114 | end 115 | 116 | def not_found_exceptions=(e=[]) 117 | @not_found_exceptions.replace(Array(e)) 118 | end 119 | 120 | def validation_exceptions=(e=[]) 121 | @validation_exceptions.replace(Array(e)) 122 | end 123 | 124 | def validation_formatter=(f) 125 | fail "Invalid validation formatter #{f}" \ 126 | unless f.respond_to?(:call) 127 | 128 | fail "Can't modify frozen proc" \ 129 | if @validation_formatter.frozen? 130 | 131 | @validation_formatter = f 132 | end 133 | 134 | def default_roles 135 | @default_roles[:resource] 136 | end 137 | 138 | def default_roles=(other={}) 139 | @default_roles[:resource].merge!(other) 140 | end 141 | 142 | def default_has_many_roles 143 | @default_roles[:has_many] 144 | end 145 | 146 | def default_has_many_roles=(other={}) 147 | @default_roles[:has_many].merge!(other) 148 | end 149 | 150 | def default_has_one_roles 151 | @default_roles[:has_one] 152 | end 153 | 154 | def default_has_one_roles=(other={}) 155 | @default_roles[:has_one].merge!(other) 156 | end 157 | 158 | DEFAULT_OPTS.each_key do |k| 159 | define_method(k) { @opts[k] } 160 | define_method("#{k}=") { |v| @opts[k] = v } 161 | end 162 | 163 | def page_using=(p={}) 164 | @page_using.replace(p) 165 | end 166 | 167 | def serializer_opts=(h={}) 168 | @serializer_opts.replace(deep_copy(DEFAULT_SERIALIZER_OPTS).merge!(h)) 169 | end 170 | 171 | def configure 172 | yield self 173 | end 174 | 175 | def freeze 176 | @query_params.freeze 177 | @error_logger.freeze 178 | 179 | deep_freeze(@default_roles) 180 | deep_freeze(@resource_config) 181 | 182 | @conflict_exceptions.freeze 183 | @not_found_exceptions.freeze 184 | @validation_exceptions.freeze 185 | @validation_formatter.freeze 186 | 187 | @opts.freeze 188 | @page_using.freeze 189 | deep_freeze(@serializer_opts) 190 | 191 | super 192 | end 193 | end 194 | 195 | class Roles < Set 196 | def intersect?(other) 197 | super(other.instance_of?(self.class) ? other : self.class[*other]) 198 | end 199 | 200 | alias === intersect? 201 | end 202 | 203 | class RolesConfig 204 | include ConfigUtils 205 | extend Forwardable 206 | 207 | def initialize(actions=[]) 208 | @data = actions.map { |action| [action, Roles.new] }.to_h 209 | end 210 | 211 | def_delegators :@data, :[], :dig 212 | 213 | def ==(other) 214 | @data == other.instance_variable_get(:@data) 215 | end 216 | 217 | def merge!(h={}) 218 | h.each do |action, roles| 219 | abort "Unknown or invalid action helper `#{action}' in configuration" \ 220 | unless @data.key?(action) 221 | @data[action].replace(Array(roles)) 222 | end 223 | @data 224 | end 225 | 226 | def initialize_copy(other) 227 | super 228 | @data = deep_copy(other.instance_variable_get(:@data)) 229 | end 230 | 231 | def freeze 232 | deep_freeze(@data) 233 | super 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /test/integration/author_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | require_relative '../../demo-app/app' 4 | 5 | class AuthorTest < SequelTest 6 | include MyAppTest 7 | include Rack::Test::Methods 8 | 9 | def app 10 | Rack::Lint.new(Sinatra::Application.new) 11 | end 12 | 13 | def test_uncoalesced_find_options 14 | options '/authors' 15 | assert_ok 16 | assert_equal 'GET,POST', last_response.headers['Allow'] 17 | end 18 | 19 | def test_uncoalesced_find 20 | DB[:authors].multi_insert [ 21 | { :email=>'dilbert@example.com', :display_name=>'Dilbert' }, 22 | { :email=>'dogbert@example.com', :display_name=>'Dogbert' }, 23 | { :email=>'catbert@example.com', :display_name=>'Catbert' }, 24 | { :email=>'wally@example.com', :display_name=>'Wally' } 25 | ] 26 | get '/authors' 27 | assert_ok 28 | vals = json[:data].map do |t| 29 | name = t[:attributes][:'display-name'] 30 | name = nil if name == 'Anonymous Coward' 31 | { :id=>t[:id].to_i, :display_name=>name } 32 | end 33 | assert_equal DB[:authors].select(:id, :display_name).all, vals 34 | end 35 | 36 | def test_coalesced_find_options 37 | options '/tags?filter[id]=2,4' 38 | assert_ok 39 | assert_equal 'GET', last_response.headers['Allow'] 40 | end 41 | 42 | def test_coalesced_find 43 | DB[:authors].multi_insert [ 44 | { :email=>'dilbert@example.com', :display_name=>'Dilbert' }, 45 | { :email=>'dogbert@example.com', :display_name=>'Dogbert' }, 46 | { :email=>'catbert@example.com', :display_name=>'Catbert' }, 47 | { :email=>'wally@example.com', :display_name=>'Wally' } 48 | ] 49 | get '/authors?filter[id]=2,4' 50 | assert_ok 51 | vals = json[:data].map { |t| { :id=>t[:id].to_i, :display_name=>t[:attributes][:'display-name'] } } 52 | assert_equal DB[:authors].where(:id=>[2, 4]).select(:id, :display_name).all, vals 53 | end 54 | 55 | def test_disallow_client_generated_id 56 | post '/authors', JSON.generate(:data=>{ 57 | :type=>'authors', 58 | :id=>9999999, 59 | :attributes=>{ 60 | :email=>'bad@mammajamba.com' 61 | } 62 | }) 63 | assert_error 403, /not supported/ 64 | end 65 | 66 | def test_register 67 | id = register 'foo@example.com', 'Foo Bar' 68 | assert_ok 69 | refute_nil id 70 | assert_equal 'Anonymous Coward', json[:data][:attributes][:'display-name'] 71 | 72 | author = DB[:authors].first(:id=>id) 73 | refute_nil author 74 | assert_nil author[:display_name] 75 | assert_equal 'Foo Bar', author[:real_name] 76 | assert_equal 'foo@example.com', author[:email] 77 | refute author[:admin] 78 | end 79 | 80 | def test_show 81 | id = register 'foo@example.com', 'Foo Bar' 82 | get "/authors/#{id}" 83 | assert_ok 84 | end 85 | 86 | def test_index 87 | id1 = register 'foo@example.com', 'Foo Bar' 88 | id2 = register 'bar@example.com', 'Bar Qux' 89 | get '/authors' 90 | assert_ok 91 | assert json[:data].any? { |d| d[:type] == 'authors' && d[:id] == id1 } 92 | assert json[:data].any? { |d| d[:type] == 'authors' && d[:id] == id2 } 93 | end 94 | 95 | def test_destroy_self 96 | id = register 'foo@example.com', 'Foo Bar' 97 | login 'foo@example.com' 98 | delete "/authors/#{id}" 99 | assert_equal 204, last_response.status 100 | assert_nil DB[:authors].first(:id=>id) 101 | end 102 | 103 | def test_superuser_destroy 104 | id = register 'foo@example.com', 'Foo Bar' 105 | login 'all@yourbase.com' 106 | delete "/authors/#{id}" 107 | assert_equal 204, last_response.status 108 | assert_nil DB[:authors].first(:id=>id) 109 | end 110 | 111 | def test_update_self 112 | id = register 'foo@example.com', 'Foo Bar' 113 | login 'foo@example.com' 114 | patch "/authors/#{id}", JSON.generate(:data=>{ :type=>'authors', :id=>id, :attributes=>{ 115 | 'real-name'=>'Bar Qux Foo', 116 | 'display-name'=>'Bar Qux' 117 | }}) 118 | assert_ok 119 | assert_equal 'Bar Qux', json[:data][:attributes][:'display-name'] 120 | 121 | author = DB[:authors].first(:id=>id) 122 | refute_nil author 123 | assert_equal 'Bar Qux', author[:display_name] 124 | assert_equal 'Bar Qux Foo', author[:real_name] 125 | assert_equal 'foo@example.com', author[:email] 126 | refute author[:admin] 127 | end 128 | 129 | def test_superuser_update_failure 130 | id = register 'foo@example.com', 'Foo Bar' 131 | login 'foo@example.com' 132 | patch "/authors/#{id}", JSON.generate(:data=>{ :type=>'authors', :id=>id, :attributes=>{ 133 | :admin=>true 134 | }}) 135 | assert_error 403 136 | end 137 | 138 | def test_superuser_update 139 | id = register 'foo@example.com', 'Foo Bar' 140 | login 'all@yourbase.com' 141 | patch "/authors/#{id}", JSON.generate(:data=>{ :type=>'authors', :id=>id, :attributes=>{ 142 | :admin=>true 143 | }}) 144 | assert_ok 145 | 146 | author = DB[:authors].first(:id=>id) 147 | refute_nil author 148 | assert author[:admin] 149 | end 150 | 151 | def prep_posts_comments 152 | author_id = register 'foo@example.com', 'Foo Bar' 153 | post_slug = 'foo-post' 154 | DB[:posts].insert \ 155 | :slug=>post_slug, 156 | :author_id=>author_id, 157 | :title=>'I am a little teapot', 158 | :body=>'short and stout!' 159 | comment_id = DB[:comments].insert \ 160 | :author_id=>author_id, 161 | :post_slug=>post_slug, 162 | :body=>'you are no teapot' 163 | 164 | [author_id, post_slug, comment_id] 165 | end 166 | 167 | def test_related_posts 168 | author, post, _ = prep_posts_comments 169 | get "/authors/#{author}/posts" 170 | assert_ok 171 | assert json[:data].any? { |d| d[:type] == 'posts' && d[:id] == post && d[:attributes] } 172 | end 173 | 174 | def test_posts_relationship 175 | author, post, _ = prep_posts_comments 176 | get "/authors/#{author}/relationships/posts" 177 | assert_ok 178 | assert json[:data].any? { |d| d[:type] == 'posts' && d[:id] == post && !d[:attributes] } 179 | end 180 | 181 | def test_related_comments_forbidden 182 | author, * = prep_posts_comments 183 | get "/authors/#{author}/comments" 184 | assert_error 403 185 | end 186 | 187 | def test_related_comments_allowed 188 | author, _, comment = prep_posts_comments 189 | login 'foo@example.com' 190 | get "/authors/#{author}/comments" 191 | assert_ok 192 | assert json[:data].any? { |d| d[:type] == 'comments' && d[:id] == comment.to_s && d[:attributes] } 193 | end 194 | 195 | def test_comments_relationship 196 | author, _, comment = prep_posts_comments 197 | get "/authors/#{author}/relationships/comments" 198 | assert_ok 199 | assert json[:data].any? { |d| d[:type] == 'comments' && d[:id] == comment.to_s && !d[:attributes] } 200 | end 201 | 202 | def test_no_sideunload 203 | author, post, comment = prep_posts_comments 204 | get "/authors/#{author}" 205 | assert_ok 206 | refute json[:included].any? { |d| d[:type] == 'posts' && d[:id] == post } 207 | refute json[:included].any? { |d| d[:type] == 'comments' && d[:id] == comment.to_s } 208 | end 209 | 210 | def test_sideunload_forbidden 211 | author, post, comment = prep_posts_comments 212 | get "/authors/#{author}", :include=>'comments,posts' 213 | assert_ok 214 | assert json[:included].any? { |d| d[:type] == 'posts' && d[:id] == post } 215 | refute json[:included].any? { |d| d[:type] == 'comments' && d[:id] == comment.to_s } 216 | end 217 | 218 | def test_sideunload_allowed 219 | login 'foo@example.com' 220 | author, post, comment = prep_posts_comments 221 | get "/authors/#{author}", :include=>'comments,posts' 222 | assert_ok 223 | assert json[:included].any? { |d| d[:type] == 'posts' && d[:id] == post } 224 | assert json[:included].any? { |d| d[:type] == 'comments' && d[:id] == comment.to_s } 225 | end 226 | 227 | def test_conflict_exception 228 | register 'foo@example.com', 'Foo Bar' 229 | @json = nil 230 | register 'foo@example.com', 'Bar Qux' 231 | assert_error 409 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /test/integration/post_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'test_helper' 3 | require_relative '../../demo-app/app' 4 | 5 | class PostTest < SequelTest 6 | include MyAppTest 7 | include Rack::Test::Methods 8 | 9 | def app 10 | Sinatra::Application.new 11 | end 12 | 13 | def test_missing_client_id 14 | login 'all@yourbase.com' 15 | post '/posts', JSON.generate(:data=>{ 16 | :type=>'posts', 17 | :attributes=>{ 18 | :title=>'This is a post', 19 | :body=>'This is a post body' 20 | } 21 | }) 22 | assert_error 403, /not provided/ 23 | end 24 | 25 | def test_validation_exception 26 | login 'all@yourbase.com' 27 | post '/posts', JSON.generate(:data=>{ 28 | :type=>'posts', 29 | :id=>'foo-bar', 30 | :attributes=>{ 31 | :title=>'This is a post', 32 | :body=>'This is a post body' 33 | } 34 | }) 35 | assert_error 422 36 | assert_equal '/data/relationships/author', json[:errors].first[:source][:pointer] 37 | assert_empty DB[:posts].all 38 | end 39 | 40 | def test_sideload_on_create 41 | author_id = DB[:authors].insert :email=>'foo@example.com' 42 | login 'foo@example.com' 43 | 44 | DB[:tags].multi_insert [{ :name=>'teapots' }, { :name=>'sassafrass' }] 45 | tag_ids = DB[:tags].select_order_map(:id) 46 | 47 | slug = 'foo-bar' 48 | 49 | post '/posts', JSON.generate(:data=>{ 50 | :type=>'posts', 51 | :id=>slug, 52 | :attributes=>{ 53 | :title=>'This is a post', 54 | :body=>'This is a post body' 55 | }, 56 | :relationships=>{ 57 | :author=>{ 58 | :data=>{ 59 | :type=>'authors', 60 | :id=>author_id 61 | } 62 | }, 63 | :tags=>{ 64 | :data=>tag_ids.map { |id| { :type=>'tags', :id=>id }} 65 | } 66 | } 67 | }) 68 | assert_ok 69 | 70 | assert_equal author_id, DB[:posts].first(:slug=>slug)[:author_id] 71 | assert_equal tag_ids, DB[:posts_tags].where(:post_slug=>slug).select_order_map(:tag_id) 72 | end 73 | 74 | def test_sideload_on_update 75 | author_id = DB[:authors].insert :email=>'foo@example.com' 76 | DB[:tags].multi_insert [{ :name=>'teapots' }, { :name=>'sassafrass' }] 77 | tag_ids = DB[:tags].select_order_map(:id) 78 | slug = 'foo-bar' 79 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 80 | DB[:posts_tags].insert :post_slug=>slug, :tag_id=>tag_ids.first 81 | 82 | login 'foo@example.com' 83 | 84 | patch "/posts/#{slug}", JSON.generate(:data=>{ 85 | :type=>'posts', 86 | :id=>slug, 87 | :attributes=>{ 88 | :body=>'This is a different post body' 89 | }, 90 | :relationships=>{ 91 | :tags=>{ 92 | :data=>[{ :type=>'tags', :id=>tag_ids.last }] 93 | } 94 | } 95 | }) 96 | 97 | assert_ok 98 | assert_equal [tag_ids.last], DB[:posts_tags].where(:post_slug=>slug).select_order_map(:tag_id) 99 | assert_match %r{different}, DB[:posts].first(:slug=>slug)[:body] 100 | end 101 | 102 | def test_slug_update 103 | author_id = DB[:authors].insert :email=>'foo@example.com' 104 | DB[:tags].multi_insert [{ :name=>'teapots' }, { :name=>'sassafrass' }] 105 | tag_ids = DB[:tags].select_order_map(:id) 106 | slug = 'foo-bar' 107 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 108 | DB[:posts_tags].multi_insert [ 109 | { :post_slug=>slug, :tag_id=>tag_ids.first }, 110 | { :post_slug=>slug, :tag_id=>tag_ids.last } 111 | ] 112 | 113 | login 'foo@example.com' 114 | 115 | new_slug = 'bar-qux' 116 | patch "/posts/#{slug}", JSON.generate(:data=>{ 117 | :type=>'posts', 118 | :id=>slug, 119 | :attributes=>{ :slug=>new_slug } 120 | }) 121 | 122 | assert_ok 123 | assert_equal tag_ids, DB[:posts_tags].where(:post_slug=>new_slug).select_order_map(:tag_id) 124 | assert_match %r{a post body}, DB[:posts].first(:slug=>new_slug)[:body] 125 | end 126 | 127 | def test_owner_cant_change_author 128 | author_id = DB[:authors].insert :email=>'foo@example.com' 129 | slug = 'foo-bar' 130 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 131 | 132 | login 'foo@example.com' 133 | 134 | patch "/posts/#{slug}/relationships/author", JSON.generate(:data=>{ 135 | :type=>'authors', 136 | :id=>1 137 | }) 138 | 139 | assert_error 403 140 | assert_equal author_id, DB[:posts].first(:slug=>slug)[:author_id] 141 | end 142 | 143 | def test_superuser_can_change_author 144 | author_id = DB[:authors].insert :email=>'foo@example.com' 145 | slug = 'foo-bar' 146 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 147 | 148 | login 'all@yourbase.com' 149 | 150 | patch "/posts/#{slug}/relationships/author", JSON.generate(:data=>{ 151 | :type=>'authors', 152 | :id=>1 153 | }) 154 | 155 | assert_ok 156 | assert_equal 1, DB[:posts].first(:slug=>slug)[:author_id] 157 | end 158 | 159 | def test_related_resource_not_found 160 | author_id = DB[:authors].insert :email=>'foo@example.com' 161 | slug = 'foo-bar' 162 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 163 | login 'foo@example.com' 164 | post "/posts/#{slug}/relationships/tags", JSON.generate(:data=>[{ :type=>'tags', :id=>99999 }]) 165 | assert_error 404 166 | end 167 | 168 | def test_owner_can_add_missing_tags 169 | author_id = DB[:authors].insert :email=>'foo@example.com' 170 | DB[:tags].multi_insert [{ :name=>'teapots' }, { :name=>'sassafrass' }] 171 | tag_ids = DB[:tags].select_order_map(:id) 172 | slug = 'foo-bar' 173 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 174 | DB[:posts_tags].insert :post_slug=>slug, :tag_id=>tag_ids.first 175 | 176 | login 'foo@example.com' 177 | 178 | post "/posts/#{slug}/relationships/tags", JSON.generate(:data=>[ 179 | { :type=>'tags', :id=>tag_ids.first }, 180 | { :type=>'tags', :id=>tag_ids.last } 181 | ]) 182 | 183 | assert_ok 184 | assert_equal tag_ids, DB[:posts_tags].where(:post_slug=>slug).select_order_map(:tag_id) 185 | end 186 | 187 | def test_owner_can_remove_present_tags 188 | author_id = DB[:authors].insert :email=>'foo@example.com' 189 | DB[:tags].multi_insert [{ :name=>'teapots' }, { :name=>'sassafrass' }] 190 | tag_ids = DB[:tags].select_order_map(:id) 191 | slug = 'foo-bar' 192 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 193 | DB[:posts_tags].multi_insert [ 194 | { :post_slug=>slug, :tag_id=>tag_ids.first }, 195 | { :post_slug=>slug, :tag_id=>tag_ids.last } 196 | ] 197 | 198 | login 'foo@example.com' 199 | 200 | request "/posts/#{slug}/relationships/tags", :method=>:delete, :input=>JSON.generate(:data=>[ 201 | { :type=>'tags', :id=>tag_ids.first }, 202 | { :type=>'tags', :id=>999999 } 203 | ]) 204 | 205 | assert_ok 206 | assert_equal [tag_ids.last], DB[:posts_tags].where(:post_slug=>slug).select_order_map(:tag_id) 207 | end 208 | 209 | def test_owner_can_clear_tags 210 | author_id = DB[:authors].insert :email=>'foo@example.com' 211 | DB[:tags].multi_insert [{ :name=>'teapots' }, { :name=>'sassafrass' }] 212 | tag_ids = DB[:tags].select_order_map(:id) 213 | slug = 'foo-bar' 214 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 215 | DB[:posts_tags].multi_insert [ 216 | { :post_slug=>slug, :tag_id=>tag_ids.first }, 217 | { :post_slug=>slug, :tag_id=>tag_ids.last } 218 | ] 219 | 220 | login 'foo@example.com' 221 | 222 | patch "/posts/#{slug}/relationships/tags", JSON.generate(:data=>[]) 223 | 224 | assert_equal 204, last_response.status 225 | assert_empty last_response.body 226 | assert_empty DB[:posts_tags].where(:post_slug=>slug).select_order_map(:tag_id) 227 | end 228 | 229 | def test_anyone_can_pluck_author 230 | author_id = DB[:authors].insert :email=>'foo@example.com' 231 | slug = 'foo-bar' 232 | DB[:posts].insert :slug=>slug, :title=>'This is a post', :body=>'This is a post body', :author_id=>author_id 233 | get "/posts/#{slug}/relationships/author" 234 | assert_ok 235 | assert_equal 'authors', json[:data][:type] 236 | assert_equal author_id, json[:data][:id].to_i 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /test/utility_classes/config_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative '../test_helper' 3 | 4 | require 'sinja/config' 5 | 6 | class TestConfig < Minitest::Test 7 | def setup 8 | @config = Sinja::Config.new 9 | end 10 | 11 | def test_it_sets_sane_defaults 12 | assert_kind_of Hash, @config.query_params 13 | assert_respond_to @config.error_logger, :call 14 | 15 | assert_kind_of Sinja::RolesConfig, @config.default_roles 16 | assert_kind_of Sinja::RolesConfig, @config.default_has_many_roles 17 | assert_kind_of Sinja::RolesConfig, @config.default_has_one_roles 18 | 19 | assert_kind_of Hash, @config.resource_config 20 | assert_respond_to @config.resource_config.default_proc, :call 21 | 22 | assert_kind_of Set, @config.conflict_exceptions 23 | assert_kind_of Set, @config.not_found_exceptions 24 | assert_kind_of Set, @config.validation_exceptions 25 | assert_respond_to @config.validation_formatter, :call 26 | 27 | assert_kind_of Hash, @config.page_using 28 | assert_kind_of Hash, @config.serializer_opts 29 | 30 | assert_equal :generate, @config.json_generator 31 | assert_equal :generate, @config.json_error_generator 32 | end 33 | 34 | def test_resource_config_default_procs 35 | @config.default_roles = { :index=>:foo } 36 | @config.default_has_many_roles = { :fetch=>:bar } 37 | @config.default_has_one_roles = { :pluck=>:qux } 38 | 39 | assert_equal Sinja::Roles[:foo], @config.resource_config[:foos][:resource][:index][:roles] 40 | assert_equal Sinja::Roles[:bar], @config.resource_config[:foos][:has_many][:bars][:fetch][:roles] 41 | assert_equal Sinja::Roles[:qux], @config.resource_config[:foos][:has_one][:qux][:pluck][:roles] 42 | 43 | assert_kind_of Sinja::Roles, @config.resource_config[:bars][:resource][:index][:roles] 44 | assert_kind_of Sinja::Roles, @config.resource_config[:bars][:has_many][:bars][:fetch][:roles] 45 | assert_kind_of Sinja::Roles, @config.resource_config[:bars][:has_one][:qux][:pluck][:roles] 46 | 47 | assert_equal @config.resource_config[:foos], 48 | @config.resource_config[:bars] 49 | refute_same @config.resource_config[:foos], 50 | @config.resource_config[:bars] 51 | 52 | assert_equal @config.resource_config[:foos][:resource], 53 | @config.resource_config[:bars][:resource] 54 | refute_same @config.resource_config[:foos][:resource], 55 | @config.resource_config[:bars][:resource] 56 | 57 | assert_equal @config.resource_config[:foos][:resource][:index], 58 | @config.resource_config[:bars][:resource][:index] 59 | refute_same @config.resource_config[:foos][:resource][:index], 60 | @config.resource_config[:bars][:resource][:index] 61 | 62 | assert_equal @config.resource_config[:foos][:resource][:index][:roles], 63 | @config.resource_config[:bars][:resource][:index][:roles] 64 | refute_same @config.resource_config[:foos][:resource][:index][:roles], 65 | @config.resource_config[:bars][:resource][:index][:roles] 66 | 67 | assert_equal @config.resource_config[:foos][:resource][:index][:sideload_on], 68 | @config.resource_config[:bars][:resource][:index][:sideload_on] 69 | refute_same @config.resource_config[:foos][:resource][:index][:sideload_on], 70 | @config.resource_config[:bars][:resource][:index][:sideload_on] 71 | 72 | assert_equal @config.resource_config[:foos][:resource][:index][:filter_by], 73 | @config.resource_config[:bars][:resource][:index][:filter_by] 74 | refute_same @config.resource_config[:foos][:resource][:index][:filter_by], 75 | @config.resource_config[:bars][:resource][:index][:filter_by] 76 | 77 | assert_equal @config.resource_config[:foos][:resource][:index][:sort_by], 78 | @config.resource_config[:bars][:resource][:index][:sort_by] 79 | refute_same @config.resource_config[:foos][:resource][:index][:sort_by], 80 | @config.resource_config[:bars][:resource][:index][:sort_by] 81 | end 82 | 83 | def test_error_logger_setter 84 | assert_raises(RuntimeError) { @config.error_logger = :i_am_not_callable } 85 | 86 | lam = proc { |h| logger.error(h) } 87 | @config.error_logger = lam 88 | assert_equal lam, @config.error_logger 89 | end 90 | 91 | def test_default_roles_setter 92 | assert_raises SystemExit do 93 | capture_io { @config.default_roles = { :i_am_not_valid=>:foo } } 94 | end 95 | 96 | roles = { :create=>:admin, :update=>:user } 97 | @config.default_roles = roles 98 | assert_equal Sinja::Roles[:admin], @config.default_roles[:create] 99 | assert_equal Sinja::Roles[:user], @config.default_roles[:update] 100 | assert_equal Sinja::Roles.new, @config.default_roles[:destroy] 101 | end 102 | 103 | def test_default_has_many_roles_setter 104 | assert_raises SystemExit do 105 | capture_io { @config.default_has_many_roles = { :i_am_not_valid=>:foo } } 106 | end 107 | 108 | roles = { :clear=>:admin, :merge=>:user } 109 | @config.default_has_many_roles = roles 110 | assert_equal Sinja::Roles[:admin], @config.default_has_many_roles[:clear] 111 | assert_equal Sinja::Roles[:user], @config.default_has_many_roles[:merge] 112 | assert_equal Sinja::Roles.new, @config.default_has_many_roles[:subtract] 113 | end 114 | 115 | def test_default_has_one_roles_setter 116 | assert_raises SystemExit do 117 | capture_io { @config.default_has_one_roles = { :i_am_not_valid=>:foo } } 118 | end 119 | 120 | roles = { :prune=>:admin, :graft=>:user } 121 | @config.default_has_one_roles = roles 122 | assert_equal Sinja::Roles[:admin], @config.default_has_one_roles[:prune] 123 | assert_equal Sinja::Roles[:user], @config.default_has_one_roles[:graft] 124 | assert_equal Sinja::Roles.new, @config.default_has_one_roles[:pluck] 125 | end 126 | 127 | def test_conflict_exceptions_setter 128 | @config.conflict_exceptions = [:c] 129 | @config.conflict_exceptions = [:a, :a, :b] 130 | assert_equal Set[:a, :b], @config.conflict_exceptions 131 | end 132 | 133 | def test_not_found_exceptions_setter 134 | @config.not_found_exceptions = [:c] 135 | @config.not_found_exceptions = [:a, :a, :b] 136 | assert_equal Set[:a, :b], @config.not_found_exceptions 137 | end 138 | 139 | def test_validation_exceptions_setter 140 | @config.validation_exceptions = [:c] 141 | @config.validation_exceptions = [:a, :a, :b] 142 | assert_equal Set[:a, :b], @config.validation_exceptions 143 | end 144 | 145 | def test_validation_formatter_setter 146 | assert_raises(RuntimeError) { @config.validation_formatter = :i_am_not_callable } 147 | 148 | lam = proc { [[:a, 1]] } 149 | @config.validation_formatter = lam 150 | assert_equal lam, @config.validation_formatter 151 | end 152 | 153 | def test_page_using 154 | @config.page_using = { :c=>3 } 155 | @config.page_using = { :a=>1, :b=>2 } 156 | assert_equal({ :a=>1, :b=>2 }, @config.page_using) 157 | end 158 | 159 | def test_serializer_opts_setter 160 | default = @config.serializer_opts[:jsonapi] 161 | @config.serializer_opts = { :meta=>{ :what=>1 } } 162 | assert_equal({ :what=>1 }, @config.serializer_opts[:meta]) 163 | assert_equal default, @config.serializer_opts[:jsonapi] 164 | end 165 | 166 | def test_json_generator_setter 167 | @config.json_generator = :fast_generate 168 | assert_equal :fast_generate, @config.json_generator 169 | end 170 | 171 | def test_it_freezes_deeply 172 | @config.freeze 173 | 174 | assert_predicate @config.query_params, :frozen? 175 | assert_predicate @config.error_logger, :frozen? 176 | 177 | assert_predicate @config.default_roles, :frozen? 178 | assert_predicate @config.default_has_one_roles, :frozen? 179 | assert_predicate @config.default_has_many_roles, :frozen? 180 | 181 | assert_predicate @config.resource_config, :frozen? 182 | assert_nil @config.resource_config.default_proc 183 | 184 | assert_predicate @config.conflict_exceptions, :frozen? 185 | assert_predicate @config.not_found_exceptions, :frozen? 186 | assert_predicate @config.validation_exceptions, :frozen? 187 | assert_predicate @config.validation_formatter, :frozen? 188 | 189 | assert_predicate @config.page_using, :frozen? 190 | assert_predicate @config.serializer_opts, :frozen? 191 | 192 | assert_predicate @config.instance_variable_get(:@opts), :frozen? 193 | end 194 | 195 | def test_it_freezes_resource_config_deeply 196 | assert_kind_of Sinja::Roles, @config.resource_config[:foos][:resource][:index][:roles] 197 | assert_kind_of Sinja::Roles, @config.resource_config[:foos][:has_many][:bars][:fetch][:roles] 198 | assert_kind_of Set, @config.resource_config[:foos][:has_many][:bars][:fetch][:sideload_on] 199 | assert_kind_of Set, @config.resource_config[:foos][:has_many][:bars][:fetch][:filter_by] 200 | assert_kind_of Set, @config.resource_config[:foos][:has_many][:bars][:fetch][:sort_by] 201 | assert_kind_of Sinja::Roles, @config.resource_config[:foos][:has_one][:qux][:pluck][:roles] 202 | 203 | @config.freeze 204 | 205 | assert_predicate @config.resource_config[:foos], :frozen? 206 | assert_nil @config.resource_config[:foos].default_proc 207 | 208 | assert_predicate @config.resource_config[:foos][:has_many], :frozen? 209 | assert_nil @config.resource_config[:foos][:has_many].default_proc 210 | 211 | assert_predicate @config.resource_config[:foos][:has_one][:qux], :frozen? 212 | assert_nil @config.resource_config[:foos][:has_one][:qux].default_proc 213 | 214 | assert_predicate @config.resource_config[:foos][:resource][:index], :frozen? 215 | assert_nil @config.resource_config[:foos][:resource][:index].default_proc 216 | 217 | assert_predicate @config.resource_config[:foos][:has_many][:bars][:fetch][:roles], :frozen? 218 | assert_predicate @config.resource_config[:foos][:has_many][:bars][:fetch][:sideload_on], :frozen? 219 | assert_predicate @config.resource_config[:foos][:has_many][:bars][:fetch][:filter_by], :frozen? 220 | assert_predicate @config.resource_config[:foos][:has_many][:bars][:fetch][:sort_by], :frozen? 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /lib/sinja/helpers/serializers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'set' 3 | 4 | require 'active_support/inflector' 5 | require 'json' 6 | require 'jsonapi-serializers' 7 | 8 | module Sinja 9 | module Helpers 10 | module Serializers 11 | VALID_PAGINATION_KEYS = Set.new(%i[self first prev next last]).freeze 12 | 13 | def dedasherize(s=nil) 14 | s.to_s.underscore.send(s.instance_of?(Symbol) ? :to_sym : :itself) 15 | end 16 | 17 | def dedasherize_names(*args) 18 | _dedasherize_names(*args).to_h 19 | end 20 | 21 | private def _dedasherize_names(hash={}) 22 | return enum_for(__callee__, hash) unless block_given? 23 | 24 | hash.each do |k, v| 25 | yield dedasherize(k), v.is_a?(Hash) ? dedasherize_names(v) : v 26 | end 27 | end 28 | 29 | def deserialize_request_body 30 | return {} unless content? 31 | 32 | request.body.rewind 33 | JSON.parse(request.body.read, :symbolize_names=>true) 34 | rescue JSON::ParserError 35 | raise BadRequestError, 'Malformed JSON in the request body' 36 | end 37 | 38 | def serialize_response_body 39 | case response.content_type[/^[^;]+/] 40 | when /json$/, /javascript$/ 41 | JSON.send(settings._sinja.json_generator, response.body) 42 | else 43 | Array(response.body).map!(&:to_s) 44 | end 45 | rescue JSON::GeneratorError 46 | raise BadRequestError, 'Unserializable entities in the response body' 47 | end 48 | 49 | def include_exclude!(options) 50 | included, default, excluded = 51 | params[:include], 52 | options.delete(:include) || [], 53 | options.delete(:exclude) || [] 54 | 55 | if included.empty? 56 | included = default.is_a?(Array) ? default : default.split(',') 57 | 58 | return included if included.empty? 59 | end 60 | 61 | excluded = excluded.is_a?(Array) ? excluded : excluded.split(',') 62 | unless excluded.empty? 63 | excluded = Set.new(excluded) 64 | included.delete_if do |termstr| 65 | terms = termstr.split('.') 66 | terms.length.times.any? do |i| 67 | excluded.include?(terms.take(i.succ).join('.')) 68 | end 69 | end 70 | 71 | return included if included.empty? 72 | end 73 | 74 | return included unless settings._resource_config 75 | 76 | # Walk the tree and try to exclude based on fetch and pluck permissions 77 | included.keep_if do |termstr| 78 | catch :keep? do 79 | *terms, last_term = termstr.split('.') 80 | 81 | # Start cursor at root of current resource 82 | config = settings._resource_config 83 | terms.each do |term| 84 | # Move cursor through each term, avoiding the default proc, 85 | # halting if no roles found, i.e. client asked to include 86 | # something that Sinja doesn't know about 87 | throw :keep?, true unless config = 88 | settings._sinja.resource_config.fetch(term.pluralize.to_sym, nil) 89 | end 90 | 91 | throw :keep?, true unless roles = 92 | config.dig(:has_many, last_term.pluralize.to_sym, :fetch, :roles) || 93 | config.dig(:has_one, last_term.singularize.to_sym, :pluck, :roles) 94 | 95 | throw :keep?, roles && (roles.empty? || roles.intersect?(role)) 96 | end 97 | end 98 | end 99 | 100 | def serialize_model(model=nil, options={}) 101 | options[:is_collection] = false 102 | options[:include] = include_exclude!(options) 103 | options[:fields] ||= params[:fields] unless params[:fields].empty? 104 | options = settings._sinja.serializer_opts.merge(options) 105 | 106 | ::JSONAPI::Serializer.serialize(model, options) 107 | rescue ::JSONAPI::Serializer::InvalidIncludeError=>e 108 | raise BadRequestError, e 109 | end 110 | 111 | def serialize_model?(model=nil, options={}) 112 | if model 113 | body serialize_model(model, options) 114 | elsif options.key?(:meta) 115 | body serialize_model(nil, :meta=>options[:meta]) 116 | else 117 | status 204 118 | end 119 | end 120 | 121 | def serialize_models(models=[], options={}, pagination=nil) 122 | options[:is_collection] = true 123 | options[:include] = include_exclude!(options) 124 | options[:fields] ||= params[:fields] unless params[:fields].empty? 125 | options = settings._sinja.serializer_opts.merge(options) 126 | 127 | if pagination 128 | # Whitelist pagination keys and dasherize query parameter names 129 | pagination = VALID_PAGINATION_KEYS 130 | .select(&pagination.method(:key?)) 131 | .map! do |outer_key| 132 | [outer_key, pagination[outer_key].map do |inner_key, value| 133 | [inner_key.to_s.dasherize.to_sym, value] 134 | end.to_h] 135 | end.to_h 136 | 137 | options[:meta] ||= {} 138 | options[:meta][:pagination] = pagination 139 | 140 | options[:links] ||= {} 141 | options[:links][:self] = request.url unless pagination.key?(:self) 142 | 143 | base_query = Rack::Utils.build_nested_query \ 144 | env['rack.request.query_hash'].dup.tap { |h| h.delete('page') } 145 | 146 | self_link, join_char = 147 | if base_query.empty? 148 | [request.path, ??] 149 | else 150 | ["#{request.path}?#{base_query}", ?&] 151 | end 152 | 153 | options[:links].merge!(pagination.map do |key, value| 154 | [key, [self_link, 155 | Rack::Utils.build_nested_query(:page=>value)].join(join_char)] 156 | end.to_h) 157 | end 158 | 159 | ::JSONAPI::Serializer.serialize(Array(models), options) 160 | rescue ::JSONAPI::Serializer::InvalidIncludeError=>e 161 | raise BadRequestError, e 162 | end 163 | 164 | def serialize_models?(models=[], options={}, pagination=nil) 165 | if Array(models).any? 166 | body serialize_models(models, options, pagination) 167 | elsif options.key?(:meta) 168 | body serialize_models([], :meta=>options[:meta]) 169 | else 170 | status 204 171 | end 172 | end 173 | 174 | def serialize_linkage(model, rel, options={}) 175 | options[:is_collection] = false 176 | options = settings._sinja.serializer_opts.merge(options) 177 | 178 | options[:serializer] ||= ::JSONAPI::Serializer.find_serializer_class(model, options) 179 | options[:include] = options[:serializer].new(model, options).format_name(rel) 180 | 181 | # TODO: This is extremely wasteful. Refactor JAS to expose the linkage serializer? 182 | content = ::JSONAPI::Serializer.serialize(model, options) 183 | content['data']['relationships'].fetch(options[:include]).tap do |linkage| 184 | %w[meta jsonapi].each do |key| 185 | linkage[key] = content[key] if content.key?(key) 186 | end 187 | end 188 | end 189 | 190 | def serialize_linkage?(updated=false, options={}) 191 | body updated ? serialize_linkage(options) : serialize_model?(nil, options) 192 | end 193 | 194 | def serialize_linkages?(updated=false, options={}) 195 | body updated ? serialize_linkage(options) : serialize_models?([], options) 196 | end 197 | 198 | def error_hash(title: nil, detail: nil, source: nil) 199 | [ 200 | { :id=>SecureRandom.uuid }.tap do |hash| 201 | hash[:title] = title if title 202 | hash[:detail] = detail if detail 203 | hash[:status] = status.to_s if status 204 | hash[:source] = source if source 205 | end 206 | ] 207 | end 208 | 209 | def exception_title(e) 210 | e.respond_to?(:title) ? e.title : e.class.name.demodulize.titleize 211 | end 212 | 213 | def serialize_errors 214 | raise env['sinatra.error'] if env['sinatra.error'] && sideloaded? 215 | 216 | abody = Array(body) 217 | error_hashes = 218 | if abody.all? { |error| error.is_a?(Hash) } 219 | # `halt' with a hash or array of hashes 220 | abody.flat_map(&method(:error_hash)) 221 | elsif not_found? 222 | # `not_found' or `halt 404' 223 | message = abody.first.to_s 224 | error_hash \ 225 | :title=>'Not Found Error', 226 | :detail=>(message unless message == '

Not Found

') 227 | elsif abody.all? { |error| error.is_a?(String) } 228 | # Try to repackage a JSON-encoded middleware error 229 | begin 230 | abody.flat_map do |error| 231 | miderr = JSON.parse(error, :symbolize_names=>true) 232 | error_hash \ 233 | :title=>'Middleware Error', 234 | :detail=>(miderr.key?(:error) ? miderr[:error] : error) 235 | end 236 | rescue JSON::ParserError 237 | abody.flat_map do |error| 238 | error_hash \ 239 | :title=>'Middleware Error', 240 | :detail=>error 241 | end 242 | end 243 | else 244 | # `halt' 245 | error_hash \ 246 | :title=>'Unknown Error(s)', 247 | :detail=>abody.to_s 248 | end unless abody.empty? 249 | 250 | # Exception already contains formatted errors 251 | error_hashes ||= env['sinatra.error'].error_hashes \ 252 | if env['sinatra.error'].respond_to?(:error_hashes) 253 | 254 | error_hashes ||= 255 | case e = env['sinatra.error'] 256 | when UnprocessibleEntityError 257 | e.tuples.flat_map do |key, full_message, type=:attributes| 258 | error_hash \ 259 | :title=>exception_title(e), 260 | :detail=>full_message.to_s, 261 | :source=>{ 262 | :pointer=>(key ? "/data/#{type}/#{key.to_s.dasherize}" : '/data') 263 | } 264 | end 265 | when Exception 266 | error_hash \ 267 | :title=>exception_title(e), 268 | :detail=>(e.message.to_s unless e.message == e.class.name) 269 | else 270 | error_hash \ 271 | :title=>'Unknown Error' 272 | end 273 | 274 | if block = settings._sinja.error_logger 275 | error_hashes.each { |h| instance_exec(h, &block) } 276 | end 277 | 278 | content_type :api_json 279 | JSON.send settings._sinja.json_error_generator, 280 | ::JSONAPI::Serializer.serialize_errors(error_hashes) 281 | end 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /lib/sinja.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'set' 3 | 4 | require 'active_support/inflector' 5 | require 'sinatra/base' 6 | require 'sinatra/namespace' 7 | 8 | require 'sinja/config' 9 | require 'sinja/errors' 10 | require 'sinja/helpers/serializers' 11 | require 'sinja/resource' 12 | require 'sinja/version' 13 | 14 | module Sinja 15 | DEFER_CODE = 357 16 | MIME_TYPE = 'application/vnd.api+json' 17 | ERROR_CODES = ObjectSpace.each_object(Class).to_a 18 | .keep_if { |klass| klass < HttpError } 19 | .map! { |c| [(c.const_get(:HTTP_STATUS) rescue nil), c] } 20 | .delete_if { |s, _| s.nil? } 21 | .to_h.freeze 22 | 23 | def self.registered(app) 24 | abort "Sinatra::JSONAPI (Sinja) is already registered on #{app}!" \ 25 | if app.respond_to?(:_sinja) 26 | 27 | app.register Sinatra::Namespace 28 | 29 | app.disable :protection, :show_exceptions, :static 30 | app.set :_sinja, Sinja::Config.new 31 | app.set :_resource_config, nil # dummy value overridden in each resource 32 | 33 | app.set :actions do |*actions| 34 | condition do 35 | actions.each do |action| 36 | raise ForbiddenError, 'You are not authorized to perform this action' \ 37 | unless can?(action) 38 | raise MethodNotAllowedError, 'Action or method not implemented or supported' \ 39 | unless respond_to?(action) 40 | end 41 | 42 | true 43 | end 44 | end 45 | 46 | app.set :qcaptures do |*index| 47 | condition do 48 | @qcaptures ||= [] 49 | 50 | index.to_h.all? do |key, subkeys| 51 | key = key.to_s 52 | 53 | params[key].is_a?(Hash) && params[key].any? && Array(subkeys).all? do |subkey| 54 | subkey = subkey.to_s 55 | 56 | # TODO: What if deleting one is successful, but not another? 57 | # We'll need to restore the hash to its original state. 58 | @qcaptures << params[key].delete(subkey) if params[key].key?(subkey) 59 | end.tap do |ok| 60 | # If us deleting key(s) causes the hash to become empty, delete it. 61 | params.delete(key) if ok && params[key].empty? 62 | end 63 | end 64 | end 65 | end 66 | 67 | app.set :qparams do |*allow_params| 68 | allow_params = allow_params.to_set 69 | 70 | abort "Unexpected query parameter(s) in route definiton" \ 71 | unless allow_params.subset?(settings._sinja.query_params.keys.to_set) 72 | 73 | condition do 74 | params.each do |key, value| 75 | key = key.to_sym 76 | 77 | # Ignore interal Sinatra query parameters (e.g. :captures) and any 78 | # "known" query parameter set to `nil' in the configurable. 79 | next if !env['rack.request.query_hash'].key?(key.to_s) || 80 | settings._sinja.query_params.fetch(key, BasicObject).nil? 81 | 82 | raise BadRequestError, "`#{key}' query parameter not allowed" \ 83 | unless allow_params.include?(key) 84 | 85 | next if env['sinja.normalized'] == params.object_id 86 | 87 | if value.instance_of?(String) && settings._sinja.query_params[key] != String 88 | params[key.to_s] = value.split(',') 89 | elsif !value.is_a?(settings._sinja.query_params[key]) 90 | raise BadRequestError, "`#{key}' query parameter malformed" 91 | end 92 | end 93 | 94 | return true if env['sinja.normalized'] == params.object_id 95 | 96 | settings._sinja.query_params.each do |key, klass| 97 | next if klass.nil? 98 | 99 | if respond_to?("normalize_#{key}_params") 100 | params[key.to_s] = send("normalize_#{key}_params") 101 | else 102 | params[key.to_s] ||= klass.new 103 | end 104 | end 105 | 106 | # Sinatra re-initializes `params' at namespace boundaries, so we'll 107 | # reference its object_id in the flag to make sure we only re-normalize 108 | # the parameters when necessary. 109 | env['sinja.normalized'] = params.object_id 110 | end 111 | end 112 | 113 | app.set(:on) { |block| condition(&block) } 114 | 115 | app.mime_type :api_json, MIME_TYPE 116 | 117 | app.helpers Helpers::Serializers do 118 | def allow(h={}) 119 | s = Set.new 120 | h.each do |method, actions| 121 | s << method if Array(actions).all?(&method(:respond_to?)) 122 | end 123 | headers 'Allow'=>s.map(&:upcase).join(',') 124 | end 125 | 126 | def attributes 127 | dedasherize_names(data.fetch(:attributes, {})) 128 | end 129 | 130 | if method_defined?(:bad_request?) 131 | # This screws up our error-handling logic in Sinatra 2.0, so override it. 132 | # https://github.com/sinatra/sinatra/issues/1211 133 | # https://github.com/sinatra/sinatra/pull/1212 134 | def bad_request? 135 | false 136 | end 137 | end 138 | 139 | def can?(action) 140 | roles = settings._resource_config[:resource].fetch(action, {})[:roles] 141 | roles.nil? || roles.empty? || roles.intersect?(role) 142 | end 143 | 144 | def content? 145 | request.body.respond_to?(:size) && request.body.size > 0 || begin 146 | request.body.rewind 147 | request.body.read(1) 148 | end 149 | end 150 | 151 | def data 152 | @data ||= {} 153 | @data[request.path] ||= begin 154 | deserialize_request_body.fetch(:data) 155 | rescue NoMethodError, KeyError 156 | raise BadRequestError, 'Malformed {json:api} request payload' 157 | end 158 | end 159 | 160 | def normalize_filter_params 161 | return {} unless params[:filter]&.any? 162 | 163 | raise BadRequestError, "Unsupported `filter' query parameter(s)" \ 164 | unless respond_to?(:filter) 165 | 166 | params[:filter].map do |k, v| 167 | [dedasherize(k).to_sym, v] 168 | end.to_h 169 | end 170 | 171 | def filter_by?(action) 172 | return if params[:filter].empty? 173 | 174 | filter = params[:filter].map { |k, v| [k.to_sym, v] }.to_h 175 | filter_by = settings.resource_config[action][:filter_by] 176 | return filter if filter_by.empty? || filter_by.superset?(filter.keys.to_set) 177 | 178 | raise BadRequestError, "Invalid `filter' query parameter(s)" 179 | end 180 | 181 | def normalize_sort_params 182 | return {} unless params[:sort]&.any? 183 | 184 | raise BadRequestError, "Unsupported `sort' query parameter(s)" \ 185 | unless respond_to?(:sort) 186 | 187 | params[:sort].map do |k| 188 | dir = k.sub!(/^-/, '') ? :desc : :asc 189 | [dedasherize(k).to_sym, dir] 190 | end.to_h 191 | end 192 | 193 | def sort_by?(action) 194 | return if params[:sort].empty? 195 | 196 | sort = params[:sort].map { |k, v| [k.to_sym, v] }.to_h 197 | sort_by = settings.resource_config[action][:sort_by] 198 | return sort if sort_by.empty? || sort_by.superset?(sort.keys.to_set) 199 | 200 | raise BadRequestError, "Invalid `sort' query parameter(s)" 201 | end 202 | 203 | def normalize_page_params 204 | return {} unless params[:page]&.any? 205 | 206 | raise BadRequestError, "Unsupported `page' query parameter(s)" \ 207 | unless respond_to?(:page) 208 | 209 | params[:page].map do |k, v| 210 | [dedasherize(k).to_sym, v] 211 | end.to_h 212 | end 213 | 214 | def page_using? 215 | return if params[:page].empty? 216 | 217 | page = params[:page].map { |k, v| [k.to_sym, v] }.to_h 218 | return page if (page.keys - settings._sinja.page_using.keys).empty? 219 | 220 | raise BadRequestError, "Invalid `page' query parameter(s)" 221 | end 222 | 223 | def filter_sort_page?(action) 224 | return enum_for(__callee__, action) unless block_given? 225 | 226 | if filter = filter_by?(action) then yield :filter, filter end 227 | if sort = sort_by?(action) then yield :sort, sort end 228 | if page = page_using? then yield :page, page end 229 | end 230 | 231 | def filter_sort_page(collection, opts) 232 | collection = filter(collection, opts[:filter]) if opts.key?(:filter) 233 | collection = sort(collection, opts[:sort]) if opts.key?(:sort) 234 | collection, pagination = page(collection, opts[:page]) if opts.key?(:page) 235 | 236 | return respond_to?(:finalize) ? finalize(collection) : collection, pagination 237 | end 238 | 239 | def halt(code, body=nil) 240 | if exception_class = ERROR_CODES[code] 241 | raise exception_class, body 242 | elsif (400...600).include?(code.to_i) 243 | raise HttpError.new(code.to_i, body) 244 | else 245 | super 246 | end 247 | end 248 | 249 | def sideloaded? 250 | env.key?('sinja.passthru') 251 | end 252 | 253 | def role 254 | nil 255 | end 256 | 257 | def role?(*roles) 258 | Roles[*roles].intersect?(role) 259 | end 260 | 261 | def sanity_check!(resource_name, id=nil) 262 | raise ConflictError, 'Resource type in payload does not match endpoint' \ 263 | unless data[:type] && data[:type].to_sym == resource_name 264 | 265 | raise ConflictError, 'Resource ID in payload does not match endpoint' \ 266 | unless id.nil? || data[:id] && data[:id].to_s == id.to_s 267 | end 268 | 269 | def transaction 270 | yield 271 | end 272 | end 273 | 274 | app.before do 275 | unless sideloaded? 276 | raise NotAcceptableError unless request.preferred_type.entry == MIME_TYPE || request.options? 277 | raise UnsupportedTypeError if content? && ( 278 | request.media_type != MIME_TYPE || request.media_type_params.keys.any? { |k| k != 'charset' } 279 | ) 280 | end 281 | 282 | content_type :api_json 283 | end 284 | 285 | app.after do 286 | body serialize_response_body if response.successful? 287 | end 288 | 289 | app.not_found do 290 | serialize_errors 291 | end 292 | 293 | app.error 400...600 do 294 | serialize_errors 295 | end 296 | 297 | app.error StandardError do 298 | env['sinatra.error'].tap do |e| 299 | boom = 300 | if settings._sinja.not_found_exceptions.any?(&e.method(:is_a?)) 301 | NotFoundError.new(e.message) unless e.instance_of?(NotFoundError) 302 | elsif settings._sinja.conflict_exceptions.any?(&e.method(:is_a?)) 303 | ConflictError.new(e.message) unless e.instance_of?(ConflictError) 304 | elsif settings._sinja.validation_exceptions.any?(&e.method(:is_a?)) 305 | UnprocessibleEntityError.new(settings._sinja.validation_formatter.(e)) unless e.instance_of?(UnprocessibleEntityError) 306 | end 307 | 308 | handle_exception!(boom) if boom # re-throw the re-packaged exception 309 | end 310 | 311 | serialize_errors 312 | end 313 | end 314 | 315 | def resource(resource_name, konst=nil, **opts, &block) 316 | abort "Must supply proc constant or block for `resource'" \ 317 | unless block = (konst if konst.instance_of?(Proc)) || block 318 | 319 | warn "DEPRECATED: Pass a block to `resource'; the ability to pass a Proc " \ 320 | 'will be removed in a future version of Sinja.' if konst.instance_of?(Proc) 321 | 322 | resource_name = resource_name.to_s 323 | .pluralize 324 | .dasherize 325 | .to_sym 326 | 327 | # trigger default procs 328 | config = _sinja.resource_config[resource_name] 329 | 330 | # incorporate route options 331 | config[:route_opts].merge!(opts) 332 | 333 | namespace %r{/#{resource_name}(?![^/])} do 334 | define_singleton_method(:_resource_config) { config } 335 | define_singleton_method(:resource_config) { config[:resource] } 336 | 337 | helpers do 338 | define_method(:sanity_check!) do |*args| 339 | super(resource_name, *args) 340 | end 341 | end 342 | 343 | before %r{/(#{config[:route_opts][:pkre]})(?:/.*)?} do |id| 344 | self.resource = 345 | if env.key?('sinja.resource') 346 | env['sinja.resource'] 347 | elsif respond_to?(:find) 348 | find(id) 349 | else 350 | raise SinjaError, 'Resource locator not defined!' 351 | end 352 | 353 | raise NotFoundError, "Resource '#{id}' not found" unless resource 354 | end 355 | 356 | register Resource 357 | 358 | instance_eval(&block) 359 | end 360 | end 361 | 362 | alias resources resource 363 | 364 | def sinja 365 | if block_given? 366 | warn "DEPRECATED: Pass a block to `sinja.configure' instead." 367 | 368 | yield _sinja 369 | else 370 | _sinja 371 | end 372 | end 373 | 374 | def configure_jsonapi(&block) 375 | _sinja.configure(&block) 376 | end 377 | 378 | def freeze_jsonapi 379 | _sinja.freeze 380 | end 381 | 382 | def self.extended(base) 383 | def base.route(*, **opts) 384 | opts[:qparams] ||= [] 385 | 386 | super 387 | end 388 | end 389 | end 390 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sinja (Sinatra::JSONAPI) 2 | 3 | 9 | 10 | [![Gem Version](https://badge.fury.io/rb/sinja.svg)](https://badge.fury.io/rb/sinja) 11 | [![Dependency Status](https://gemnasium.com/badges/github.com/mwpastore/sinja.svg)](https://gemnasium.com/github.com/mwpastore/sinja) 12 | [![Build Status](https://travis-ci.org/mwpastore/sinja.svg?branch=master)](https://travis-ci.org/mwpastore/sinja) 13 | [![{json:api} version](https://img.shields.io/badge/%7Bjson%3Aapi%7D%20version-1.0-lightgrey.svg)][7] 14 | [![Chat in #sinja-rb on Gitter](https://badges.gitter.im/sinja-rb/Lobby.svg)](https://gitter.im/sinja-rb/Lobby) 15 | 16 | Sinja is a [Sinatra 2.0][1] [extension][10] for quickly building [RESTful][11], 17 | [{json:api}][2]-compliant web services, leveraging the excellent 18 | [JSONAPI::Serializers][3] gem for payload serialization. It enhances Sinatra's 19 | DSL to enable resource-, relationship-, and role-centric API development, and 20 | it configures Sinatra with the proper settings, MIME-types, filters, 21 | conditions, and error-handling. 22 | 23 | There are [many][31] parsing (deserializing), rendering (serializing), and 24 | other "JSON API" libraries available for Ruby, but relatively few that attempt 25 | to correctly implement the entire {json:api} server specification, including 26 | routing, request header and query parameter checking, and relationship 27 | side-loading. Sinja lets you focus on the business logic of your applications 28 | without worrying about the specification, and without pulling in a heavy 29 | framework like [Rails][16]. It's lightweight, ORM-agnostic, and 30 | [Ember.js][32]-friendly! 31 | 32 | 33 | 34 | 35 | 36 | - [Synopsis](#synopsis) 37 | - [Installation](#installation) 38 | - [Ol' Blue Eyes is Back](#ol-blue-eyes-is-back) 39 | - [Basic Usage](#basic-usage) 40 | - [Configuration](#configuration) 41 | - [Sinatra](#sinatra) 42 | - [Sinja](#sinja) 43 | - [Resources](#resources) 44 | - [Resource Locators](#resource-locators) 45 | - [Action Helpers](#action-helpers) 46 | - [`resource`](#resource) 47 | - [`has_one`](#has_one) 48 | - [`has_many`](#has_many) 49 | - [Advanced Usage](#advanced-usage) 50 | - [Action Helper Hooks & Utilities](#action-helper-hooks--utilities) 51 | - [Authorization](#authorization) 52 | - [`default_roles` configurables](#default_roles-configurables) 53 | - [`:roles` Action Helper option](#roles-action-helper-option) 54 | - [`role` helper](#role-helper) 55 | - [Query Parameters](#query-parameters) 56 | - [Working with Collections](#working-with-collections) 57 | - [Filtering](#filtering) 58 | - [Sorting](#sorting) 59 | - [Paging](#paging) 60 | - [Finalizing](#finalizing) 61 | - [Conflicts](#conflicts) 62 | - [Validations](#validations) 63 | - [Missing Records](#missing-records) 64 | - [Transactions](#transactions) 65 | - [Side-Unloading Related Resources](#side-unloading-related-resources) 66 | - [Side-Loading Relationships](#side-loading-relationships) 67 | - [Deferring Relationships](#deferring-relationships) 68 | - [Avoiding Null Foreign Keys](#avoiding-null-foreign-keys) 69 | - [Coalesced Find Requests](#coalesced-find-requests) 70 | - [Patchless Clients](#patchless-clients) 71 | - [Extensions](#extensions) 72 | - [Sequel](#sequel) 73 | - [Application Concerns](#application-concerns) 74 | - [Performance](#performance) 75 | - [Public APIs](#public-apis) 76 | - [Commonly Used](#commonly-used) 77 | - [Less-Commonly Used](#less-commonly-used) 78 | - [Sinja or Sinatra::JSONAPI](#sinja-or-sinatrajsonapi) 79 | - [Code Organization](#code-organization) 80 | - [Testing](#testing) 81 | - [Comparison with JSONAPI::Resources](#comparison-with-jsonapiresources) 82 | - [Development](#development) 83 | - [Contributing](#contributing) 84 | - [License](#license) 85 | 86 | 87 | 88 | ## Synopsis 89 | 90 | ```ruby 91 | require 'sinatra/jsonapi' 92 | 93 | resource :posts do 94 | show do |id| 95 | Post[id.to_i] 96 | end 97 | 98 | index do 99 | Post.all 100 | end 101 | 102 | create do |attr| 103 | post = Post.create(attr) 104 | next post.id, post 105 | end 106 | end 107 | 108 | freeze_jsonapi 109 | ``` 110 | 111 | Assuming the presence of a `Post` model and serializer, running the above 112 | "classic"-style Sinatra application would enable the following endpoints (with 113 | all other {json:api} endpoints returning 404 or 405): 114 | 115 | * `GET /posts/` 116 | * `GET /posts` 117 | * `POST /posts` 118 | 119 | The resource locator and other action helpers, documented below, enable other 120 | endpoints. 121 | 122 | Of course, "modular"-style Sinatra aplications (subclassing Sinatra::Base) 123 | require you to register the extension: 124 | 125 | ```ruby 126 | require 'sinatra/base' 127 | require 'sinatra/jsonapi' 128 | 129 | class App < Sinatra::Base 130 | register Sinatra::JSONAPI 131 | 132 | resource :posts do 133 | # .. 134 | end 135 | 136 | freeze_jsonapi 137 | end 138 | ``` 139 | 140 | Please see the [demo-app](/demo-app) for a more complete example. 141 | 142 | ## Installation 143 | 144 | Add this line to your application's Gemfile: 145 | 146 | ```ruby 147 | gem 'sinja' 148 | ``` 149 | 150 | And then execute: 151 | 152 | ```sh 153 | $ bundle 154 | ``` 155 | 156 | Or install it yourself as: 157 | 158 | ```sh 159 | $ gem install sinja 160 | ``` 161 | 162 | Sinja is not compatible with Sinatra 1.x due to its limitations with nested 163 | regexp-style namespaces and routes. 164 | 165 | ## Ol' Blue Eyes is Back 166 | 167 | The "power" so to speak of implementing this functionality as a Sinatra 168 | extension is that all of Sinatra's usual features are available within your 169 | resource definitions. Action helper blocks get compiled into Sinatra helpers, 170 | and the `resource`, `has_one`, and `has_many` keywords build 171 | [Sinatra::Namespace][21] blocks. You can manage caching directives, set 172 | headers, and even `halt` (or `not_found`, although such cases are usually 173 | handled transparently by returning `nil` values or empty collections from 174 | action helpers) as appropriate. 175 | 176 | ```ruby 177 | class App < Sinatra::Base 178 | register Sinatra::JSONAPI 179 | 180 | # <- This is a Sinatra::Base class definition. (Duh.) 181 | 182 | resource :books do 183 | # <- This is a Sinatra::Namespace block. 184 | 185 | show do |id| 186 | # <- This is a "special" Sinatra helper, scoped to the resource namespace. 187 | end 188 | 189 | has_one :author do 190 | # <- This is a Sinatra::Namespace block, nested under the resource namespace. 191 | 192 | pluck do 193 | # <- This is a "special" Sinatra helper, scoped to the nested namespace. 194 | end 195 | end 196 | end 197 | 198 | freeze_jsonapi 199 | end 200 | ``` 201 | 202 | This lets you easily pepper in all the syntactic sugar you might expect to see 203 | in a typical Sinatra application: 204 | 205 | ```ruby 206 | class App < Sinatra::Base 207 | register Sinatra::JSONAPI 208 | 209 | configure :development do 210 | enable :logging 211 | end 212 | 213 | helpers do 214 | def foo; true end 215 | end 216 | 217 | before do 218 | cache_control :public, max_age: 3_600 219 | end 220 | 221 | # define a custom /status route 222 | get('/status', provides: :json) { 'OK' } 223 | 224 | resource :books do 225 | helpers do 226 | def find(id) 227 | Book[id.to_i] 228 | end 229 | end 230 | 231 | show do 232 | headers 'X-ISBN'=>resource.isbn 233 | last_modified resource.updated_at 234 | next resource, include: ['author'] 235 | end 236 | 237 | has_one :author do 238 | helpers do 239 | def bar; false end 240 | end 241 | 242 | before do 243 | cache_control :private 244 | halt 403 unless foo || bar 245 | end 246 | 247 | pluck do 248 | etag resource.author.hash, :weak 249 | resource.author 250 | end 251 | end 252 | 253 | # define a custom /books/top10 route 254 | get '/top10' do 255 | halt 403 unless can?(:index) # restrict access to those with index rights 256 | 257 | serialize_models Book.where{}.reverse_order(:recent_sales).limit(10).all 258 | end 259 | end 260 | 261 | freeze_jsonapi 262 | end 263 | ``` 264 | 265 | ## Basic Usage 266 | 267 | You'll need a database schema and models (using the engine and ORM of your 268 | choice) and [serializers][3] to get started. Create a new Sinatra application 269 | (classic or modular) to hold all your {json:api} controllers and (if 270 | subclassing Sinatra::Base) register this extension. Instead of defining routes 271 | with `get`, `post`, etc. as you normally would, define `resource` blocks with 272 | action helpers and `has_one` and `has_many` relationship blocks (with their own 273 | action helpers). Sinja will draw and enable the appropriate routes based on the 274 | defined resources, relationships, and action helpers. Other routes will return 275 | the appropriate HTTP statuses: 403, 404, or 405. 276 | 277 | ### Configuration 278 | 279 | #### Sinatra 280 | 281 | Registering this extension has a number of application-wide implications, 282 | detailed below. If you have any non-{json:api} routes, you may want to keep them 283 | in a separate application and incorporate them as middleware or mount them 284 | elsewhere (e.g. with [Rack::URLMap][4]), or host them as a completely separate 285 | web service. It may not be feasible to have custom routes that don't conform to 286 | these settings. 287 | 288 | * Registers [Sinatra::Namespace][21] and [Mustermann][25] 289 | * Disables [Rack::Protection][6] (can be reenabled with `enable :protection` or 290 | by manually `use`-ing the Rack::Protection middleware) 291 | * Disables static file routes (can be reenabled with `enable :static`; be sure 292 | to reenable Rack::Protection::PathTraversal as well) 293 | * Disables "classy" error pages (in favor of "classy" {json:api} error documents) 294 | * Adds an `:api_json` MIME-type (`application/vnd.api+json`) 295 | * Enforces strict checking of the `Accept` and `Content-Type` request headers 296 | * Sets the `Content-Type` response header to `:api_json` (can be overriden with 297 | the `content_type` helper) 298 | * Normalizes and strictly enforces query parameters to reflect the features 299 | supported by {json:api} 300 | * Formats all errors to the proper {json:api} structure 301 | * Serializes all response bodies (including errors) to JSON 302 | * Modifies `halt` and `not_found` to raise exceptions instead of just setting 303 | the status code and body of the response 304 | 305 | #### Sinja 306 | 307 | Sinja provides its own configuration store that can be accessed through the 308 | `configure_jsonapi` block. The following configurables are available (with 309 | their defaults shown): 310 | 311 | ```ruby 312 | configure_jsonapi do |c| 313 | #c.conflict_exceptions = [] # see "Conflicts" below 314 | 315 | #c.not_found_exceptions = [] # see "Missing Records" below 316 | 317 | # see "Validations" below 318 | #c.validation_exceptions = [] 319 | #c.validation_formatter = ->{ [] } 320 | 321 | # see "Authorization" below 322 | #c.default_roles = {} 323 | #c.default_has_one_roles = {} 324 | #c.default_has_many_roles = {} 325 | 326 | # You can't set this directly; see "Query Parameters" below 327 | #c.query_params = { 328 | # :include=>Array, :fields=>Hash, :filter=>Hash, :page=>Hash, :sort=>Array 329 | #} 330 | 331 | #c.page_using = {} # see "Paging" below 332 | 333 | # Set the error logger used by Sinja (set to `nil' to disable) 334 | #c.error_logger = ->(error_hash) { logger.error('sinja') { error_hash } } 335 | 336 | # A hash of options to pass to JSONAPI::Serializer.serialize 337 | #c.serializer_opts = {} 338 | 339 | # JSON methods to use when serializing response bodies and errors 340 | #c.json_generator = development? ? :pretty_generate : :generate 341 | #c.json_error_generator = development? ? :pretty_generate : :generate 342 | end 343 | ``` 344 | 345 | The above structures are mutable (e.g. you can do `c.conflict_exceptions << 346 | FooError` and `c.serializer_opts[:meta] = { foo: 'bar' }`) until you call 347 | `freeze_jsonapi` to freeze the configuration store. **You should always freeze 348 | the store after Sinja is configured and all your resources are defined.** 349 | 350 | ### Resources 351 | 352 | Resources declared with the `resource` keyword (and relationships declared with 353 | the `has_many` and `has_one` keywords) are dasherized and pluralized to match 354 | the "type" property of JSONAPI::Serializers. For example, `resource :foo_bar` 355 | would instruct Sinja to draw the appropriate routes under `/foo-bars`. Your 356 | serializer type(s) should always match your resource (and relationship) names; 357 | see the relevant [documentation][33] for more information. 358 | 359 | The primary key portion of the route is extracted using a regular expression, 360 | `\d+` by default. To use a different pattern, pass the `:pkre` resource route 361 | option: 362 | 363 | ```ruby 364 | resource :foo_bar, pkre: /\d+-\d+/ do 365 | helpers do 366 | def find(id) 367 | # Look up a FooBar with a composite primary key of two integers. 368 | FooBar[id.split('-', 2).map!(&:to_i)] 369 | end 370 | end 371 | 372 | # .. 373 | end 374 | ``` 375 | 376 | This helps Sinja (and Sinatra) disambiguate between standard {json:api} routes 377 | used to fetch resources (e.g. `GET /foo-bars/1`) and similarly-structured 378 | custom routes (e.g. `GET /foo-bars/recent`). 379 | 380 | ### Resource Locators 381 | 382 | Much of Sinja's advanced functionality (e.g. updating and destroying resources, 383 | relationship routes) is dependent upon its ability to locate the corresponding 384 | resource for a request. To enable these features, define an ordinary helper 385 | method named `find` in your resource definition that takes a single ID argument 386 | and returns the corresponding object. Once defined, a `resource` object will be 387 | made available in any action helpers that operate on a single (parent) 388 | resource. 389 | 390 | ```ruby 391 | resource :posts do 392 | helpers do 393 | def find(id) 394 | Post[id.to_i] 395 | end 396 | end 397 | 398 | show do 399 | next resource, include: 'comments' 400 | end 401 | end 402 | ``` 403 | 404 | * What's the difference between `find` and `show`? 405 | 406 | You can think of it as the difference between a Model and a View: `find` 407 | retrieves the record, `show` presents it. 408 | 409 | * Why separate the two? Why not use `show` as the resource locator? 410 | 411 | For a variety of reasons, but primarily because the access rights for viewing 412 | a resource are not always the same as those for updating and/or destroying a 413 | resource, and vice-versa. For example, a user may be able to delete a 414 | resource or subtract a relationship link without being able to see the 415 | resource or its relationship linkage. 416 | 417 | * How do I control access to the resource locator? 418 | 419 | You don't. Instead, control access to the action helpers that use it: `show`, 420 | `update`, `destroy`, and all of the relationship action helpers such as 421 | `pluck` and `fetch`. 422 | 423 | * What happens if I define an action helper that requires a resource locator, 424 | but don't define a resource locator? 425 | 426 | Sinja will act as if you had not defined the action helper. 427 | 428 | As a bit of syntactic sugar, if you define a `find` helper and subsequently 429 | call `show` without a block, Sinja will generate a `show` action helper that 430 | simply returns `resource`. 431 | 432 | ### Action Helpers 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 452 | 457 | 464 | 465 | 466 |
resourcehas_onehas_many
467 | 468 | Action helpers should be defined within the appropriate block contexts 469 | (`resource`, `has_one`, or `has_many`) using the given keywords and arguments 470 | below. Implicitly return the expected values as described below (as an array if 471 | necessary) or use the `next` keyword (instead of `return` or `break`) to exit 472 | the action helper. Return values with a question mark below may be omitted 473 | entirely. Any helper may additionally return an options hash to pass along to 474 | JSONAPI::Serializer.serialize (which will be merged into the global 475 | `serializer_opts` described above). The `:include` (see "Side-Unloading Related 476 | Resources" below) and `:fields` (for sparse fieldsets) query parameters are 477 | automatically passed through to JSONAPI::Serializers. 478 | 479 | All arguments to action helpers are "tainted" and should be treated as 480 | potentially dangerous: IDs, attribute hashes, and (arrays of) [resource 481 | identifier object][22] hashes. 482 | 483 | Finally, some routes will automatically invoke the resource locator on your 484 | behalf and make the selected resource available to the corresponding action 485 | helper(s) as `resource`. For example, the `PATCH //:id` route looks up 486 | the resource with that ID using the `find` resource locator and makes it 487 | available to the `update` action helper as `resource`. 488 | 489 | #### `resource` 490 | 491 | ##### `index {..}` => Array 492 | 493 | Return an array of zero or more objects to serialize on the response. 494 | 495 | ##### `show {|id| ..}` => Object 496 | 497 | Without a resource locator: Take an ID and return the corresponding object (or 498 | `nil` if not found) to serialize on the response. (Note that only one or the 499 | other `show` action helpers is allowed in any given resource block.) 500 | 501 | ##### `show {..}` => Object 502 | 503 | With a resource locator: Return the `resource` object to serialize on the 504 | response. (Note that only one or the other `show` action helpers is allowed in 505 | any given resource block.) 506 | 507 | ##### `show_many {|ids| ..}` => Array 508 | 509 | Take an array of IDs and return an equally-lengthed array of objects to 510 | serialize on the response. See "Coalesced Find Requests" below. 511 | 512 | ##### `create {|attr| ..}` => id, Object 513 | 514 | Without client-generated IDs: Take a hash of (dedasherized) attributes, create 515 | a new resource, and return the server-generated ID and the created resource. 516 | (Note that only one or the other `create` action helpers is allowed in any 517 | given resource block.) 518 | 519 | ##### `create {|attr, id| ..}` => id, Object? 520 | 521 | With client-generated IDs: Take a hash of (dedasherized) attributes and a 522 | client-generated ID, create a new resource, and return the ID and optionally 523 | the created resource. (Note that only one or the other `create` action helpers 524 | is allowed in any given resource block.) 525 | 526 | ##### `update {|attr| ..}` => Object? 527 | 528 | Take a hash of (dedasherized) attributes, update `resource`, and optionally 529 | return the updated resource. **Requires a resource locator.** 530 | 531 | ##### `destroy {..}` 532 | 533 | Delete or destroy `resource`. **Requires a resource locator.** 534 | 535 | #### `has_one` 536 | 537 | **Requires a resource locator.** 538 | 539 | ##### `pluck {..}` => Object 540 | 541 | Return the related object vis-à-vis `resource` to serialize on the 542 | response. 543 | 544 | ##### `prune {..}` => TrueClass? 545 | 546 | Remove the relationship from `resource`. To serialize the updated linkage on 547 | the response, refresh or reload `resource` (if necessary) and return a truthy 548 | value. 549 | 550 | For example, using [Sequel][13]: 551 | 552 | ```ruby 553 | has_one :qux do 554 | prune do 555 | resource.qux = nil 556 | resource.save_changes # will return truthy if the relationship was present 557 | end 558 | end 559 | ``` 560 | 561 | ##### `graft {|rio| ..}` => TrueClass? 562 | 563 | Take a [resource identifier object][22] hash and update the relationship on 564 | `resource`. To serialize the updated linkage on the response, refresh or reload 565 | `resource` (if necessary) and return a truthy value. 566 | 567 | #### `has_many` 568 | 569 | **Requires a resource locator.** 570 | 571 | ##### `fetch {..}` => Array 572 | 573 | Return an array of related objects vis-à-vis `resource` to serialize on 574 | the response. 575 | 576 | ##### `clear {..}` => TrueClass? 577 | 578 | Remove all relationships from `resource`. To serialize the updated linkage on 579 | the response, refresh or reload `resource` (if necessary) and return a truthy 580 | value. 581 | 582 | For example, using [Sequel][13]: 583 | 584 | ```ruby 585 | has_many :bars do 586 | clear do 587 | resource.remove_all_bars # will return truthy if relationships were present 588 | end 589 | end 590 | ``` 591 | 592 | ##### `replace {|rios| ..}` => TrueClass? 593 | 594 | Take an array of [resource identifier object][22] hashes and update 595 | (add/remove) the relationships on `resource`. To serialize the updated linkage 596 | on the response, refresh or reload `resource` (if necessary) and return a 597 | truthy value. 598 | 599 | In principle, `replace` should delete all members of the existing collection 600 | and insert all members of a new collection, but in practice—for 601 | performance reasons, especially with large collections and/or complex 602 | constraints—it may be prudent to simply apply a delta. 603 | 604 | ##### `merge {|rios| ..}` => TrueClass? 605 | 606 | Take an array of [resource identifier object][22] hashes and update (add unless 607 | already present) the relationships on `resource`. To serialize the updated 608 | linkage on the response, refresh or reload `resource` (if necessary) and return 609 | a truthy value. 610 | 611 | ##### `subtract {|rios| ..}` => TrueClass? 612 | 613 | Take an array of [resource identifier object][22] hashes and update (remove 614 | unless already missing) the relationships on `resource`. To serialize the 615 | updated linkage on the response, refresh or reload `resource` (if necessary) 616 | and return a truthy value. 617 | 618 | ## Advanced Usage 619 | 620 | ### Action Helper Hooks & Utilities 621 | 622 | You may remove a previously-registered action helper with `remove_`: 623 | 624 | ```ruby 625 | resource :foos do 626 | index do 627 | # .. 628 | end 629 | 630 | remove_index 631 | end 632 | ``` 633 | 634 | You may invoke an action helper keyword without a block to modify the options 635 | (i.e. roles and sideloading) of a previously-registered action helper while 636 | preseving the existing behavior: 637 | 638 | ```ruby 639 | resource :bars do 640 | show do |id| 641 | # .. 642 | end 643 | 644 | show(roles: :admin) # restrict the above action helper to the `admin' role 645 | end 646 | ``` 647 | 648 | You may define an ordinary helper method named `before_` (in the 649 | resource or relationship scope or any parent scopes) that takes the same 650 | arguments as the corresponding block: 651 | 652 | ```ruby 653 | helpers do 654 | def before_create(attr) 655 | halt 400 unless valid_key?(attr.delete(:special_key)) 656 | end 657 | end 658 | 659 | resource :quxes do 660 | create do |attr| 661 | attr.key?(:special_key) # => false 662 | end 663 | end 664 | ``` 665 | 666 | Any changes made to attribute hashes or (arrays of) resource identifier object 667 | hashes in a `before` hook will be persisted to the action helper. 668 | 669 | ### Authorization 670 | 671 | Sinja provides a simple role-based authorization scheme to restrict access to 672 | routes based on the action helpers they invoke. For example, you might say all 673 | logged-in users have access to `index`, `show`, `pluck`, and `fetch` (the 674 | read-only action helpers), but only administrators have access to `create`, 675 | `update`, etc. (the read-write action helpers). You can have as many roles as 676 | you'd like, e.g. a super-administrator role to restrict access to `destroy`. 677 | Users can be in one or more roles, and action helpers can be restricted to one 678 | or more roles for maximum flexibility. 679 | 680 | The scheme is 100% opt-in. If you prefer to use [Pundit][34] or some other gem 681 | to handle authorization, go nuts! 682 | 683 | There are three main components to Sinja's built-in scheme: 684 | 685 | #### `default_roles` configurables 686 | 687 | You set the default roles for the entire Sinja application in the top-level 688 | configuration. Action helpers without any default roles are unrestricted by 689 | default. 690 | 691 | ```ruby 692 | configure_jsonapi do |c| 693 | # Resource roles 694 | c.default_roles = { 695 | index: :user, 696 | show: :user, 697 | create: :admin, 698 | update: :admin, 699 | destroy: :super 700 | } 701 | 702 | # To-one relationship roles 703 | c.default_has_one_roles = { 704 | pluck: :user, 705 | prune: :admin, 706 | graft: :admin 707 | } 708 | 709 | # To-many relationship roles 710 | c.default_has_many_roles = { 711 | fetch: :user, 712 | clear: :admin, 713 | replace: :admin, 714 | merge: :admin, 715 | subtract: :admin 716 | } 717 | end 718 | ``` 719 | 720 | #### `:roles` Action Helper option 721 | 722 | To override the default roles for any given action helper, specify a `:roles` 723 | option when defining it. To remove all restrictions from an action helper, set 724 | `:roles` to an empty array. For example, to manage access to `show` at 725 | different levels of granularity (with the above default roles): 726 | 727 | ```ruby 728 | resource :foos do 729 | show do 730 | # any logged-in user (with the `user' role) can access /foos/:id 731 | end 732 | end 733 | 734 | resource :bars do 735 | show(roles: :admin) do 736 | # only logged-in users with the `admin' role can access /bars/:id 737 | end 738 | end 739 | 740 | resource :quxes do 741 | show(roles: []) do 742 | # anyone (bypassing the `role' helper) can access /quxes/:id 743 | end 744 | end 745 | ``` 746 | 747 | #### `role` helper 748 | 749 | Finally, define a `role` helper in your application that returns the user's 750 | role(s) (if any). You can handle login failures in your middleware, elsewhere 751 | in the application (i.e. a `before` filter), or within the helper, either by 752 | raising an error or by letting Sinja raise an error on restricted action 753 | helpers when `role` returns `nil` (the default behavior). 754 | 755 | ```ruby 756 | helpers do 757 | def role 758 | env['my_auth_middleware'].login! 759 | session[:roles] 760 | rescue MyAuthenticationFailure=>e 761 | nil 762 | end 763 | end 764 | ``` 765 | 766 | If you need more fine-grained control, for example if your action helper logic 767 | varies by the user's role, you can use a switch statement on `role` along with 768 | the `Sinja::Roles` utility class: 769 | 770 | ```ruby 771 | index(roles: [:user, :admin, :super]) do 772 | case role 773 | when Sinja::Roles[:user] 774 | # logic specific to the `user' role 775 | when Sinja::Roles[:admin, :super] 776 | # logic specific to administrative roles 777 | end 778 | end 779 | ``` 780 | 781 | Or use the `role?` helper: 782 | 783 | ```ruby 784 | show do |id| 785 | exclude = [] 786 | exclude << 'secrets' unless role?(:admin) 787 | 788 | next resource, exclude: exclude 789 | end 790 | ``` 791 | 792 | You can append resource- or even relationship-specific roles by defining a 793 | nested helper and calling `super` (keeping in mind that `resource` may be 794 | `nil`). 795 | 796 | ```ruby 797 | helpers do 798 | def role 799 | [:user] if logged_in_user 800 | end 801 | end 802 | 803 | resource :foos do 804 | helpers do 805 | def role 806 | super.tap do |a| 807 | a << :owner if resource&.owner == logged_in_user 808 | end 809 | end 810 | end 811 | 812 | create(roles: :user) {|attr| .. } 813 | update(roles: :owner) {|attr| .. } 814 | end 815 | ``` 816 | 817 | Please see the [demo-app](/demo-app) for a more complete example. 818 | 819 | Finally, because the `role` helper is invoked several times and may return 820 | different results throughout the request lifecycle, Sinja does not memoize 821 | (cache the return value keyed by function signature) it. If you have an 822 | expensive component of your role helper that is not context-dependent, it may 823 | be worth memoizing yourself: 824 | 825 | ```ruby 826 | helpers do 827 | def role 828 | @roles ||= expensive_role_lookup.freeze 829 | 830 | @roles.dup.tap do |a| 831 | a << :foo if bar 832 | end 833 | end 834 | end 835 | ``` 836 | 837 | ### Query Parameters 838 | 839 | The {json:api} specification states that any unhandled query parameters should 840 | cause the request to abort with HTTP status 400. To enforce this requirement, 841 | Sinja maintains a global "whitelist" of acceptable query parameters as well as 842 | a per-route whitelist, and interrogates your application to see which features 843 | it supports; for example, a route may generally allow a `filter` query 844 | parameter, but you may not have defined a `filter` helper. 845 | 846 | To let a custom query parameter through to the standard action helpers, add it 847 | to the `query_params` configurable with a `nil` value: 848 | 849 | ```ruby 850 | configure_jsonapi do |c| 851 | c.query_params[:foo] = nil 852 | end 853 | ``` 854 | 855 | To let a custom route accept standard query parameters, add a `:qparams` route 856 | condition: 857 | 858 | ```ruby 859 | get '/top10', qparams: [:include, :sort] do 860 | # .. 861 | end 862 | ``` 863 | 864 | ### Working with Collections 865 | 866 | #### Filtering 867 | 868 | Allow clients to filter the collections returned by the `index` and `fetch` 869 | action helpers by defining a `filter` helper in the appropriate scope that 870 | takes a collection and a hash of `filter` query parameters (with its top-level 871 | keys dedasherized and symbolized) and returns the filtered collection. You may 872 | also set a `:filter_by` option on the action helper to an array of symbols 873 | representing the "filter-able" fields for that resource. 874 | 875 | For example, to implement simple equality filters using Sequel: 876 | 877 | ```ruby 878 | helpers do 879 | def filter(collection, fields={}) 880 | collection.where(fields) 881 | end 882 | end 883 | 884 | resource :posts do 885 | index(filter_by: [:title, :type]) do 886 | Foo # return a Sequel::Dataset (instead of an array of Sequel::Model instances) 887 | end 888 | end 889 | ``` 890 | 891 | The easiest way to set a default filter is to tweak the post-processed query 892 | parameter(s) in a `before_` hook: 893 | 894 | ```ruby 895 | resource :posts do 896 | helpers do 897 | def before_index 898 | params[:filter][:type] = 'article' if params[:filter].empty? 899 | end 900 | end 901 | 902 | index do 903 | # .. 904 | end 905 | end 906 | ``` 907 | 908 | #### Sorting 909 | 910 | Allow clients to sort the collections returned by the `index` and `fetch` 911 | action helpers by defining a `sort` helper in the appropriate scope that takes 912 | a collection and a hash of `sort` query parameters (with its top-level keys 913 | dedasherized and symbolized) and returns the sorted collection. The hash values 914 | are either `:asc` (to sort ascending) or `:desc` (to sort descending). You may 915 | also set a `:sort_by` option on the action helper to an array of symbols 916 | representing the "sort-able" fields for that resource. 917 | 918 | For example, to implement sorting using Sequel: 919 | 920 | ```ruby 921 | helpers do 922 | def sort(collection, fields={}) 923 | collection.order(*fields.map {|k, v| Sequel.send(v, k) }) 924 | end 925 | end 926 | 927 | resource :posts do 928 | index(sort_by: :created_at) do 929 | Foo # return a Sequel::Dataset (instead of an array of Sequel::Model instances) 930 | end 931 | end 932 | ``` 933 | 934 | The easiest way to set a default sort order is to tweak the post-processed 935 | query parameter(s) in a `before_` hook: 936 | 937 | ```ruby 938 | resource :posts do 939 | helpers do 940 | def before_index 941 | params[:sort][:title] = :asc if params[:sort].empty? 942 | end 943 | end 944 | 945 | index do 946 | # .. 947 | end 948 | end 949 | ``` 950 | 951 | #### Paging 952 | 953 | Allow clients to page the collections returned by the `index` and `fetch` 954 | action helpers by defining a `page` helper in the appropriate scope that takes 955 | a collection and a hash of `page` query parameters (with its top-level keys 956 | dedasherized and symbolized) and returns the paged collection along with a 957 | special nested hash used as root metadata and to build the paging links. 958 | 959 | The top-level keys of the hash returned by this method must be members of the 960 | set: {`:self`, `:first`, `:prev`, `:next`, `:last`}. The values of the hash are 961 | hashes themselves containing the query parameters used to construct the 962 | corresponding link. For example, the hash: 963 | 964 | ```ruby 965 | { 966 | prev: { 967 | number: 3, 968 | size: 10 969 | }, 970 | next: { 971 | number: 5, 972 | size: 10 973 | } 974 | } 975 | ``` 976 | 977 | Could be used to build the following top-level links in the response document: 978 | 979 | ```json 980 | "links": { 981 | "prev": "/posts?page[number]=3&page[size]=10", 982 | "next": "/posts?page[number]=5&page[size]=10" 983 | } 984 | ``` 985 | 986 | You must also set the `page_using` configurable to a hash of symbols 987 | representing the paging fields used in your application (for example, `:number` 988 | and `:size` for the above example) along with their default values (or `nil`). 989 | Please see the [Sequel extension][30] for a detailed, working example. 990 | 991 | The easiest way to page a collection by default is to tweak the post-processed 992 | query parameter(s) in a `before_` hook: 993 | 994 | ```ruby 995 | resource :posts do 996 | helpers do 997 | def before_index 998 | params[:page][:number] = 1 if params[:page].empty? 999 | end 1000 | end 1001 | 1002 | index do 1003 | # .. 1004 | end 1005 | end 1006 | ``` 1007 | 1008 | #### Finalizing 1009 | 1010 | If you need to perform any additional actions on a collection after it is 1011 | filtered, sorted, and/or paged, but before it is serialized, define a 1012 | `finalize` helper that takes a collection and returns the finalized collection. 1013 | For example, to convert Sequel datasets to arrays of models before 1014 | serialization: 1015 | 1016 | ```ruby 1017 | helpers do 1018 | def finalize(collection) 1019 | collection.all 1020 | end 1021 | end 1022 | ``` 1023 | 1024 | ### Conflicts 1025 | 1026 | If your database driver raises exceptions on constraint violations, you should 1027 | specify which exception class(es) should be handled and return HTTP status 409. 1028 | 1029 | For example, using [Sequel][13]: 1030 | 1031 | ```ruby 1032 | configure_jsonapi do |c| 1033 | c.conflict_exceptions << Sequel::ConstraintViolation 1034 | end 1035 | ``` 1036 | 1037 | ### Validations 1038 | 1039 | If your ORM raises exceptions on validation errors, you should specify which 1040 | exception class(es) should be handled and return HTTP status 422, along 1041 | with a formatter proc that transforms the exception object into an array of 1042 | two-element arrays containing the name or symbol of the attribute that failed 1043 | validation and the detailed errror message for that attribute. 1044 | 1045 | For example, using [Sequel][13]: 1046 | 1047 | ```ruby 1048 | configure_jsonapi do |c| 1049 | c.validation_exceptions << Sequel::ValidationFailed 1050 | c.validation_formatter = ->(e) { e.errors.keys.zip(e.errors.full_messages) } 1051 | end 1052 | ``` 1053 | 1054 | ### Missing Records 1055 | 1056 | If your database driver raises exceptions on missing records, you should 1057 | specify which exception class(es) should be handled and return HTTP status 404. 1058 | This is particularly useful for relationship action helpers, which don't have 1059 | access to a dedicated subresource locator. 1060 | 1061 | For example, using [Sequel][13]: 1062 | 1063 | ```ruby 1064 | configure_jsonapi do |c| 1065 | c.not_found_exceptions << Sequel::NoMatchingRow 1066 | end 1067 | ``` 1068 | 1069 | ### Transactions 1070 | 1071 | If your database driver support transactions, you should define a yielding 1072 | `transaction` helper in your application for Sinja to use when working with 1073 | sideloaded data in the request. For example, if relationship data is provided 1074 | in the request payload when creating resources, Sinja will automatically farm 1075 | out to other routes to build those relationships after the resource is created. 1076 | If any step in that process fails, ideally the parent resource and any 1077 | relationships would be rolled back before returning an error message to the 1078 | requester. 1079 | 1080 | For example, using [Sequel][13] with the database handle stored in the constant 1081 | `DB`: 1082 | 1083 | ```ruby 1084 | helpers do 1085 | def transaction 1086 | DB.transaction { yield } 1087 | end 1088 | end 1089 | ``` 1090 | 1091 | ### Side-Unloading Related Resources 1092 | 1093 | You may pass an `:include` serializer option (which can be either a 1094 | comma-delimited string or array of strings) when returning resources from 1095 | action helpers. This instructs JSONAPI::Serializers to include a default set of 1096 | related resources along with the primary resource. If the client specifies an 1097 | `include` query parameter, Sinja will automatically pass it to 1098 | JSONAPI::Serializer.serialize, replacing any default value. You may also pass a 1099 | Sinja-specific `:exclude` option to prevent certain related resources from 1100 | being included in the response. If you exclude a resource, its descendents will 1101 | be automatically excluded as well. Feedback welcome. 1102 | 1103 | Sinja will attempt to automatically exclude related resources based on the 1104 | current user's role(s) and any available `pluck` and `fetch` action helper 1105 | roles. For example, if resource Foo has many Bars and the current user does not 1106 | have access to Foo.Bars#fetch, the user will not be able to include Bars. It 1107 | will traverse the roles configuration, so if the current user has access to 1108 | Foo.Bars#fetch but not Bars.Qux#pluck, the user will be able to include Bars 1109 | but not Bars.Qux. This feature is experimental. Note that in contrast to the 1110 | `:exclude` option, if a related resource is excluded by this mechanism, its 1111 | descendents will _not_ be automatically excluded. 1112 | 1113 | ### Side-Loading Relationships 1114 | 1115 | Sinja works hard to DRY up your business logic. As mentioned above, when a 1116 | request comes in to create or update a resource and that request includes 1117 | relationships, Sinja will try to farm out the work to your defined relationship 1118 | routes. Let's look at this example request from the {json:api} specification: 1119 | 1120 | ``` 1121 | POST /photos HTTP/1.1 1122 | Content-Type: application/vnd.api+json 1123 | Accept: application/vnd.api+json 1124 | ``` 1125 | 1126 | ```json 1127 | { 1128 | "data": { 1129 | "type": "photos", 1130 | "attributes": { 1131 | "title": "Ember Hamster", 1132 | "src": "http://example.com/images/productivity.png" 1133 | }, 1134 | "relationships": { 1135 | "photographer": { 1136 | "data": { "type": "people", "id": "9" } 1137 | } 1138 | } 1139 | } 1140 | } 1141 | ``` 1142 | 1143 | Assuming a `:photos` resource with a `has_one :photographer` relationship in 1144 | the application, and `graft` is configured to sideload on `create` (more on 1145 | this in a moment), Sinja will invoke the following action helpers in turn: 1146 | 1147 | 1. `create` on the Photos resource (with `data.attributes`) 1148 | 1. `graft` on the Photographer relationship (with 1149 | `data.relationships.photographer.data`) 1150 | 1151 | If any step of the process fails—for example, if the `graft` action 1152 | helper is not defined in the Photographer relationship, or if it does not 1153 | permit sideloading from `create`, or if it raises an error—the entire 1154 | request will fail and any database changes will be rolled back (given a 1155 | `transaction` helper). Note that the user's role must grant them access to call 1156 | either `graft` or `create`. 1157 | 1158 | `create` and `update` are the resource action helpers that trigger sideloading; 1159 | `graft` and `prune` are the to-one action helpers invoked by sideloading; and 1160 | `replace`, `merge`, and `clear` are the to-many action helpers invoked by 1161 | sideloading. You must indicate which combinations are valid using the 1162 | `:sideload_on` action helper option. For example: 1163 | 1164 | ```ruby 1165 | resource :photos do 1166 | helpers do 1167 | def find(id) ..; end 1168 | end 1169 | 1170 | create {|attr| .. } 1171 | 1172 | has_one :photographer do 1173 | # Allow `create' to sideload Photographer 1174 | graft(sideload_on: :create) {|rio| .. } 1175 | end 1176 | 1177 | has_many :tags do 1178 | # Allow `create' to sideload Tags 1179 | merge(sideload_on: :create) {|rios| .. } 1180 | end 1181 | end 1182 | ``` 1183 | 1184 | The following matrix outlines which combinations of action helpers and 1185 | `:sideload_on` options enable which behaviors: 1186 | 1187 | 1188 | 1189 | 1190 | 1191 | 1192 | 1193 | 1194 | 1195 | 1196 | 1197 | 1198 | 1199 | 1200 | 1201 | 1202 | 1203 | 1204 | 1205 | 1206 | 1207 | 1208 | 1209 | 1210 | 1211 | 1212 | 1213 | 1214 | 1215 | 1216 | 1217 | 1218 | 1219 | 1220 | 1221 | 1222 | 1223 | 1224 |
Desired behaviorFor to-one relationship(s)For to-many relationship(s)
Define Action HelperWith :sideload_onDefine Action HelperWith :sideload_on
Set relationship(s) when creating resourcegraft:createmerge:create
Set relationship(s) when updating resourcegraft:updatereplace:update
Delete relationship(s) when updating resourceprune:updateclear:update
1225 | 1226 | #### Deferring Relationships 1227 | 1228 | If you're side-loading multiple relationships, you may need one applied before 1229 | another (e.g. set the author of a post before setting its tags). You can use 1230 | the built-in `defer` helper to affect the order of operations: 1231 | 1232 | ```ruby 1233 | has_one :author do 1234 | graft(sideload_on: :create) do |rio| 1235 | resource.author = Author.with_pk!(rio[:id].to_i) 1236 | resource.save_changes 1237 | end 1238 | end 1239 | 1240 | has_many :tags do 1241 | merge(sideload_on: :create) do |rios| 1242 | defer unless resource.author # come back to this if the author isn't set yet 1243 | 1244 | tags = resource.author.preferred_tags 1245 | # .. 1246 | end 1247 | end 1248 | ``` 1249 | 1250 | #### Avoiding Null Foreign Keys 1251 | 1252 | Now, let's say our DBA is forward-thinking and wants to make the foreign key 1253 | constraint between the `photographer_id` column on the Photos table and the 1254 | People table non-nullable. Unfortunately, that will break Sinja, because the 1255 | Photo will be inserted first, with a null Photographer. (Deferrable constraints 1256 | would be a perfect solution to this problem, but `NOT NULL` constraints are not 1257 | deferrable in Postgres, and constraints in general are not deferrable in 1258 | MySQL.) 1259 | 1260 | Instead, we'll need to enforce our non-nullable relationships at the 1261 | application level. To accomplish this, define an ordinary helper named 1262 | `validate!` (in the resource scope or any parent scopes). This method, if 1263 | present, is invoked from within the transaction after the entire request has 1264 | been processed, and so can abort the transaction (following your ORM's 1265 | semantics). For example: 1266 | 1267 | ```ruby 1268 | resource :photos do 1269 | helpers do 1270 | def validate! 1271 | fail 'Invalid Photographer for Photo' if resource.photographer.nil? 1272 | end 1273 | end 1274 | end 1275 | ``` 1276 | 1277 | If your ORM supports validation—and "deferred validation"—you can 1278 | easily handle all such situations (as well as other types of validations) at 1279 | the top-level of your application. (Make sure to define your validation 1280 | exceptions and formatter as described above.) For example, using [Sequel][13]: 1281 | 1282 | ```ruby 1283 | class Photo < Sequel::Model 1284 | many_to_one :photographer 1285 | 1286 | # http://sequel.jeremyevans.net/rdoc/files/doc/validations_rdoc.html 1287 | def validate 1288 | super 1289 | errors.add(:photographer, 'cannot be null') if photographer.nil? 1290 | end 1291 | end 1292 | 1293 | helpers do 1294 | def validate! 1295 | raise Sequel::ValidationFailed, resource.errors unless resource.valid? 1296 | end 1297 | end 1298 | 1299 | resource :photos do 1300 | create do |attr| 1301 | photo = Photo.new 1302 | photo.set(attr) 1303 | photo.save(validate: false) # defer validation 1304 | next photo.id, photo 1305 | end 1306 | 1307 | has_one :photographer do 1308 | graft(sideload_on: :create) do |rio| 1309 | resource.photographer = People.with_pk!(rio[:id].to_i) 1310 | resource.save_changes(validate: !sideloaded?) # defer validation if sideloaded 1311 | end 1312 | end 1313 | end 1314 | ``` 1315 | 1316 | Note that the `validate!` hook is _only_ invoked from within transactions 1317 | involving the `create` and `update` action helpers (and any action helpers 1318 | invoked via the sideloading mechanism), so this deferred validation pattern is 1319 | only appropriate in those cases. You must use immedate validation in all other 1320 | cases. The `sideloaded?` helper is provided to help disambiguate edge cases. 1321 | 1322 | > TODO: The following three sections are a little confusing. Rewrite them. 1323 | 1324 | ##### Many-to-One 1325 | 1326 | Example: Photo belongs to (has one) Photographer; Photo.Photographer cannot be 1327 | null. 1328 | 1329 | * Don't define `prune` relationship action helper 1330 | * Define `graft` relationship action helper to enable reassigning the Photographer 1331 | * Define `destroy` resource action helper to enable removing the Photo 1332 | * Use `validate!` helper to check for nulls 1333 | 1334 | ##### One-to-Many 1335 | 1336 | Example: Photographer has many Photos; Photo.Photographer cannot be null. 1337 | 1338 | * Don't define `clear` relationship action helper 1339 | * Don't define `subtract` relationship action helper 1340 | * Delegate removing Photos and reassigning Photographers to Photo resource 1341 | 1342 | ##### Many-to-Many 1343 | 1344 | Example: Photo has many Tags. 1345 | 1346 | Nothing to worry about here! Feel free to use `NOT NULL` foreign key 1347 | constraints on the join table. 1348 | 1349 | ### Coalesced Find Requests 1350 | 1351 | If your {json:api} client coalesces find requests, the resource locator (or 1352 | `show` action helper) will be invoked once for each ID in the `:id` filter, and 1353 | the resulting collection will be serialized on the response. Both query 1354 | parameter syntaxes for arrays are supported: `?filter[id]=1,2` and 1355 | `?filter[id][]=1&filter[id][]=2`. If any ID is not found (i.e. `show` returns 1356 | `nil`), the route will halt with HTTP status 404. 1357 | 1358 | Optionally, to reduce round trips to the database, you may define a "special" 1359 | `show_many` action helper that takes an array of IDs to show. It does not take 1360 | `:roles` or any other options and will only be invoked if the current user has 1361 | access to `show`. This feature is experimental. 1362 | 1363 | Collections assembled during coalesced find requests will not be filtered, 1364 | sorted, or paged. The easiest way to limit the number of records that can be 1365 | queried is to define a `show_many` action helper and validate the length of the 1366 | passed array in the `before_show_many` hook. For example, using [Sequel][13]: 1367 | 1368 | ```ruby 1369 | resource :foos do 1370 | helpers do 1371 | def before_show_many(ids) 1372 | halt 413, 'You want the impossible.' if ids.length > 50 1373 | end 1374 | end 1375 | 1376 | show_many do |ids| 1377 | Foo.where_all(id: ids.map!(&:to_i)) 1378 | end 1379 | end 1380 | ``` 1381 | 1382 | ### Patchless Clients 1383 | 1384 | {json:api} [recommends][23] supporting patchless clients by using the 1385 | `X-HTTP-Method-Override` request header to coerce a `POST` into a `PATCH`. To 1386 | support this in Sinja, add the Sinja::MethodOverride middleware (which is a 1387 | stripped-down version of [Rack::MethodOverride][24]) into your application (or 1388 | Rackup configuration): 1389 | 1390 | ```ruby 1391 | require 'sinja' 1392 | require 'sinja/method_override' 1393 | 1394 | class MyApp < Sinatra::Base 1395 | use Sinja::MethodOverride 1396 | 1397 | register Sinja 1398 | 1399 | # .. 1400 | end 1401 | ``` 1402 | 1403 | ## Extensions 1404 | 1405 | Sinja extensions provide additional helpers, DSL, and ORM-specific boilerplate 1406 | as separate gems. Community contributions welcome! 1407 | 1408 | ### Sequel 1409 | 1410 | Please see [Sinja::Sequel][30] for more information. 1411 | 1412 | ## Application Concerns 1413 | 1414 | ### Performance 1415 | 1416 | Although there is some heavy metaprogramming happening at boot time, the end 1417 | result is simply a collection of Sinatra namespaces, routes, filters, 1418 | conditions, helpers, etc., and Sinja applications should perform as if you had 1419 | written them verbosely. The main caveat is that there are quite a few block 1420 | closures, which don't perform as well as normal methods in Ruby. Feedback 1421 | welcome. 1422 | 1423 | ### Public APIs 1424 | 1425 | Sinja makes a few APIs public to help you work around edge cases in your 1426 | application. 1427 | 1428 | #### Commonly Used 1429 | 1430 | **can?** 1431 | : Takes the symbol of an action helper and returns true if the current user has 1432 | access to call that action helper for the current resource using the `role` 1433 | helper and role definitions detailed under "Authorization" below. 1434 | 1435 | **role?** 1436 | : Takes a list of role(s) and returns true if it has members in common with the 1437 | current user's role(s). 1438 | 1439 | **sideloaded?** 1440 | : Returns true if the request was invoked from another action helper. 1441 | 1442 | #### Less-Commonly Used 1443 | 1444 | These are helpful if you want to add some custom routes to your Sinja 1445 | application. 1446 | 1447 | **data** 1448 | : Returns the `data` key of the deserialized request payload (with symbolized 1449 | names). 1450 | 1451 | **dedasherize** 1452 | : Takes a string or symbol and returns the string or symbol with any and all 1453 | dashes transliterated to underscores, and camelCase converted to snake_case. 1454 | 1455 | **dedasherize_names** 1456 | : Takes a hash and returns the hash with its keys dedasherized (deeply). 1457 | 1458 | **serialize_model** 1459 | : Takes a model (and optional hash of JSONAPI::Serializers options) and returns 1460 | a serialized model. 1461 | 1462 | **serialize_model?** 1463 | : Takes a model (and optional hash of JSONAPI::Serializers options) and returns 1464 | a serialized model if non-`nil`, or the root metadata if present, or a HTTP 1465 | status 204. 1466 | 1467 | **serialize_models** 1468 | : Takes an array of models (and optional hash of JSONAPI::Serializers options) 1469 | and returns a serialized collection. 1470 | 1471 | **serialize_models?** 1472 | : Takes an array of models (and optional hash of JSONAPI::Serializers options) 1473 | and returns a serialized collection if non-empty, or the root metadata if 1474 | present, or a HTTP status 204. 1475 | 1476 | ### Sinja or Sinatra::JSONAPI 1477 | 1478 | Everything is dual-namespaced under both Sinatra::JSONAPI and Sinja, and Sinja 1479 | requires Sinatra::Base, so this: 1480 | 1481 | ```ruby 1482 | require 'sinatra/base' 1483 | require 'sinatra/jsonapi' 1484 | 1485 | class App < Sinatra::Base 1486 | register Sinatra::JSONAPI 1487 | 1488 | configure_jsonapi do |c| 1489 | # .. 1490 | end 1491 | 1492 | # .. 1493 | 1494 | freeze_jsonapi 1495 | end 1496 | ``` 1497 | 1498 | Can also be written like this ("modular"-style applications only): 1499 | 1500 | ```ruby 1501 | require 'sinja' 1502 | 1503 | class App < Sinatra::Base 1504 | register Sinja 1505 | 1506 | sinja.configure do |c| 1507 | # .. 1508 | end 1509 | 1510 | # .. 1511 | 1512 | sinja.freeze 1513 | end 1514 | ``` 1515 | 1516 | ### Code Organization 1517 | 1518 | Sinja applications might grow overly large with a block for each resource. I am 1519 | still working on a better way to handle this (as well as a way to provide 1520 | standalone resource controllers for e.g. cloud functions), but for the time 1521 | being you can store each resource block as its own Proc, and pass it to the 1522 | `resource` keyword as a block. The migration to some future solution should be 1523 | relatively painless. For example: 1524 | 1525 | ```ruby 1526 | # controllers/foo_controller.rb 1527 | FooController = proc do 1528 | show do |id| 1529 | Foo[id.to_i] 1530 | end 1531 | 1532 | index do 1533 | Foo.all 1534 | end 1535 | 1536 | # .. 1537 | end 1538 | 1539 | # app.rb 1540 | require 'sinatra/base' 1541 | require 'sinatra/jsonapi' 1542 | 1543 | require_relative 'controllers/foo_controller' 1544 | 1545 | class App < Sinatra::Base 1546 | register Sinatra::JSONAPI 1547 | 1548 | resource :foos, &FooController 1549 | 1550 | freeze_jsonapi 1551 | end 1552 | ``` 1553 | 1554 | ### Testing 1555 | 1556 | The short answer to "How do I test my Sinja application?" is "Like you would 1557 | any other Sinatra application." Unfortunately, the testing story isn't quite 1558 | *there* yet for Sinja. I think leveraging something like [Munson][27] or 1559 | [json_api_client][28] is probably the best bet for integration testing, but 1560 | unfortunately both projects are rife with broken and/or missing critical 1561 | features. And until we can solve the general code organization problem (most 1562 | likely with patches to Sinatra), it will remain difficult to isolate action 1563 | helpers and other artifacts for unit testing. 1564 | 1565 | Sinja's own test suite is based on [Rack::Test][29] (plus some ugly kludges). 1566 | I wouldn't recommend it but it might be a good place to start looking for 1567 | ideas. It leverages the [demo-app](/demo-app) with Sequel and an in-memory 1568 | database to perform integration testing of Sinja's various features under 1569 | MRI/YARV and JRuby. The goal is to free you from worrying about whether your 1570 | applications will behave according to the {json:api} spec (as long as you 1571 | follow the usage documented in this README) and focus on testing your business 1572 | logic. 1573 | 1574 | ## Comparison with JSONAPI::Resources 1575 | 1576 | | Feature | JR | Sinja | 1577 | | :-------------- | :------------------------------- | :------------------------------------------------ | 1578 | | Serializer | Built-in | [JSONAPI::Serializers][3] | 1579 | | Framework | Rails | Sinatra 2.0, but easy to mount within others | 1580 | | Routing | ActionDispatch::Routing | Mustermann | 1581 | | Caching | ActiveSupport::Cache | BYO | 1582 | | ORM | ActiveRecord/ActiveModel | BYO | 1583 | | Authorization | [Pundit][9] | Role-based | 1584 | | Immutability | `immutable` method | Omit mutator action helpers (e.g. `update`) | 1585 | | Fetchability | `fetchable_fields` method | Omit attributes in Serializer | 1586 | | Creatability | `creatable_fields` method | Handle in `create` action helper or Model\* | 1587 | | Updatability | `updatable_fields` method | Handle in `update` action helper or Model\* | 1588 | | Sortability | `sortable_fields` method | `sort` helper and `:sort_by` option | 1589 | | Default sorting | `default_sort` method | Set default for `params[:sort]` | 1590 | | Context | `context` method | Rack middleware (e.g. `env['context']`) | 1591 | | Attributes | Define in Model and Resource | Define in Model\* and Serializer | 1592 | | Formatting | `:format` attribute keyword | Define attribute as a method in Serialier | 1593 | | Relationships | Define in Model and Resource | Define in Model, Resource, and Serializer | 1594 | | Filters | `filter(s)` keywords | `filter` helper and `:filter_by` option | 1595 | | Default filters | `:default` filter keyword | Set default for `params[:filter]` | 1596 | | Pagination | JSONAPI::Paginator | `page` helper and `page_using` configurable | 1597 | | Meta | `meta` method | Serializer `:meta` option | 1598 | | Primary keys | `resource_key_type` configurable | Serializer `id` method | 1599 | 1600 | \* – Depending on your ORM. 1601 | 1602 | ## Development 1603 | 1604 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 1605 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 1606 | prompt that will allow you to experiment. 1607 | 1608 | To install this gem onto your local machine, run `bundle exec rake install`. To 1609 | release a new version, update the version number in `version.rb`, and then run 1610 | `bundle exec rake release`, which will create a git tag for the version, push 1611 | git commits and tags, and push the `.gem` file to 1612 | [rubygems.org](https://rubygems.org). 1613 | 1614 | ## Contributing 1615 | 1616 | Bug reports and pull requests are welcome on GitHub at 1617 | https://github.com/mwpastore/sinja. 1618 | 1619 | ## License 1620 | 1621 | The gem is available as open source under the terms of the [MIT 1622 | License](http://opensource.org/licenses/MIT). 1623 | 1624 | [1]: http://www.sinatrarb.com 1625 | [2]: http://jsonapi.org 1626 | [3]: https://github.com/fotinakis/jsonapi-serializers 1627 | [4]: http://www.rubydoc.info/github/rack/rack/master/Rack/URLMap 1628 | [5]: http://rodauth.jeremyevans.net 1629 | [6]: https://github.com/sinatra/sinatra/tree/master/rack-protection 1630 | [7]: http://jsonapi.org/format/1.0/ 1631 | [8]: https://github.com/cerebris/jsonapi-resources 1632 | [9]: https://github.com/cerebris/jsonapi-resources#authorization 1633 | [10]: http://www.sinatrarb.com/extensions-wild.html 1634 | [11]: https://en.wikipedia.org/wiki/Representational_state_transfer 1635 | [12]: https://github.com/rails-api/active_model_serializers 1636 | [13]: http://sequel.jeremyevans.net 1637 | [14]: http://talentbox.github.io/sequel-rails/ 1638 | [15]: http://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/ActiveModel.html 1639 | [16]: http://rubyonrails.org 1640 | [17]: https://github.com/rails/rails/tree/master/activerecord 1641 | [18]: https://github.com/rails/rails/tree/master/activemodel 1642 | [19]: http://www.ruby-grape.org 1643 | [20]: http://roda.jeremyevans.net 1644 | [21]: http://www.sinatrarb.com/contrib/namespace.html 1645 | [22]: http://jsonapi.org/format/#document-resource-identifier-objects 1646 | [23]: http://jsonapi.org/recommendations/#patchless-clients 1647 | [24]: http://www.rubydoc.info/github/rack/rack/Rack/MethodOverride 1648 | [25]: http://www.sinatrarb.com/mustermann/ 1649 | [26]: https://jsonapi-suite.github.io/jsonapi_suite/ 1650 | [27]: https://github.com/coryodaniel/munson 1651 | [28]: https://github.com/chingor13/json_api_client 1652 | [29]: https://github.com/brynary/rack-test 1653 | [30]: https://github.com/mwpastore/sinja-sequel 1654 | [31]: http://jsonapi.org/implementations/#server-libraries-ruby 1655 | [32]: http://emberjs.com 1656 | [33]: https://github.com/fotinakis/jsonapi-serializers#more-customizations 1657 | [34]: https://github.com/elabs/pundit 1658 | --------------------------------------------------------------------------------