model_driven_api 3.6.2 → 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.
@@ -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,4 @@
1
+ module Api::V3::Auth
2
+ class OauthController < Api::V2::Auth::OauthController
3
+ end
4
+ end
@@ -0,0 +1,2 @@
1
+ class Api::V3::AuthenticationController < Api::V2::AuthenticationController
2
+ 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