jpie 0.3.0

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.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ desc 'Run Brakeman security scanner'
11
+ task brakeman: :environment do
12
+ require 'brakeman'
13
+ Brakeman.run app_path: '.', print_report: true, exit_on_warn: true
14
+ end
15
+
16
+ desc 'Run all checks (RSpec, RuboCop, Brakeman)'
17
+ task check: %i[spec rubocop brakeman]
18
+
19
+ task default: :check
data/jpie.gemspec ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/jpie/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'jpie'
7
+ spec.version = JPie::VERSION
8
+ spec.authors = ['Emil Kampp']
9
+ spec.email = ['emil@example.com']
10
+
11
+ spec.summary = 'A resource-focused Rails library for developing JSON:API compliant servers'
12
+ spec.description = 'JPie provides a framework for developing JSON:API compliant servers with Rails 8+. ' \
13
+ 'It focuses on clean architecture with strong separation of concerns.'
14
+ spec.homepage = 'https://github.com/emilkampp/jpie'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.4.0'
17
+
18
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = "#{spec.homepage}.git"
21
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = 'exe'
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ['lib']
33
+
34
+ # Runtime dependencies
35
+ spec.add_dependency 'activesupport', '~> 8.0', '>= 8.0.0'
36
+ spec.add_dependency 'rails', '~> 8.0', '>= 8.0.0'
37
+
38
+ # Development dependencies
39
+ spec.add_development_dependency 'brakeman', '~> 6.0'
40
+ spec.add_development_dependency 'rake', '~> 13.0'
41
+ spec.add_development_dependency 'rspec', '~> 3.12'
42
+ spec.add_development_dependency 'rspec-rails', '~> 7.0'
43
+ spec.add_development_dependency 'rubocop', '~> 1.50'
44
+ spec.add_development_dependency 'rubocop-performance', '~> 1.16'
45
+ spec.add_development_dependency 'rubocop-rails', '~> 2.18'
46
+ spec.add_development_dependency 'rubocop-rspec', '~> 2.20'
47
+ spec.metadata['rubygems_mfa_required'] = 'true'
48
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ class Configuration
5
+ attr_accessor :default_page_size, :maximum_page_size
6
+
7
+ def initialize
8
+ @default_page_size = 20
9
+ @maximum_page_size = 100
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Controller
5
+ module CrudActions
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def jsonapi_resource(resource_class)
10
+ setup_jsonapi_resource(resource_class)
11
+ end
12
+
13
+ # More concise alias for modern Rails style
14
+ alias_method :resource, :jsonapi_resource
15
+
16
+ private
17
+
18
+ def setup_jsonapi_resource(resource_class)
19
+ define_method :resource_class do
20
+ resource_class
21
+ end
22
+
23
+ # Define automatic CRUD methods
24
+ define_automatic_crud_methods(resource_class)
25
+ end
26
+
27
+ def define_automatic_crud_methods(resource_class)
28
+ define_index_method(resource_class)
29
+ define_show_method(resource_class)
30
+ define_create_method(resource_class)
31
+ define_update_method(resource_class)
32
+ define_destroy_method(resource_class)
33
+ end
34
+
35
+ def define_index_method(resource_class)
36
+ define_method :index do
37
+ resources = resource_class.scope(context)
38
+ sort_fields = parse_sort_params
39
+ resources = resource_class.sort(resources, sort_fields) if sort_fields.any?
40
+ render_jsonapi(resources)
41
+ end
42
+ end
43
+
44
+ def define_show_method(resource_class)
45
+ define_method :show do
46
+ resource = resource_class.scope(context).find(params[:id])
47
+ render_jsonapi(resource)
48
+ end
49
+ end
50
+
51
+ def define_create_method(resource_class)
52
+ define_method :create do
53
+ attributes = deserialize_params
54
+ resource = resource_class.model.create!(attributes)
55
+ render_jsonapi(resource, status: :created)
56
+ end
57
+ end
58
+
59
+ def define_update_method(resource_class)
60
+ define_method :update do
61
+ resource = resource_class.scope(context).find(params[:id])
62
+ attributes = deserialize_params
63
+ resource.update!(attributes)
64
+ render_jsonapi(resource)
65
+ end
66
+ end
67
+
68
+ def define_destroy_method(resource_class)
69
+ define_method :destroy do
70
+ resource = resource_class.scope(context).find(params[:id])
71
+ resource.destroy!
72
+ head :no_content
73
+ end
74
+ end
75
+ end
76
+
77
+ # These methods can still be called manually or used to override defaults
78
+ def index
79
+ resources = resource_class.scope(context)
80
+ sort_fields = parse_sort_params
81
+ resources = resource_class.sort(resources, sort_fields) if sort_fields.any?
82
+ render_jsonapi(resources)
83
+ end
84
+
85
+ def show
86
+ resource = resource_class.scope(context).find(params[:id])
87
+ render_jsonapi(resource)
88
+ end
89
+
90
+ def create
91
+ attributes = deserialize_params
92
+ resource = model_class.create!(attributes)
93
+ render_jsonapi(resource, status: :created)
94
+ end
95
+
96
+ def update
97
+ resource = resource_class.scope(context).find(params[:id])
98
+ attributes = deserialize_params
99
+ resource.update!(attributes)
100
+ render_jsonapi(resource)
101
+ end
102
+
103
+ def destroy
104
+ resource = resource_class.scope(context).find(params[:id])
105
+ resource.destroy!
106
+ head :no_content
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Controller
5
+ module ErrorHandling
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ rescue_from JPie::Errors::Error, with: :render_jsonapi_error
10
+
11
+ if defined?(ActiveRecord)
12
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
13
+ rescue_from ActiveRecord::RecordInvalid, with: :render_validation_error
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def render_jsonapi_error(error)
20
+ render json: { errors: [error.to_hash] },
21
+ status: error.status,
22
+ content_type: 'application/vnd.api+json'
23
+ end
24
+
25
+ def render_not_found_error(error)
26
+ json_error = JPie::Errors::NotFoundError.new(detail: error.message)
27
+ render_jsonapi_error(json_error)
28
+ end
29
+
30
+ def render_validation_error(error)
31
+ errors = error.record.errors.full_messages.map do
32
+ JPie::Errors::ValidationError.new(detail: it).to_hash
33
+ end
34
+
35
+ render json: { errors: },
36
+ status: :unprocessable_entity,
37
+ content_type: 'application/vnd.api+json'
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Controller
5
+ module ParameterParsing
6
+ def parse_include_params
7
+ params[:include]&.split(',')&.map(&:strip) || []
8
+ end
9
+
10
+ def parse_sort_params
11
+ params[:sort]&.split(',')&.map(&:strip) || []
12
+ end
13
+
14
+ def deserialize_params
15
+ deserializer.deserialize(request.body.read, context)
16
+ rescue JSON::ParserError => e
17
+ raise JPie::Errors::BadRequestError.new(detail: "Invalid JSON: #{e.message}")
18
+ end
19
+
20
+ def context
21
+ @context ||= build_context
22
+ end
23
+
24
+ private
25
+
26
+ def build_context
27
+ {
28
+ current_user: try(:current_user),
29
+ controller: self,
30
+ action: action_name
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Controller
5
+ module Rendering
6
+ def resource_class
7
+ # Default implementation that infers from controller name
8
+ @resource_class ||= infer_resource_class
9
+ end
10
+
11
+ def serializer
12
+ @serializer ||= JPie::Serializer.new(resource_class)
13
+ end
14
+
15
+ def deserializer
16
+ @deserializer ||= JPie::Deserializer.new(resource_class)
17
+ end
18
+
19
+ protected
20
+
21
+ def model_class
22
+ resource_class.model
23
+ end
24
+
25
+ # More concise method names following Rails conventions
26
+ def render_jsonapi(resource_or_resources, status: :ok, meta: nil)
27
+ includes = parse_include_params
28
+ json_data = serializer.serialize(resource_or_resources, context, includes: includes)
29
+ json_data[:meta] = meta if meta
30
+
31
+ render json: json_data, status:, content_type: 'application/vnd.api+json'
32
+ end
33
+
34
+ # Keep original methods for backward compatibility
35
+ alias render_jsonapi_resource render_jsonapi
36
+ alias render_jsonapi_resources render_jsonapi
37
+
38
+ private
39
+
40
+ def infer_resource_class
41
+ # Convert controller name to resource class name
42
+ # e.g., "UsersController" -> "UserResource"
43
+ # e.g., "Api::V1::UsersController" -> "UserResource"
44
+ controller_name = self.class.name
45
+ return nil unless controller_name&.end_with?('Controller')
46
+
47
+ # Remove "Controller" suffix and any namespace
48
+ base_name = controller_name.split('::').last.chomp('Controller')
49
+
50
+ # Convert plural controller name to singular resource name
51
+ # e.g., "Users" -> "User"
52
+ singular_name = base_name.singularize
53
+ resource_class_name = "#{singular_name}Resource"
54
+
55
+ # Try to constantize the resource class
56
+ resource_class_name.constantize
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require_relative 'controller/error_handling'
5
+ require_relative 'controller/parameter_parsing'
6
+ require_relative 'controller/rendering'
7
+ require_relative 'controller/crud_actions'
8
+
9
+ module JPie
10
+ module Controller
11
+ extend ActiveSupport::Concern
12
+
13
+ include ErrorHandling
14
+ include ParameterParsing
15
+ include Rendering
16
+ include CrudActions
17
+ end
18
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ class Deserializer
5
+ attr_reader :resource_class, :options
6
+
7
+ def initialize(resource_class, options = {})
8
+ @resource_class = resource_class
9
+ @options = options
10
+ end
11
+
12
+ def deserialize(json_data, context = {})
13
+ data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
14
+
15
+ validate_json_api_structure!(data)
16
+
17
+ if data['data'].is_a?(Array)
18
+ deserialize_collection(data['data'], context)
19
+ else
20
+ deserialize_single(data['data'], context)
21
+ end
22
+ rescue JSON::ParserError => e
23
+ raise Errors::BadRequestError.new(detail: "Invalid JSON: #{e.message}")
24
+ end
25
+
26
+ private
27
+
28
+ def deserialize_single(resource_data, context)
29
+ validate_resource_data!(resource_data)
30
+ extract_attributes(resource_data, context)
31
+ end
32
+
33
+ def deserialize_collection(resources_data, context)
34
+ resources_data.map { deserialize_single(it, context) }
35
+ end
36
+
37
+ def extract_attributes(resource_data, _context)
38
+ attributes = resource_data['attributes'] || {}
39
+ type = resource_data['type']
40
+ id = resource_data['id']
41
+
42
+ validate_type!(type) if type
43
+
44
+ model_attributes = transform_attribute_keys(attributes)
45
+ filtered_attributes = filter_allowed_attributes(model_attributes)
46
+ result = deserialize_attribute_values(filtered_attributes)
47
+
48
+ add_id_if_present(result, id)
49
+ end
50
+
51
+ def transform_attribute_keys(attributes)
52
+ attributes.transform_keys { it.to_s.underscore }
53
+ end
54
+
55
+ def filter_allowed_attributes(model_attributes)
56
+ allowed_attributes = resource_class._attributes.map(&:to_s)
57
+ model_attributes.slice(*allowed_attributes)
58
+ end
59
+
60
+ def deserialize_attribute_values(attributes)
61
+ attributes.transform_values { deserialize_value(it) }
62
+ end
63
+
64
+ def add_id_if_present(result, id)
65
+ result['id'] = id if id
66
+ result.with_indifferent_access
67
+ end
68
+
69
+ def deserialize_value(value)
70
+ case value
71
+ when String
72
+ # Only try to parse as datetime if it looks like an ISO8601 string
73
+ if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
74
+ begin
75
+ Time.parse(value)
76
+ rescue ArgumentError, TypeError
77
+ value
78
+ end
79
+ else
80
+ value
81
+ end
82
+ else
83
+ value
84
+ end
85
+ end
86
+
87
+ def validate_json_api_structure!(data)
88
+ return if data.is_a?(Hash) && data.key?('data')
89
+
90
+ raise Errors::BadRequestError.new(detail: 'Invalid JSON:API structure. Missing "data" key.')
91
+ end
92
+
93
+ def validate_resource_data!(resource_data)
94
+ raise Errors::BadRequestError.new(detail: 'Invalid resource data structure.') unless resource_data.is_a?(Hash)
95
+
96
+ return if resource_data.key?('type')
97
+
98
+ raise Errors::BadRequestError.new(detail: 'Resource data must include "type".')
99
+ end
100
+
101
+ def validate_type!(type)
102
+ expected_type = resource_class.type
103
+ return if type == expected_type
104
+
105
+ raise Errors::BadRequestError.new(
106
+ detail: "Expected type '#{expected_type}', got '#{type}'"
107
+ )
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Errors
5
+ class Error < StandardError
6
+ attr_reader :status, :code, :title, :detail, :source
7
+
8
+ def initialize(status:, code: nil, title: nil, detail: nil, source: nil)
9
+ @status = status
10
+ @code = code
11
+ @title = title
12
+ @detail = detail
13
+ @source = source
14
+ super(detail || title || 'An error occurred')
15
+ end
16
+
17
+ def to_hash
18
+ {
19
+ status: status.to_s,
20
+ code:,
21
+ title:,
22
+ detail:,
23
+ source:
24
+ }.compact
25
+ end
26
+ end
27
+
28
+ class ValidationError < Error
29
+ def initialize(detail:, source: nil)
30
+ super(status: 422, title: 'Validation Error', detail:, source:)
31
+ end
32
+ end
33
+
34
+ class NotFoundError < Error
35
+ def initialize(detail: 'Resource not found')
36
+ super(status: 404, title: 'Not Found', detail:)
37
+ end
38
+ end
39
+
40
+ class BadRequestError < Error
41
+ def initialize(detail: 'Bad Request')
42
+ super(status: 400, title: 'Bad Request', detail:)
43
+ end
44
+ end
45
+
46
+ class UnauthorizedError < Error
47
+ def initialize(detail: 'Unauthorized')
48
+ super(status: 401, title: 'Unauthorized', detail:)
49
+ end
50
+ end
51
+
52
+ class ForbiddenError < Error
53
+ def initialize(detail: 'Forbidden')
54
+ super(status: 403, title: 'Forbidden', detail:)
55
+ end
56
+ end
57
+
58
+ class InternalServerError < Error
59
+ def initialize(detail: 'Internal Server Error')
60
+ super(status: 500, title: 'Internal Server Error', detail:)
61
+ end
62
+ end
63
+
64
+ class ResourceError < Error
65
+ def initialize(detail:)
66
+ super(status: 500, title: 'Resource Error', detail:)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module JPie
6
+ module Generators
7
+ class ResourceGenerator < Rails::Generators::NamedBase
8
+ desc 'Generate a JPie resource class'
9
+
10
+ argument :attributes, type: :array, default: [], banner: 'field:type field:type'
11
+
12
+ class_option :model, type: :string, desc: 'Model class to associate with this resource'
13
+
14
+ def create_resource_file
15
+ template 'resource.rb.erb', File.join('app/resources', "#{file_name}_resource.rb")
16
+ end
17
+
18
+ private
19
+
20
+ def model_class_name
21
+ options[:model] || class_name
22
+ end
23
+
24
+ def resource_attributes
25
+ return [] if attributes.empty?
26
+
27
+ attributes.map(&:name)
28
+ end
29
+
30
+ def template_path
31
+ File.expand_path('templates', __dir__)
32
+ end
33
+
34
+ def source_root
35
+ template_path
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Resource < JPie::Resource
4
+ model <%= model_class_name %>
5
+
6
+ <% if resource_attributes.any? -%>
7
+ attributes <%= resource_attributes.map { |attr| ":#{attr}" }.join(', ') %>
8
+ <% else -%>
9
+ # Define your attributes here:
10
+ # attributes :name, :email, :created_at
11
+ <% end -%>
12
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module JPie
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :jpie
8
+
9
+ config.jpie = ActiveSupport::OrderedOptions.new
10
+
11
+ # Configure Rails inflections to preserve JPie casing
12
+ initializer 'jpie.inflections' do
13
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
14
+ inflect.acronym 'JPie'
15
+ end
16
+ end
17
+
18
+ initializer 'jpie.configure' do |app|
19
+ JPie.configure do |config|
20
+ app.config.jpie.each do |key, value|
21
+ config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
22
+ end
23
+ end
24
+ end
25
+
26
+ initializer 'jpie.action_controller' do
27
+ ActiveSupport.on_load(:action_controller) do
28
+ extend JPie::Controller::ClassMethods if defined?(JPie::Controller::ClassMethods)
29
+ end
30
+ end
31
+
32
+ generators do
33
+ require 'jpie/generators/resource_generator'
34
+ end
35
+ end
36
+ end