model_driven_api 3.6.3 → 3.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +521 -54
- data/Rakefile +3 -0
- data/app/controllers/api/v2/application_controller.rb +6 -47
- data/app/controllers/api/v2/info_controller.rb +2 -1308
- data/app/controllers/api/v3/application_controller.rb +132 -0
- data/app/controllers/api/v3/auth/oauth_controller.rb +4 -0
- data/app/controllers/api/v3/authentication_controller.rb +2 -0
- data/app/controllers/api/v3/info_controller.rb +37 -0
- data/app/controllers/api/v3/raw_controller.rb +14 -0
- data/app/controllers/api/v3/users_controller.rb +10 -0
- data/config/routes.rb +44 -0
- data/lib/api/custom_action_dispatcher.rb +41 -0
- data/lib/api/model_resolver.rb +20 -0
- data/lib/api/open_api/base.rb +91 -0
- data/lib/api/open_api/v2.rb +1238 -0
- data/lib/api/open_api/v3.rb +349 -0
- data/lib/api/resource_attribute_set.rb +25 -0
- data/lib/api/v3/serializer_factory.rb +65 -0
- data/lib/model_driven_api/engine.rb +7 -1
- data/lib/model_driven_api/version.rb +1 -1
- data/lib/model_driven_api.rb +8 -0
- metadata +75 -6
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
class Api::V3::ApplicationController < Api::V2::ApplicationController
|
|
2
|
+
include Pagy::Backend
|
|
3
|
+
|
|
4
|
+
def index
|
|
5
|
+
authorize! :index, @model
|
|
6
|
+
|
|
7
|
+
status, result, status_number = check_for_custom_action
|
|
8
|
+
return render json: result, status: (status_number.presence || 200) if status == true
|
|
9
|
+
|
|
10
|
+
scope = apply_filters(@model.all)
|
|
11
|
+
scope = apply_sorting(scope)
|
|
12
|
+
|
|
13
|
+
pagy, records = pagy(scope, page: page_number, limit: page_size)
|
|
14
|
+
|
|
15
|
+
serializer = Api::V3::SerializerFactory.serializer_for(@model)
|
|
16
|
+
render json: serializer.new(records, **serializer_opts).serializable_hash.merge(meta: { total: pagy.count }),
|
|
17
|
+
status: :ok
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
authorize! :show, @record
|
|
22
|
+
|
|
23
|
+
status, result, status_number = check_for_custom_action
|
|
24
|
+
return render json: result, status: (status_number.presence || 200) if status == true
|
|
25
|
+
|
|
26
|
+
serializer = Api::V3::SerializerFactory.serializer_for(@model)
|
|
27
|
+
render json: serializer.new(@record, **serializer_opts).serializable_hash, status: :ok
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create
|
|
31
|
+
authorize! :create, @model
|
|
32
|
+
|
|
33
|
+
status, result, status_number = check_for_custom_action
|
|
34
|
+
return render json: result, status: (status_number.presence || 200) if status == true
|
|
35
|
+
|
|
36
|
+
record = @model.new(jsonapi_attributes)
|
|
37
|
+
record.save!
|
|
38
|
+
serializer = Api::V3::SerializerFactory.serializer_for(@model)
|
|
39
|
+
render json: serializer.new(record, **serializer_opts).serializable_hash, status: :created
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update
|
|
43
|
+
authorize! :update, @record
|
|
44
|
+
|
|
45
|
+
status, result, status_number = check_for_custom_action
|
|
46
|
+
return render json: result, status: (status_number.presence || 200) if status == true
|
|
47
|
+
|
|
48
|
+
@record.update!(jsonapi_attributes)
|
|
49
|
+
serializer = Api::V3::SerializerFactory.serializer_for(@model)
|
|
50
|
+
render json: serializer.new(@record, **serializer_opts).serializable_hash, status: :ok
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
alias_method :patch, :update
|
|
54
|
+
|
|
55
|
+
def destroy
|
|
56
|
+
authorize! :destroy, @record
|
|
57
|
+
|
|
58
|
+
status, result, status_number = check_for_custom_action
|
|
59
|
+
return render json: result, status: (status_number.presence || 200) if status == true
|
|
60
|
+
|
|
61
|
+
@record.destroy!
|
|
62
|
+
head :no_content
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def apply_filters(scope)
|
|
68
|
+
filter_params = params[:filter] || {}
|
|
69
|
+
filter_params.each do |field, value|
|
|
70
|
+
next unless @model.ransackable_attributes.include?(field.to_s)
|
|
71
|
+
scope = scope.where(field => value)
|
|
72
|
+
end
|
|
73
|
+
scope
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_sorting(scope)
|
|
77
|
+
return scope if params[:sort].blank?
|
|
78
|
+
params[:sort].to_s.split(",").each do |field|
|
|
79
|
+
if field.start_with?("-")
|
|
80
|
+
scope = scope.order(field[1..] => :desc)
|
|
81
|
+
else
|
|
82
|
+
scope = scope.order(field => :asc)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
scope
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def page_number
|
|
89
|
+
(params.dig("page", "number") || 1).to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def page_size
|
|
93
|
+
(params.dig("page", "size") || Pagy::DEFAULT[:limit] || 25).to_i
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def jsonapi_attributes
|
|
97
|
+
(params.dig("data", "attributes") || {}).to_h
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Hybrid sideloading: json_attrs[:include] keys are the default;
|
|
101
|
+
# the client can override with ?include=assoc1,assoc2 (empty string = no sideloads).
|
|
102
|
+
def requested_includes
|
|
103
|
+
return default_includes if params["include"].nil?
|
|
104
|
+
params["include"].to_s.split(",").filter_map { |s| s.strip.to_sym unless s.strip.empty? }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def default_includes
|
|
108
|
+
jattrs = @model.respond_to?(:json_attrs) ? (@model.json_attrs || {}) : {}
|
|
109
|
+
Array(jattrs[:include]).flat_map do |item|
|
|
110
|
+
case item
|
|
111
|
+
when Hash then item.keys
|
|
112
|
+
when Symbol then [item]
|
|
113
|
+
else []
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# JSON:API sparse fieldsets: ?fields[roles]=name,lock_version
|
|
119
|
+
def sparse_fields
|
|
120
|
+
return {} if params["fields"].blank?
|
|
121
|
+
params["fields"].to_h.transform_keys(&:to_sym).transform_values { |v| v.to_s.split(",").map(&:to_sym) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def serializer_opts
|
|
125
|
+
opts = {}
|
|
126
|
+
includes = requested_includes
|
|
127
|
+
opts[:include] = includes if includes.any?
|
|
128
|
+
fields = sparse_fields
|
|
129
|
+
opts[:fields] = fields if fields.any?
|
|
130
|
+
opts
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class Api::V3::InfoController < Api::V2::InfoController
|
|
2
|
+
# Override openapi/swagger to generate a v3-accurate spec.
|
|
3
|
+
# All other info actions (version, roles, heartbeat, ntp, translations,
|
|
4
|
+
# schema, dsl, settings) are inherited unchanged — they return plain JSON
|
|
5
|
+
# and are version-agnostic.
|
|
6
|
+
def openapi
|
|
7
|
+
uri = URI(request.url)
|
|
8
|
+
spec = {
|
|
9
|
+
"openapi" => "3.0.0",
|
|
10
|
+
"info" => {
|
|
11
|
+
"title" => "#{Settings.ns(:main).app_name} API",
|
|
12
|
+
"description" => Api::OpenApi::V3.new(ApplicationRecord.subclasses, request).description,
|
|
13
|
+
"version" => "v3",
|
|
14
|
+
},
|
|
15
|
+
"servers" => [
|
|
16
|
+
{
|
|
17
|
+
"url" => "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port.present?}/api/v3",
|
|
18
|
+
"description" => "JSON:API v3 base URL",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
"components" => {
|
|
22
|
+
"securitySchemes" => {
|
|
23
|
+
"bearerAuth" => {
|
|
24
|
+
"type" => "http",
|
|
25
|
+
"scheme" => "bearer",
|
|
26
|
+
"bearerFormat" => "JWT",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"security" => [{ "bearerAuth" => [] }],
|
|
31
|
+
"paths" => Api::OpenApi::V3.new(ApplicationRecord.subclasses, request).generate,
|
|
32
|
+
}
|
|
33
|
+
render json: spec.to_json, status: 200
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
alias_method :swagger, :openapi
|
|
37
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class Api::V3::RawController < Api::V3::ApplicationController
|
|
2
|
+
skip_before_action :extract_model
|
|
3
|
+
|
|
4
|
+
def sql
|
|
5
|
+
return render json: { error: "Query is required" }, status: 400 if params[:query].nil?
|
|
6
|
+
|
|
7
|
+
result = SafeSqlExecutor.execute_select(params[:query]).to_a
|
|
8
|
+
render json: result, status: 200
|
|
9
|
+
rescue ArgumentError => e
|
|
10
|
+
render json: { error: e.message }, status: 400
|
|
11
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
12
|
+
render json: { error: e.message }, status: 400
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class Api::V3::UsersController < Api::V3::ApplicationController
|
|
2
|
+
before_action :check_demoting, only: [ :update, :patch, :destroy ]
|
|
3
|
+
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def check_demoting
|
|
7
|
+
attrs = (params.dig("data", "attributes") || {})
|
|
8
|
+
unauthorized! StandardError.new("You cannot demote yourself") if (params[:id].to_i == current_user.id && (attrs.key?("admin") || attrs.key?("locked")))
|
|
9
|
+
end
|
|
10
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -8,6 +8,50 @@ Rails.application.routes.draw do
|
|
|
8
8
|
match "/auth/failure", to: redirect("/api/v2/auth/failure"), via: [:get, :post]
|
|
9
9
|
end
|
|
10
10
|
namespace :api, constraints: { format: :json } do
|
|
11
|
+
namespace :v3 do
|
|
12
|
+
if ThecoreAuthCommons.oauth_vars?
|
|
13
|
+
namespace :auth do
|
|
14
|
+
post :jwt, to: "oauth#exchange_token"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
resources :users
|
|
19
|
+
|
|
20
|
+
namespace :info do
|
|
21
|
+
get :version
|
|
22
|
+
get :roles
|
|
23
|
+
get :translations
|
|
24
|
+
get :schema
|
|
25
|
+
get :dsl
|
|
26
|
+
get :heartbeat
|
|
27
|
+
get :ntp
|
|
28
|
+
get :settings
|
|
29
|
+
get :swagger
|
|
30
|
+
get :openapi
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
post "authenticate" => "authentication#authenticate"
|
|
34
|
+
|
|
35
|
+
get ":ctrl/custom_action/:action_name", to: "application#index"
|
|
36
|
+
get ":ctrl/custom_action/:action_name/:id", to: "application#show"
|
|
37
|
+
post ":ctrl/custom_action/:action_name", to: "application#create"
|
|
38
|
+
put ":ctrl/custom_action/:action_name/:id", to: "application#update"
|
|
39
|
+
patch ":ctrl/custom_action/:action_name/:id", to: "application#update"
|
|
40
|
+
delete ":ctrl/custom_action/:action_name/:id", to: "application#destroy"
|
|
41
|
+
|
|
42
|
+
namespace :raw do
|
|
43
|
+
post :sql
|
|
44
|
+
get :sql
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
get "*path/:id", to: "application#show"
|
|
48
|
+
get "*path", to: "application#index"
|
|
49
|
+
post "*path", to: "application#create"
|
|
50
|
+
put "*path/:id", to: "application#update"
|
|
51
|
+
patch "*path/:id", to: "application#update"
|
|
52
|
+
delete "*path/:id", to: "application#destroy"
|
|
53
|
+
end
|
|
54
|
+
|
|
11
55
|
namespace :v2 do
|
|
12
56
|
# Authentication via Oauth2 only if the environment variable is set
|
|
13
57
|
if ThecoreAuthCommons.oauth_vars?
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Api
|
|
2
|
+
class CustomActionDispatcher
|
|
3
|
+
# Dispatch a custom action if the request signals one.
|
|
4
|
+
# Returns false if this is not a custom action call.
|
|
5
|
+
# Returns [true, body, status] when dispatched.
|
|
6
|
+
# Raises NoMethodError if the action name is present but not found.
|
|
7
|
+
def self.call(model, params, request)
|
|
8
|
+
custom_action = if !params[:do].blank?
|
|
9
|
+
params[:do]
|
|
10
|
+
elsif request.url.include?("/custom_action/")
|
|
11
|
+
params[:action_name]
|
|
12
|
+
end
|
|
13
|
+
return false unless custom_action
|
|
14
|
+
|
|
15
|
+
params[:request_url] = request.url
|
|
16
|
+
params[:remote_ip] = request.remote_ip
|
|
17
|
+
params[:request_verb] = request.request_method
|
|
18
|
+
params[:token] = extract_bearer(request)
|
|
19
|
+
|
|
20
|
+
Rails.logger.debug("CustomActionDispatcher: #{custom_action} on #{model}")
|
|
21
|
+
|
|
22
|
+
if model.respond_to?("custom_action_#{custom_action}")
|
|
23
|
+
body, status = model.send("custom_action_#{custom_action}", params)
|
|
24
|
+
elsif ("Endpoints::#{model}".constantize rescue false) &&
|
|
25
|
+
"Endpoints::#{model}".constantize.instance_methods.include?(custom_action.to_sym)
|
|
26
|
+
body, status = "Endpoints::#{model}".constantize.new(custom_action, params).result
|
|
27
|
+
else
|
|
28
|
+
raise NoMethodError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
[true, body, status]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.extract_bearer(request)
|
|
35
|
+
pattern = /^Bearer /
|
|
36
|
+
header = request.headers["Authorization"]
|
|
37
|
+
header.gsub(pattern, "") if header&.match(pattern)
|
|
38
|
+
end
|
|
39
|
+
private_class_method :extract_bearer
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Api
|
|
2
|
+
class ModelResolver
|
|
3
|
+
class NotFound < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Resolve model from params, controller_path, or controller_name.
|
|
6
|
+
# Returns nil when no class can be resolved (info/utility controllers use this path).
|
|
7
|
+
# Raises NotFound when a class is resolved but is not an ActiveRecord model (and not TestApi).
|
|
8
|
+
def self.resolve(params, controller_path, controller_name)
|
|
9
|
+
model = (params[:ctrl].classify.constantize rescue
|
|
10
|
+
params[:path].split("/").first.classify.constantize rescue
|
|
11
|
+
controller_path.classify.constantize rescue
|
|
12
|
+
controller_name.classify.constantize rescue
|
|
13
|
+
nil)
|
|
14
|
+
if model && model != TestApi
|
|
15
|
+
raise NotFound unless (model.new.is_a?(ActiveRecord::Base) rescue false)
|
|
16
|
+
end
|
|
17
|
+
model
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Api
|
|
2
|
+
module OpenApi
|
|
3
|
+
class Base
|
|
4
|
+
def initialize(models, request)
|
|
5
|
+
@models = models
|
|
6
|
+
@request = request
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def compute_type(model, key)
|
|
12
|
+
Rails.logger.debug "compute_type #{model} #{key}"
|
|
13
|
+
# if it's a file, a date or a text, then return string
|
|
14
|
+
instance = model.new
|
|
15
|
+
# If it's a method, it is a peculiar case, in which we have to return "object" and additionalProperties: true
|
|
16
|
+
return "method" if model.methods.include?(:json_attrs) && model.json_attrs && model.json_attrs.include?(:methods) && model.json_attrs[:methods].include?(key.to_sym)
|
|
17
|
+
# If it's not the case of a method, then it's a field
|
|
18
|
+
method_class = instance.send(key).class.to_s
|
|
19
|
+
Rails.logger.debug "compute_type #{model} #{key} #{method_class}"
|
|
20
|
+
method_key = model.columns_hash[key]
|
|
21
|
+
|
|
22
|
+
# Not columns
|
|
23
|
+
return nil if method_key.nil?
|
|
24
|
+
return "object" if method_class == "ActiveStorage::Attached::One"
|
|
25
|
+
return "array" if method_class == "ActiveStorage::Attached::Many" || method_class == "Array" || method_class.ends_with?("Array") || method_class.ends_with?("Collection") || method_class.ends_with?("Relation") || method_class.ends_with?("Set") || method_class.ends_with?("List") || method_class.ends_with?("Queue") || method_class.ends_with?("Stack") || method_class.ends_with?("ActiveRecord_Associations_CollectionProxy")
|
|
26
|
+
|
|
27
|
+
# Columns
|
|
28
|
+
case method_key.type
|
|
29
|
+
when :json, :jsonb
|
|
30
|
+
return "object"
|
|
31
|
+
when :enum
|
|
32
|
+
return "array"
|
|
33
|
+
when :text, :hstore
|
|
34
|
+
return "string"
|
|
35
|
+
when :decimal, :float, :bigint
|
|
36
|
+
return "number"
|
|
37
|
+
end
|
|
38
|
+
method_key.type.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def integer?(str)
|
|
42
|
+
true if Integer(str) rescue false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def number?(str)
|
|
46
|
+
true if Float(str) rescue false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def datetime?(str)
|
|
50
|
+
true if DateTime.parse(str) rescue false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create_properties_from_model(model, dsl, remove_reserved = false)
|
|
54
|
+
parsed_json = JSON.parse(model.new.to_json(dsl))
|
|
55
|
+
parsed_json.keys.map do |k|
|
|
56
|
+
type = compute_type(model, k)
|
|
57
|
+
|
|
58
|
+
# Remove fields that cannot be created or updated
|
|
59
|
+
if remove_reserved && %w( id created_at updated_at lock_version ).include?(k.to_s)
|
|
60
|
+
nil
|
|
61
|
+
elsif type == "method" && (parsed_json[k].is_a?(FalseClass) || parsed_json[k].is_a?(TrueClass))
|
|
62
|
+
[k, { "type": "boolean" }]
|
|
63
|
+
elsif type == "method" && parsed_json[k].is_a?(String) && number?(parsed_json[k])
|
|
64
|
+
[k, { "type": "number" }]
|
|
65
|
+
elsif type == "method" && parsed_json[k].is_a?(String) && integer?(parsed_json[k])
|
|
66
|
+
[k, { "type": "integer" }]
|
|
67
|
+
elsif type == "method" && parsed_json[k].is_a?(String) && datetime?(parsed_json[k])
|
|
68
|
+
[k, { "type": "string", "format": "date-time" }]
|
|
69
|
+
elsif type == "method"
|
|
70
|
+
# Unknown or complex format returned
|
|
71
|
+
[k, { "type": "object", "additionalProperties": true }]
|
|
72
|
+
elsif type == "date"
|
|
73
|
+
[k, { "type": "string", "format": "date" }]
|
|
74
|
+
elsif type == "datetime"
|
|
75
|
+
[k, { "type": "string", "format": "date-time" }]
|
|
76
|
+
elsif type == "object" && (k.classify.constantize rescue false)
|
|
77
|
+
sub_model = k.classify.constantize
|
|
78
|
+
properties = dsl[:include].present? && dsl[:include].include?(k) ? create_properties_from_model(sub_model, dsl[:include][k.to_sym]) : create_properties_from_model(sub_model, {})
|
|
79
|
+
[k, { "type": "object", "properties": properties }] rescue nil
|
|
80
|
+
elsif type == "array" && (k.classify.constantize rescue false)
|
|
81
|
+
sub_model = k.classify.constantize
|
|
82
|
+
properties = dsl[:include].present? && dsl[:include].include?(k) ? create_properties_from_model(sub_model, dsl[:include][k.to_sym]) : create_properties_from_model(sub_model, {})
|
|
83
|
+
[k, { "type": "array", "items": { "type": "object", "properties": properties } }] rescue nil
|
|
84
|
+
else
|
|
85
|
+
[k, { "type": type }]
|
|
86
|
+
end unless type.blank?
|
|
87
|
+
end.compact.to_h
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|