smooth 2.0.1 → 2.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/Gemfile +1 -2
- data/README.md +150 -5
- data/Rakefile +16 -0
- data/app/assets/javascripts/smooth/index.js +5152 -0
- data/bin/smooth +9 -0
- data/{app/assets/javascripts/smooth → developer-tools}/.keep +0 -0
- data/developer-tools/bower.json +8 -0
- data/developer-tools/config.ru +3 -0
- data/developer-tools/dist/08d606864d3ad3f0b98660d391f5a1c2.gif +0 -0
- data/developer-tools/dist/2d66bcdc27cd89f71068e98a7a929712.gif +0 -0
- data/developer-tools/dist/3e9816417b11485d454f9b3662b06e7b.eot +0 -0
- data/developer-tools/dist/47de617fd1d745ad120ccb9e2924b98c.gif +0 -0
- data/developer-tools/dist/5ae23ad29b67289a1375d2043e289c52.eot +0 -0
- data/developer-tools/dist/60c2a8500e63bf211b7df9608f7613ea.svg +450 -0
- data/developer-tools/dist/645f50ba6c1e56f078fa018855d97eb0.gif +0 -0
- data/developer-tools/dist/71ab514d1cedda303417ad7a06472fea.ttf +0 -0
- data/developer-tools/dist/8cca2f02b0af2da365ff4d1755f29146.ttf +0 -0
- data/developer-tools/dist/939cf252f0eb4efbd2d170c974411c49.gif +0 -0
- data/developer-tools/dist/9af25aaeb6ca6d08d213b04841813eb5.gif +0 -0
- data/developer-tools/dist/b683029bafe0305ac2234038a03e1541.woff +0 -0
- data/developer-tools/dist/c9dec22105ad9330c811599b8b6464f8.woff +0 -0
- data/developer-tools/dist/ca279c55a51ab2641c4712a333633581.gif +0 -0
- data/developer-tools/dist/client.js +5152 -0
- data/developer-tools/dist/f5b27137d3f5e9b1d91b16b37386dd03.gif +0 -0
- data/developer-tools/dist/f99a231ed57ee113b50b1c3e9f9fcdc3.svg +399 -0
- data/developer-tools/dist/index.html +18 -0
- data/developer-tools/dist/inspector.js +38432 -0
- data/developer-tools/dist/jquery.min.js +9190 -0
- data/developer-tools/package.json +39 -0
- data/developer-tools/server.js +14 -0
- data/developer-tools/src/client.coffee +21 -0
- data/developer-tools/src/client/collection.coffee +14 -0
- data/developer-tools/src/client/model.coffee +11 -0
- data/developer-tools/src/client/resource.coffee +132 -0
- data/{app/controllers/.keep → developer-tools/src/client/runner.coffee} +0 -0
- data/developer-tools/src/dependencies.coffee +7 -0
- data/developer-tools/src/inspector.cjsx +49 -0
- data/developer-tools/src/inspector/models/interface_collection.coffee +31 -0
- data/developer-tools/src/inspector/pages/index.cjsx +31 -0
- data/developer-tools/src/inspector/pages/resources.cjsx +5 -0
- data/developer-tools/src/inspector/views/grid_sort.cjsx +23 -0
- data/developer-tools/src/inspector/views/icon_heading.cjsx +15 -0
- data/developer-tools/src/inspector/views/resource_card.cjsx +34 -0
- data/developer-tools/src/inspector/views/sidebar.cjsx +12 -0
- data/developer-tools/src/inspector/views/toolbar.cjsx +17 -0
- data/developer-tools/src/styles/index.scss +136 -0
- data/developer-tools/src/styles/views.scss +13 -0
- data/developer-tools/src/util.coffee +48 -0
- data/developer-tools/webpack.config.js +56 -0
- data/developer-tools/webpack.hot.config.js +65 -0
- data/lib/smooth.rb +209 -28
- data/lib/smooth/active_record/adapter.rb +24 -0
- data/lib/smooth/api.rb +272 -18
- data/lib/smooth/api/policy.rb +2 -2
- data/lib/smooth/api/tracking.rb +4 -4
- data/lib/smooth/application.rb +66 -0
- data/lib/smooth/cache.rb +1 -1
- data/lib/smooth/command.rb +267 -18
- data/lib/smooth/command/async_worker.rb +27 -0
- data/lib/smooth/command/instrumented.rb +6 -4
- data/lib/smooth/command/run_proxy.rb +21 -0
- data/lib/smooth/configuration.rb +63 -8
- data/lib/smooth/documentation.rb +3 -6
- data/lib/smooth/dsl.rb +1 -36
- data/lib/smooth/dsl_adapter.rb +34 -0
- data/lib/smooth/event.rb +8 -4
- data/lib/smooth/event/proxy.rb +9 -0
- data/lib/smooth/event/relay.rb +38 -0
- data/lib/smooth/example.rb +1 -1
- data/lib/smooth/ext/core.rb +16 -0
- data/lib/smooth/model_adapter.rb +31 -0
- data/lib/smooth/query.rb +143 -13
- data/lib/smooth/resource.rb +227 -52
- data/lib/smooth/resource/router.rb +217 -0
- data/lib/smooth/resource/templating.rb +62 -0
- data/lib/smooth/resource/tracking.rb +1 -1
- data/lib/smooth/response.rb +73 -0
- data/lib/smooth/serializer.rb +102 -11
- data/lib/smooth/user_adapter.rb +83 -0
- data/lib/smooth/util.rb +17 -0
- data/lib/smooth/version.rb +1 -1
- data/smooth.gemspec +6 -2
- data/spec/acceptance/books_routes_spec.rb +50 -0
- data/spec/acceptance/embedded_relationships_spec.rb +26 -0
- data/spec/dummy/app/apis/application_api.rb +8 -3
- data/spec/dummy/app/commands/create_book.rb +5 -0
- data/spec/dummy/app/models/book.rb +1 -0
- data/spec/dummy/app/models/library.rb +2 -0
- data/spec/dummy/app/models/user.rb +2 -0
- data/spec/dummy/app/queries/book_query.rb +13 -0
- data/spec/dummy/app/resources/{books.rb → books_definition.rb} +37 -12
- data/spec/dummy/db/migrate/20140824215902_create_users.rb +10 -0
- data/spec/dummy/db/migrate/20140826193259_create_libraries.rb +10 -0
- data/spec/dummy/db/schema.rb +8 -1
- data/spec/lib/smooth/api/async_spec.rb +21 -0
- data/spec/lib/smooth/api_spec.rb +8 -0
- data/spec/lib/smooth/command_spec.rb +87 -6
- data/spec/lib/smooth/configuration_spec.rb +4 -0
- data/spec/lib/smooth/event/relay_spec.rb +33 -0
- data/spec/lib/smooth/event_spec.rb +5 -8
- data/spec/lib/smooth/query_spec.rb +42 -0
- data/spec/lib/smooth/resource/router_spec.rb +14 -0
- data/spec/lib/smooth/resource_spec.rb +33 -1
- data/spec/lib/smooth/serializer_spec.rb +20 -0
- data/spec/lib/smooth/templating_spec.rb +23 -0
- data/spec/lib/smooth/util_spec.rb +22 -0
- data/spec/spec_helper.rb +1 -1
- metadata +151 -17
- data/app/helpers/.keep +0 -0
- data/app/mailers/.keep +0 -0
- data/app/models/.keep +0 -0
- data/app/views/.keep +0 -0
- data/spec/dummy/db/development.sqlite3 +0 -0
- data/spec/dummy/db/test.sqlite3 +0 -0
@@ -0,0 +1,83 @@
|
|
1
|
+
module Smooth
|
2
|
+
module UserAdapter
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
base.send(:attr_accessor, :last_request_params, :last_request_headers)
|
6
|
+
|
7
|
+
base.send(:before_create, -> { generate_token(Smooth.config.auth_token_column) })
|
8
|
+
end
|
9
|
+
|
10
|
+
def generate_token(column)
|
11
|
+
if self.class.column_names.include?(column.to_s)
|
12
|
+
write_attribute(column, SecureRandom.urlsafe_base64)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
def find_for_smooth_api_request(id, passed_authentication_token)
|
18
|
+
where(id: id, authentication_token: passed_authentication_token).first
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_for_token_authentication(passed_authentication_token)
|
22
|
+
id, token = passed_authentication_token.split(':')
|
23
|
+
find_for_smooth_api_request(id, token)
|
24
|
+
end
|
25
|
+
|
26
|
+
def anonymous(params = nil, headers = nil)
|
27
|
+
User.new.tap do |user|
|
28
|
+
user.last_request_params = params if params
|
29
|
+
user.last_request_headers = headers if headers
|
30
|
+
user.making_anonymous_request = true
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def making_anonymous_request=(setting)
|
36
|
+
@making_anonymous_request = !!(setting)
|
37
|
+
end
|
38
|
+
|
39
|
+
def anonymous?
|
40
|
+
!!(@making_anonymous_request)
|
41
|
+
end
|
42
|
+
|
43
|
+
def smooth_authentication_token
|
44
|
+
read_attribute(:authentication_token)
|
45
|
+
"#{ id }:#{ token }"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Allows for using the current_user making an API request
|
49
|
+
# as the source of all queries, and commands run against
|
50
|
+
# Smooth resources.
|
51
|
+
#
|
52
|
+
# Example:
|
53
|
+
#
|
54
|
+
# current_user.smooth.query("books.mine", published_before: 2014)
|
55
|
+
#
|
56
|
+
# Piping all queries to the Smooth Resources through the same interface
|
57
|
+
# makes implementing a declarative, role based access control policy pretty
|
58
|
+
# easy.
|
59
|
+
#
|
60
|
+
# You could even add the following methods to all of your ApplicationController
|
61
|
+
#
|
62
|
+
# Example:
|
63
|
+
#
|
64
|
+
# class ApplicationController < ActionController::Base
|
65
|
+
# def run_query *args, &block
|
66
|
+
# current_user.smooth.send(:query, *args, &block)
|
67
|
+
# end
|
68
|
+
#
|
69
|
+
# def run_command *args, &block
|
70
|
+
# current_user.smooth.send(:run_command, *args, &block)
|
71
|
+
# end
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# class BooksController < ApplicationController
|
75
|
+
# def index
|
76
|
+
# render :json => run_query("books", params)
|
77
|
+
# end
|
78
|
+
# end
|
79
|
+
def smooth(api = :default)
|
80
|
+
Smooth.fetch_api(api).as(self)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
data/lib/smooth/util.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
module Smooth
|
2
|
+
module Util
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def uri_template(url_pattern)
|
6
|
+
URITemplate.new(:colon, url_pattern)
|
7
|
+
end
|
8
|
+
|
9
|
+
def expand_url_template(uri_template, vars = {})
|
10
|
+
uri_template.expand(vars)
|
11
|
+
end
|
12
|
+
|
13
|
+
def extract_url_vars(uri_template, actual_url)
|
14
|
+
uri_template.extract(actual_url).tap(&:symbolize_keys!)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/smooth/version.rb
CHANGED
data/smooth.gemspec
CHANGED
@@ -21,9 +21,13 @@ Gem::Specification.new do |spec|
|
|
21
21
|
spec.add_dependency 'hashie'
|
22
22
|
spec.add_dependency 'activesupport', '>= 4.0.0'
|
23
23
|
spec.add_dependency 'activerecord', '>= 4.0.0'
|
24
|
-
spec.add_dependency 'active_model_serializers'
|
25
|
-
spec.add_dependency '
|
24
|
+
spec.add_dependency 'active_model_serializers', '~> 0.8.0'
|
25
|
+
spec.add_dependency 'ffaker'
|
26
|
+
spec.add_dependency 'factory_girl'
|
26
27
|
spec.add_dependency 'mutations'
|
28
|
+
spec.add_dependency 'sinatra'
|
29
|
+
spec.add_dependency 'escape_utils'
|
30
|
+
spec.add_dependency 'uri_template'
|
27
31
|
|
28
32
|
spec.add_development_dependency "bundler", "~> 1.3"
|
29
33
|
spec.add_development_dependency "rake"
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "The Books Resource Routes" do
|
4
|
+
let(:books) { Smooth("Books") }
|
5
|
+
|
6
|
+
let(:session) do
|
7
|
+
Rack::MockSession.new(books.api.sinatra)
|
8
|
+
end
|
9
|
+
|
10
|
+
let(:client) do
|
11
|
+
Rack::Test::Session.new(session)
|
12
|
+
end
|
13
|
+
|
14
|
+
it "should make a request to the show action" do
|
15
|
+
book = Book.create(title:"Cristian The LionHeart")
|
16
|
+
response = client.get("/books/#{ book.id }")
|
17
|
+
json = JSON.parse(response.body) rescue {}
|
18
|
+
book = json.fetch("book")
|
19
|
+
|
20
|
+
expect(response.status).to eq(200)
|
21
|
+
expect(book["title"]).to eq("Cristian The LionHeart")
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should make a request to the books query" do
|
25
|
+
Book.create(title:"Luca The Coming Champ", year_published: 1895)
|
26
|
+
response = client.get("/books", title: "Luca")
|
27
|
+
json = JSON.parse(response.body) rescue {}
|
28
|
+
|
29
|
+
expect(json).not_to be_empty
|
30
|
+
expect(response.status).to eq(200)
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should make a request to the create command" do
|
34
|
+
response = client.post("/books", title: "The Biography of Jon Soeder")
|
35
|
+
|
36
|
+
json = JSON.parse(response.body) rescue {}
|
37
|
+
|
38
|
+
book = json.fetch("book")
|
39
|
+
|
40
|
+
expect(response.status).to eq(200)
|
41
|
+
expect(book).to have_key("author_id")
|
42
|
+
expect(book["id"]).not_to be_nil
|
43
|
+
expect(book["title"]).to eq("The Biography of Jon Soeder")
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should return errors if i don't include the right params" do
|
47
|
+
response = client.post("/books")
|
48
|
+
expect(response.status).not_to eq(200)
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "The Books Resource Routes" do
|
4
|
+
let(:books) { Smooth("Books") }
|
5
|
+
|
6
|
+
let(:session) do
|
7
|
+
Rack::MockSession.new(books.api.sinatra)
|
8
|
+
end
|
9
|
+
|
10
|
+
let(:client) do
|
11
|
+
Rack::Test::Session.new(session)
|
12
|
+
end
|
13
|
+
|
14
|
+
it "should fetch objects by ids" do
|
15
|
+
|
16
|
+
book_ids = 3.times.map do |n|
|
17
|
+
Book.create(title:"Book #{ Time.now.to_i }")
|
18
|
+
end.slice(0,2).map(&:id).join(',')
|
19
|
+
|
20
|
+
response =client.get("/books?ids=#{ book_ids }")
|
21
|
+
json = JSON.parse(response.body) rescue {}
|
22
|
+
|
23
|
+
expect(json.length).to eq(2)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
@@ -3,12 +3,17 @@ require 'smooth/dsl'
|
|
3
3
|
api "My Application" do
|
4
4
|
version :v1
|
5
5
|
|
6
|
+
authentication_strategy :header, "X-AUTH-TOKEN"
|
7
|
+
|
8
|
+
user_class User do
|
9
|
+
include(Smooth::UserAdapter)
|
10
|
+
end
|
11
|
+
|
6
12
|
desc "Public users include anyone with access to the URL"
|
7
13
|
policy :public_users do
|
8
|
-
|
9
14
|
# commands / queries can be set to true or false to allow
|
10
15
|
# all commands and queries defined for the books resource.
|
11
|
-
allow :books, :commands => false, :queries => true
|
16
|
+
#allow :books, :commands => false, :queries => true
|
12
17
|
|
13
18
|
# we can also pass an array of queries or commands
|
14
19
|
# allow :books, :commands => [:like]
|
@@ -17,7 +22,7 @@ api "My Application" do
|
|
17
22
|
desc "Authenticated users register and are given an auth token"
|
18
23
|
policy :logged_in_users do
|
19
24
|
authenticate_with :header => 'X-AUTH-TOKEN', :param => :auth_token
|
20
|
-
allow :books, :commands => true, :queries => true
|
25
|
+
#allow :books, :commands => true, :queries => true
|
21
26
|
end
|
22
27
|
|
23
28
|
desc "Admin users have the admin flag set to true"
|
@@ -1,4 +1,5 @@
|
|
1
1
|
resource "Books" do
|
2
|
+
desc "The default serializer for book"
|
2
3
|
serializer do
|
3
4
|
desc "A unique id for the book", :type => :integer
|
4
5
|
attribute :id
|
@@ -13,11 +14,17 @@ resource "Books" do
|
|
13
14
|
def computed_property
|
14
15
|
object.created_at
|
15
16
|
end
|
17
|
+
|
18
|
+
desc "Another way for doing computed properties"
|
19
|
+
computed(:another_computed_property) do
|
20
|
+
object.created_at.to_i
|
21
|
+
end
|
16
22
|
end
|
17
23
|
|
18
24
|
# This will create a class 'UpdateBook'. The execute method
|
19
25
|
# is open for definition by the developer.
|
20
|
-
|
26
|
+
desc "Update a book's attributes"
|
27
|
+
command :update do
|
21
28
|
# Will ensure the command is run with
|
22
29
|
# Book.accessible_to(current_user).find(id).
|
23
30
|
scope :accessible_to
|
@@ -29,17 +36,21 @@ resource "Books" do
|
|
29
36
|
string :title
|
30
37
|
end
|
31
38
|
end
|
39
|
+
|
40
|
+
execute(:update)
|
32
41
|
end
|
33
42
|
|
34
|
-
|
43
|
+
desc "Create a book"
|
44
|
+
command :create do
|
35
45
|
scope :accessible_to
|
36
46
|
|
37
47
|
params do
|
38
|
-
string :title
|
48
|
+
string :title, faker: 'app.author'
|
39
49
|
end
|
40
50
|
end
|
41
51
|
|
42
|
-
|
52
|
+
desc "Toggle whether you like a book or not"
|
53
|
+
command :like do
|
43
54
|
scope :all
|
44
55
|
|
45
56
|
params do
|
@@ -52,18 +63,24 @@ resource "Books" do
|
|
52
63
|
end
|
53
64
|
end
|
54
65
|
|
66
|
+
desc "Here we just define a relationship to a known class"
|
67
|
+
command :criticize, (CriticizeBook = Class.new(Smooth::Command))
|
68
|
+
|
55
69
|
# This will create a class 'BookQuery'. The build_scope method
|
56
70
|
# is open for definition by the developer.
|
57
71
|
query do
|
58
|
-
|
72
|
+
scope :accessible_to
|
59
73
|
|
60
74
|
params do
|
61
75
|
desc "The year the book was published (example: YYYY)"
|
62
|
-
integer :year_published
|
76
|
+
integer :year_published, operator: :gte
|
77
|
+
|
78
|
+
desc "A partial string to filter the title by"
|
79
|
+
string :title, operator: :like
|
63
80
|
end
|
64
81
|
|
65
82
|
role :admin do
|
66
|
-
|
83
|
+
scope :all
|
67
84
|
end
|
68
85
|
end
|
69
86
|
|
@@ -72,19 +89,27 @@ resource "Books" do
|
|
72
89
|
# under /api/v1/books, /api/v1/books/1 etc
|
73
90
|
routes do
|
74
91
|
desc "List all books"
|
75
|
-
get "/books", :to => :query
|
92
|
+
get "/books", :to => :query, :as => :list_books
|
76
93
|
|
77
94
|
desc "Show an individual book"
|
78
|
-
show "/books/:id", :to => :show
|
95
|
+
show "/books/:id", :to => :show, :as => :show_book
|
79
96
|
|
80
97
|
desc "Create a new book"
|
81
|
-
post "/books", :to => :create
|
98
|
+
post "/books", :to => :create, :as => :create_book
|
82
99
|
|
83
100
|
desc "Update an existing book"
|
84
|
-
put "/books/:id", :to => :update
|
101
|
+
put "/books/:id", :to => :update, :as => :update_book
|
85
102
|
|
86
103
|
desc "Like a book"
|
87
|
-
put "/books/:id/like", :to => :like
|
104
|
+
put "/books/:id/like", :to => :like, :as => :like_book
|
105
|
+
end
|
106
|
+
|
107
|
+
template do
|
108
|
+
title { Smooth.faker('company.catch_phrase') }
|
109
|
+
end
|
110
|
+
|
111
|
+
template :ancient, class: Book do
|
112
|
+
year_published { 1776 }
|
88
113
|
end
|
89
114
|
|
90
115
|
examples :client => :rest do
|
data/spec/dummy/db/schema.rb
CHANGED
@@ -11,7 +11,7 @@
|
|
11
11
|
#
|
12
12
|
# It's strongly recommended that you check this file into your version control system.
|
13
13
|
|
14
|
-
ActiveRecord::Schema.define(version:
|
14
|
+
ActiveRecord::Schema.define(version: 20140824215902) do
|
15
15
|
|
16
16
|
create_table "authors", force: true do |t|
|
17
17
|
t.string "name"
|
@@ -27,4 +27,11 @@ ActiveRecord::Schema.define(version: 20140822065916) do
|
|
27
27
|
t.datetime "updated_at"
|
28
28
|
end
|
29
29
|
|
30
|
+
create_table "users", force: true do |t|
|
31
|
+
t.string "email"
|
32
|
+
t.string "role"
|
33
|
+
t.datetime "created_at"
|
34
|
+
t.datetime "updated_at"
|
35
|
+
end
|
36
|
+
|
30
37
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "Smooth Command Background Job Handling" do
|
4
|
+
let(:api) { Smooth() }
|
5
|
+
|
6
|
+
it "should serialize a command call and restore it from memory" do
|
7
|
+
key = api.serialize_for_async('books.create', {title:'New Book'})
|
8
|
+
expect(key).to be_present
|
9
|
+
end
|
10
|
+
|
11
|
+
it "should deserialize a command call" do
|
12
|
+
key = api.serialize_for_async('books.create', {title:'New Book'})
|
13
|
+
hash = Smooth.config.memory_store.read(key).symbolize_keys
|
14
|
+
api_name, object_path, payload = hash.values_at(:api, :object_path, :payload)
|
15
|
+
|
16
|
+
expect(api_name).to eq('My Application')
|
17
|
+
expect(Smooth(api_name)).to eq(api)
|
18
|
+
expect(Smooth(api_name).lookup(object_path)).to be_present
|
19
|
+
expect(payload[:title]).to eq('New Book')
|
20
|
+
end
|
21
|
+
end
|
data/spec/lib/smooth/api_spec.rb
CHANGED
@@ -19,5 +19,13 @@ describe "The Smooth API Definition" do
|
|
19
19
|
it "should have a version" do
|
20
20
|
expect(api.version).to equal(:v1)
|
21
21
|
end
|
22
|
+
|
23
|
+
it "should lookup objects by a shortcut alias / path" do
|
24
|
+
expect(api.lookup_object_by("books")).to be_a(Smooth::Resource)
|
25
|
+
expect(api.lookup_object_by("books.create")).to eq(CreateBook)
|
26
|
+
expect(api.lookup_object_by("books.like")).to eq(LikeBook)
|
27
|
+
expect(api.lookup_object_by("books.query")).to eq(BookQuery)
|
28
|
+
expect(api.lookup_object_by("books.serializer")).to eq(BookSerializer)
|
29
|
+
end
|
22
30
|
end
|
23
31
|
|