jpie 0.4.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0e1d1634f188bca4057e3550479af557bd00ba0d262c919ba221aa99997490e
4
- data.tar.gz: 89db580da555f310a1b840daad204e2315fcb8b7828278b328271412d3bc321d
3
+ metadata.gz: 8d3cbfd72c12a023879f10c3314c321bf0210c861cffd838dc860d0497b53ae5
4
+ data.tar.gz: c8caf96a60fe557f7f07ce74e9cb725370b2d410856e33c2138f0bef7f1ba141
5
5
  SHA512:
6
- metadata.gz: d2ced2be2db661db82e08200be2e5df033c072dcbf249565719336d3b08532d2cb01c4bbcec78cfb5e14fbbbadddb0c127292b70a9b970da3ecc20891bc8c8ab
7
- data.tar.gz: 5d1ac4453eb74ba6a8ea04036f00065e26a54c2ff85a603525dd90106bd4f82a839ed3985083c5ff60d087b6bb1239c7a618a7a024e7e3fa1955c82d863aefa8
6
+ metadata.gz: dd2a288d7526fd45c3f2a30881019656a0772942d83f208e9a031d976223a8497ea29f00295d37d2c045805dae791a79f2d6d6be5c1835007dfd9ba1b6b8b4c2
7
+ data.tar.gz: 99a5f5e5fb484a394ce36c6ac9f5635c2f06c820dc6c41f16d89903e7135fde2b1e32d6a1a7df5277ee3ee3f248b15651a1353fb83315e96c9525a47b8638221
@@ -0,0 +1,19 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Dependency Management
7
+
8
+ ## Requirements
9
+ - Keep dependencies minimal and justified
10
+ - Document new dependencies in README.md
11
+ - Keep development dependencies in Gemfile
12
+ - Ensure compatibility with Ruby 3.4+
13
+ - Only support Rails 8+ features
14
+ - Use modern gem versions
15
+
16
+ ## Compatibility Requirements
17
+ - Maintain Ruby 3.4+ compatibility
18
+ - Support Rails 8+ integration
19
+ - Follow JSON:API specification strictly
@@ -0,0 +1,16 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Example Guidelines
7
+
8
+ - Examples must only include required code
9
+ - Examples must not include any unrelated or superfluous code
10
+ - Examples must be a single markdown file
11
+ - Examples must use the `http` code blocks for examples
12
+ - Examples must only include the minimum number of examples
13
+ - Examples must never include migrations
14
+ - Examples must not include a features section or similar
15
+ - Examples must include an introduction to the example
16
+ - Examples live in /examples/*.md
@@ -0,0 +1,14 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Git Commit Guidelines
7
+
8
+ - Write clear, descriptive commit messages
9
+ - Keep commits focused and atomic
10
+ - Run and pass tests before committing
11
+ - Update documentation in the same commit as code changes
12
+ - Ignore spec/examples.txt and other files listed in .gitignore
13
+ - Include Ruby/Rails version requirements in relevant commits
14
+ - Update tests in the same commit as code changes
@@ -0,0 +1,30 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Project Information
7
+ - Project Name: jpie
8
+ - Description: Ruby gem for JSON:API implementation
9
+
10
+ # Project Structure
11
+
12
+ ## Core Structure
13
+ - Keep core functionality in lib/jpie/
14
+ - Place tests in spec/jpie/
15
+ - Use proper namespacing (JPie module)
16
+ - Follow Ruby gem best practices
17
+
18
+ ## Protected Files
19
+ - Do not update spec/jpie/database.rb unless absolutely necessary
20
+ - Do not update spec/jpie/resources.rb unless absolutely necessary
21
+
22
+ ## Documentation
23
+ - Keep README.md up to date with installation and usage instructions
24
+ - Include example usage in documentation
25
+
26
+ ## Development Process
27
+ - Always read the .aiconfig file
28
+ - Always implement code slowly and methodically
29
+ - Always test as you go
30
+ - Always make sure rubocop passes
@@ -0,0 +1,14 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Security Guidelines
7
+
8
+ - Never commit sensitive data or credentials
9
+ - Use environment variables for configuration
10
+ - Follow secure coding practices
11
+ - Keep dependencies up to date
12
+ - Follow Rails 8+ security best practices
13
+ - Use Ruby 3.4+ security features
14
+ - Implement proper input validation
@@ -0,0 +1,15 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Style Guidelines
7
+
8
+ ## Ruby Style
9
+ - Follow Ruby style guide and RuboCop rules defined in .rubocop.yml
10
+ - Prefer rubocop autocorrect
11
+ - Always pass rubocop before committing to git
12
+ - Use `{data:}` rather than `{data: data}`
13
+ - Only use code comments when absolutely necessary
14
+ - Keep any code comments short and concise
15
+ - Don't update .rubocop.yml unless absolutely necessary
@@ -0,0 +1,16 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: false
5
+ ---
6
+ # Testing Guidelines
7
+
8
+ - Write RSpec tests for all new features
9
+ - Maintain test coverage for all public methods
10
+ - Use meaningful test descriptions
11
+ - Follow the existing test structure in spec/
12
+ - Test both success and error cases
13
+ - Use modern RSpec features and syntax
14
+ - Don't reduce test coverage (line, file, branch, or other)
15
+ - Only care about coverage reduction when running all specs
16
+ - Do not add any functionality for new features to existing tests unless absolutely necessary
@@ -0,0 +1,114 @@
1
+ # JSON:API Relationship Management
2
+
3
+ This example demonstrates how to manage relationships using JPie's JSON:API compliant relationship endpoints.
4
+
5
+ ## Setup
6
+
7
+ Define your resources with relationships:
8
+
9
+ ```ruby
10
+ # app/resources/user_resource.rb
11
+ class UserResource < JPie::Resource
12
+ model User
13
+ attributes :name, :email
14
+ has_many :posts
15
+ end
16
+
17
+ # app/resources/post_resource.rb
18
+ class PostResource < JPie::Resource
19
+ model Post
20
+ attributes :title, :content
21
+ has_one :author, resource: 'UserResource'
22
+ end
23
+
24
+ # app/controllers/users_controller.rb
25
+ class UsersController < ApplicationController
26
+ include JPie::Controller
27
+ end
28
+ ```
29
+
30
+ Configure routes:
31
+
32
+ ```ruby
33
+ # config/routes.rb
34
+ Rails.application.routes.draw do
35
+ jpie_resources :users
36
+ jpie_resources :posts
37
+ end
38
+ ```
39
+
40
+ ## Relationship Operations
41
+
42
+ ### Get Relationship Linkage
43
+
44
+ ```http
45
+ GET /users/1/relationships/posts
46
+
47
+ Response:
48
+ {
49
+ "data": [
50
+ { "type": "posts", "id": "1" },
51
+ { "type": "posts", "id": "3" }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ ### Replace Relationship
57
+
58
+ ```http
59
+ PATCH /users/1/relationships/posts
60
+ Content-Type: application/vnd.api+json
61
+
62
+ {
63
+ "data": [
64
+ { "type": "posts", "id": "2" },
65
+ { "type": "posts", "id": "4" }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ ### Add to Relationship
71
+
72
+ ```http
73
+ POST /users/1/relationships/posts
74
+ Content-Type: application/vnd.api+json
75
+
76
+ {
77
+ "data": [
78
+ { "type": "posts", "id": "5" }
79
+ ]
80
+ }
81
+ ```
82
+
83
+ ### Remove from Relationship
84
+
85
+ ```http
86
+ DELETE /users/1/relationships/posts
87
+ Content-Type: application/vnd.api+json
88
+
89
+ {
90
+ "data": [
91
+ { "type": "posts", "id": "2" }
92
+ ]
93
+ }
94
+ ```
95
+
96
+ ### Get Related Resources
97
+
98
+ ```http
99
+ GET /users/1/posts
100
+
101
+ Response:
102
+ {
103
+ "data": [
104
+ {
105
+ "type": "posts",
106
+ "id": "1",
107
+ "attributes": {
108
+ "title": "First Post",
109
+ "content": "Hello world!"
110
+ }
111
+ }
112
+ ]
113
+ }
114
+ ```
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Controller
5
+ module RelatedActions
6
+ extend ActiveSupport::Concern
7
+
8
+ # GET /resources/:id/:relationship_name
9
+ # Returns the related resources themselves (not just linkage)
10
+ def related_show
11
+ validate_relationship_exists
12
+ validate_include_params
13
+ resource = find_resource
14
+ related_resources = get_related_resources(resource)
15
+ render_jsonapi(related_resources)
16
+ end
17
+
18
+ private
19
+
20
+ def validate_relationship_exists
21
+ relationship_name = params[:relationship_name]
22
+ return unless relationship_name # Skip validation if no relationship_name param
23
+
24
+ return if resource_class._relationships.key?(relationship_name.to_sym)
25
+
26
+ raise JPie::Errors::NotFoundError.new(
27
+ detail: "Relationship '#{relationship_name}' does not exist for #{resource_class.name}"
28
+ )
29
+ end
30
+
31
+ def find_resource
32
+ resource_class.scope(context).find(params[:id])
33
+ end
34
+
35
+ def relationship_name
36
+ @relationship_name ||= params[:relationship_name].to_sym
37
+ end
38
+
39
+ def get_related_resources(resource)
40
+ relationship_method = relationship_name
41
+ resource.send(relationship_method)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'relationship_validation'
4
+
5
+ module JPie
6
+ module Controller
7
+ module RelationshipActions
8
+ extend ActiveSupport::Concern
9
+ include RelationshipValidation
10
+
11
+ # GET /resources/:id/relationships/:relationship_name
12
+ # Returns relationship linkage data
13
+ def relationship_show
14
+ validate_relationship_exists
15
+ resource = find_resource
16
+ relationship_data = get_relationship_data(resource)
17
+ render_relationship_data(relationship_data)
18
+ end
19
+
20
+ # PATCH /resources/:id/relationships/:relationship_name
21
+ # Updates relationship linkage (replaces all relationships)
22
+ def relationship_update
23
+ validate_relationship_exists
24
+ validate_relationship_update_request
25
+ resource = find_resource
26
+ relationship_data = parse_relationship_data
27
+ update_relationship_data(resource, relationship_data)
28
+ render_relationship_data(get_relationship_data(resource))
29
+ end
30
+
31
+ # POST /resources/:id/relationships/:relationship_name
32
+ # Adds to relationship linkage (for to-many relationships)
33
+ def relationship_create
34
+ validate_relationship_exists
35
+ validate_relationship_update_request
36
+ resource = find_resource
37
+
38
+ unless relationship_is_to_many?
39
+ raise JPie::Errors::BadRequestError.new(
40
+ detail: 'POST is only supported for to-many relationships'
41
+ )
42
+ end
43
+
44
+ relationship_data = parse_relationship_data
45
+
46
+ unless relationship_data.is_a?(Array)
47
+ raise JPie::Errors::BadRequestError.new(
48
+ detail: 'Adding to relationships requires an array of resource identifier objects'
49
+ )
50
+ end
51
+
52
+ add_to_relationship(resource, relationship_data)
53
+ render_relationship_data(get_relationship_data(resource))
54
+ end
55
+
56
+ # DELETE /resources/:id/relationships/:relationship_name
57
+ # Removes from relationship linkage (for to-many relationships)
58
+ def relationship_destroy
59
+ validate_relationship_exists
60
+ validate_relationship_update_request
61
+ resource = find_resource
62
+
63
+ unless relationship_is_to_many?
64
+ raise JPie::Errors::BadRequestError.new(
65
+ detail: 'DELETE is only supported for to-many relationships'
66
+ )
67
+ end
68
+
69
+ relationship_data = parse_relationship_data
70
+
71
+ unless relationship_data.is_a?(Array)
72
+ raise JPie::Errors::BadRequestError.new(
73
+ detail: 'Removing from relationships requires an array of resource identifier objects'
74
+ )
75
+ end
76
+
77
+ remove_from_relationship(resource, relationship_data)
78
+ render_relationship_data(get_relationship_data(resource))
79
+ end
80
+
81
+ private
82
+
83
+ def find_resource
84
+ resource_class.scope(context).find(params[:id])
85
+ end
86
+
87
+ def relationship_name
88
+ @relationship_name ||= params[:relationship_name].to_sym
89
+ end
90
+
91
+ def relationship_config
92
+ @relationship_config ||= resource_class._relationships[relationship_name]
93
+ end
94
+
95
+ def get_relationship_data(resource)
96
+ relationship_method = relationship_name
97
+ related_objects = resource.send(relationship_method)
98
+
99
+ if related_objects.respond_to?(:each)
100
+ # to-many relationship
101
+ related_objects.map { |obj| { type: infer_type(obj), id: obj.id.to_s } }
102
+ elsif related_objects
103
+ # to-one relationship
104
+ { type: infer_type(related_objects), id: related_objects.id.to_s }
105
+ end
106
+ end
107
+
108
+ def parse_relationship_data
109
+ body = request.body.read
110
+ request.body.rewind
111
+ parsed_body = JSON.parse(body)
112
+
113
+ unless parsed_body.key?('data')
114
+ raise JPie::Errors::BadRequestError.new(
115
+ detail: 'Request must include a "data" member'
116
+ )
117
+ end
118
+
119
+ parsed_body['data']
120
+ end
121
+
122
+ def update_relationship_data(resource, relationship_data)
123
+ if relationship_data.nil?
124
+ # Set relationship to null (only valid for to-one relationships)
125
+ unless relationship_is_to_many?
126
+ clear_relationship(resource)
127
+ else
128
+ raise JPie::Errors::BadRequestError.new(
129
+ detail: 'Cannot set a to-many relationship to null'
130
+ )
131
+ end
132
+ elsif relationship_data.is_a?(Array)
133
+ # to-many relationship - replace all
134
+ if relationship_is_to_many?
135
+ replace_to_many_relationship(resource, relationship_data)
136
+ else
137
+ raise JPie::Errors::BadRequestError.new(
138
+ detail: 'Invalid data type for to-one relationship'
139
+ )
140
+ end
141
+ elsif relationship_data.is_a?(Hash)
142
+ # to-one relationship - replace
143
+ unless relationship_is_to_many?
144
+ replace_to_one_relationship(resource, relationship_data)
145
+ else
146
+ raise JPie::Errors::BadRequestError.new(
147
+ detail: 'Invalid data type for to-many relationship'
148
+ )
149
+ end
150
+ else
151
+ raise JPie::Errors::BadRequestError.new(
152
+ detail: 'Relationship data must be null, an object, or an array of objects'
153
+ )
154
+ end
155
+ end
156
+
157
+ def add_to_relationship(resource, relationship_data)
158
+ begin
159
+ related_objects = find_related_objects(relationship_data)
160
+ association = association_for_resource(resource)
161
+
162
+ related_objects.each do |related_object|
163
+ association << related_object unless association.include?(related_object)
164
+ end
165
+
166
+ resource.save!
167
+ rescue ActiveRecord::AssociationTypeMismatch => e
168
+ raise JPie::Errors::NotFoundError.new(
169
+ detail: "Related resource not found: Invalid resource type for relationship"
170
+ )
171
+ rescue ActiveRecord::RecordInvalid => e
172
+ raise JPie::Errors::ValidationError.new(
173
+ detail: "Failed to add relationships: #{e.message}"
174
+ )
175
+ end
176
+ end
177
+
178
+ def remove_from_relationship(resource, relationship_data)
179
+ begin
180
+ related_objects = find_related_objects(relationship_data)
181
+ association = association_for_resource(resource)
182
+
183
+ related_objects.each do |related_object|
184
+ association.delete(related_object)
185
+ end
186
+
187
+ resource.save!
188
+ rescue ActiveRecord::AssociationTypeMismatch => e
189
+ raise JPie::Errors::NotFoundError.new(
190
+ detail: "Related resource not found: Invalid resource type for relationship"
191
+ )
192
+ rescue ActiveRecord::RecordInvalid => e
193
+ raise JPie::Errors::ValidationError.new(
194
+ detail: "Failed to remove relationships: #{e.message}"
195
+ )
196
+ end
197
+ end
198
+
199
+ def clear_relationship(resource)
200
+ association_name = association_name_for_relationship
201
+ resource.send("#{association_name}=", nil)
202
+ resource.save!
203
+ rescue ActiveRecord::RecordInvalid => e
204
+ raise JPie::Errors::ValidationError.new(
205
+ detail: "Failed to clear relationship: #{e.message}"
206
+ )
207
+ end
208
+
209
+ def replace_to_many_relationship(resource, relationship_data)
210
+ begin
211
+ related_objects = find_related_objects(relationship_data)
212
+ association_name = association_name_for_relationship
213
+ resource.send("#{association_name}=", related_objects)
214
+ resource.save!
215
+ rescue ActiveRecord::AssociationTypeMismatch => e
216
+ raise JPie::Errors::NotFoundError.new(
217
+ detail: "Related resource not found: Invalid resource type for relationship"
218
+ )
219
+ rescue ActiveRecord::RecordInvalid => e
220
+ raise JPie::Errors::ValidationError.new(
221
+ detail: "Failed to replace relationships: #{e.message}"
222
+ )
223
+ end
224
+ end
225
+
226
+ def replace_to_one_relationship(resource, relationship_data)
227
+ begin
228
+ related_object = find_related_object(relationship_data)
229
+ association_name = association_name_for_relationship
230
+ resource.send("#{association_name}=", related_object)
231
+ resource.save!
232
+ rescue ActiveRecord::AssociationTypeMismatch => e
233
+ raise JPie::Errors::NotFoundError.new(
234
+ detail: "Related resource not found: Invalid resource type for relationship"
235
+ )
236
+ rescue ActiveRecord::RecordInvalid => e
237
+ raise JPie::Errors::ValidationError.new(
238
+ detail: "Failed to replace relationship: #{e.message}"
239
+ )
240
+ end
241
+ end
242
+
243
+ def find_related_objects(relationship_data)
244
+ relationship_data.map { |data| find_related_object(data) }
245
+ end
246
+
247
+ def find_related_object(resource_identifier)
248
+ validate_resource_identifier(resource_identifier)
249
+
250
+ type = resource_identifier['type']
251
+ id = resource_identifier['id']
252
+
253
+ related_model_class = infer_model_class_from_type(type)
254
+ related_model_class.find(id)
255
+ rescue ActiveRecord::RecordNotFound
256
+ raise JPie::Errors::NotFoundError.new(
257
+ detail: "Related resource not found: #{type}##{id}"
258
+ )
259
+ end
260
+
261
+ def association_for_resource(resource)
262
+ association_name = association_name_for_relationship
263
+ resource.send(association_name)
264
+ end
265
+
266
+ def association_name_for_relationship
267
+ # For now, assume the relationship name matches the association name
268
+ # This could be made more sophisticated to handle custom association names
269
+ relationship_name
270
+ end
271
+
272
+ def infer_type(object)
273
+ # Convert model class name to JSON:API type
274
+ # e.g., "User" -> "users", "BlogPost" -> "blog-posts"
275
+ object.class.name.underscore.dasherize.pluralize
276
+ end
277
+
278
+ def infer_model_class_from_type(type)
279
+ # Convert JSON:API type back to model class
280
+ # e.g., "users" -> User, "blog-posts" -> BlogPost
281
+ class_name = type.singularize.underscore.camelize
282
+ class_name.constantize
283
+ end
284
+
285
+ def render_relationship_data(relationship_data)
286
+ response_data = { data: relationship_data }
287
+ render json: response_data, content_type: 'application/vnd.api+json'
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Controller
5
+ module RelationshipValidation
6
+ private
7
+
8
+ def validate_relationship_exists
9
+ relationship_name = params[:relationship_name]
10
+ return unless relationship_name # Skip validation if no relationship_name param
11
+
12
+ return if resource_class._relationships.key?(relationship_name.to_sym)
13
+
14
+ raise JPie::Errors::NotFoundError.new(
15
+ detail: "Relationship '#{relationship_name}' does not exist for #{resource_class.name}"
16
+ )
17
+ end
18
+
19
+ def validate_relationship_update_request
20
+ validate_content_type
21
+ validate_request_body
22
+ validate_relationship_type
23
+ end
24
+
25
+ def validate_content_type
26
+ # Only validate content type for write operations
27
+ return unless request.post? || request.patch? || request.put?
28
+
29
+ content_type = request.content_type
30
+ return if content_type&.include?('application/vnd.api+json')
31
+
32
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
33
+ detail: 'Content-Type must be application/vnd.api+json for JSON:API requests'
34
+ )
35
+ end
36
+
37
+ def validate_request_body
38
+ body = request.body.read
39
+ request.body.rewind
40
+
41
+ raise JPie::Errors::BadRequestError.new(detail: 'Request body cannot be empty') if body.blank?
42
+
43
+ JSON.parse(body)
44
+ rescue JSON::ParserError => e
45
+ raise JPie::Errors::BadRequestError.new(detail: "Invalid JSON: #{e.message}")
46
+ end
47
+
48
+ def validate_resource_identifier(resource_identifier)
49
+ unless resource_identifier.is_a?(Hash) &&
50
+ resource_identifier.key?('type') &&
51
+ resource_identifier.key?('id')
52
+ raise JPie::Errors::BadRequestError.new(
53
+ detail: 'Resource identifier objects must have "type" and "id" members'
54
+ )
55
+ end
56
+
57
+ type = resource_identifier['type']
58
+ id = resource_identifier['id']
59
+
60
+ unless type.is_a?(String) && id.is_a?(String)
61
+ raise JPie::Errors::BadRequestError.new(
62
+ detail: 'Resource identifier object members must be strings'
63
+ )
64
+ end
65
+
66
+ if type.empty? || id.empty?
67
+ raise JPie::Errors::BadRequestError.new(
68
+ detail: 'Resource identifier object members cannot be empty strings'
69
+ )
70
+ end
71
+ end
72
+
73
+ def validate_relationship_type
74
+ validate_relationship_exists
75
+ data = parse_relationship_data
76
+
77
+ if relationship_is_to_many?
78
+ validate_to_many_relationship_data(data)
79
+ else
80
+ validate_to_one_relationship_data(data)
81
+ end
82
+ end
83
+
84
+ def validate_to_many_relationship_data(data)
85
+ if data.nil?
86
+ raise JPie::Errors::BadRequestError.new(
87
+ detail: 'Cannot set a to-many relationship to null'
88
+ )
89
+ end
90
+
91
+ unless data.is_a?(Array)
92
+ raise JPie::Errors::BadRequestError.new(
93
+ detail: 'The value of data must be an array for to-many relationships'
94
+ )
95
+ end
96
+
97
+ data.each { |identifier| validate_resource_identifier(identifier) }
98
+ end
99
+
100
+ def validate_to_one_relationship_data(data)
101
+ unless data.nil? || data.is_a?(Hash)
102
+ raise JPie::Errors::BadRequestError.new(
103
+ detail: 'The value of data must be a single resource identifier object or null for to-one relationships'
104
+ )
105
+ end
106
+
107
+ validate_resource_identifier(data) if data
108
+ end
109
+
110
+ def relationship_is_to_many?
111
+ return false unless relationship_config
112
+
113
+ relationship_config[:type] == :has_many
114
+ end
115
+ end
116
+ end
117
+ end
@@ -6,6 +6,8 @@ require_relative 'controller/parameter_parsing'
6
6
  require_relative 'controller/rendering'
7
7
  require_relative 'controller/crud_actions'
8
8
  require_relative 'controller/json_api_validation'
9
+ require_relative 'controller/relationship_actions'
10
+ require_relative 'controller/related_actions'
9
11
 
10
12
  module JPie
11
13
  module Controller
@@ -16,5 +18,28 @@ module JPie
16
18
  include Rendering
17
19
  include CrudActions
18
20
  include JsonApiValidation
21
+ include RelationshipActions
22
+ include RelatedActions
23
+
24
+ # Relationship route actions
25
+ def show_relationship
26
+ relationship_show
27
+ end
28
+
29
+ def update_relationship
30
+ relationship_update
31
+ end
32
+
33
+ def create_relationship
34
+ relationship_create
35
+ end
36
+
37
+ def destroy_relationship
38
+ relationship_destroy
39
+ end
40
+
41
+ def show_related
42
+ related_show
43
+ end
19
44
  end
20
45
  end
data/lib/jpie/errors.rb CHANGED
@@ -55,6 +55,12 @@ module JPie
55
55
  end
56
56
  end
57
57
 
58
+ class UnsupportedMediaTypeError < Error
59
+ def initialize(detail: 'Unsupported Media Type')
60
+ super(status: 415, title: 'Unsupported Media Type', detail:)
61
+ end
62
+ end
63
+
58
64
  class InternalServerError < Error
59
65
  def initialize(detail: 'Internal Server Error')
60
66
  super(status: 500, title: 'Internal Server Error', detail:)
data/lib/jpie/railtie.rb CHANGED
@@ -29,6 +29,12 @@ module JPie
29
29
  end
30
30
  end
31
31
 
32
+ initializer 'jpie.routing' do
33
+ ActiveSupport.on_load(:action_dispatch) do
34
+ ActionDispatch::Routing::Mapper.include JPie::Routing
35
+ end
36
+ end
37
+
32
38
  generators do
33
39
  require 'jpie/generators/resource_generator'
34
40
  end
@@ -59,13 +59,13 @@ module JPie
59
59
  def has_many(name, options = {})
60
60
  name = name.to_sym
61
61
  resource_class_name = options[:resource] || infer_resource_class_name(name)
62
- relationship(name, { resource: resource_class_name }.merge(options))
62
+ relationship(name, { type: :has_many, resource: resource_class_name }.merge(options))
63
63
  end
64
64
 
65
65
  def has_one(name, options = {})
66
66
  name = name.to_sym
67
67
  resource_class_name = options[:resource] || infer_resource_class_name(name)
68
- relationship(name, { resource: resource_class_name }.merge(options))
68
+ relationship(name, { type: :has_one, resource: resource_class_name }.merge(options))
69
69
  end
70
70
 
71
71
  private
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ module Routing
5
+ # Add jpie_resources method to Rails routing DSL that creates JSON:API compliant routes
6
+ def jpie_resources(*resources)
7
+ options = resources.extract_options!
8
+ merged_options = build_merged_options(options)
9
+
10
+ # Create standard RESTful routes for the resource
11
+ resources(*resources, merged_options) do
12
+ yield if block_given?
13
+ add_jsonapi_relationship_routes(merged_options) if relationship_routes_allowed?(merged_options)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def build_merged_options(options)
20
+ default_options = {
21
+ defaults: { format: :json },
22
+ constraints: { format: :json }
23
+ }
24
+ default_options.merge(options)
25
+ end
26
+
27
+ def relationship_routes_allowed?(merged_options)
28
+ only_actions = merged_options[:only]
29
+ except_actions = merged_options[:except]
30
+
31
+ if only_actions
32
+ # If only specific actions are allowed, don't add relationship routes
33
+ # unless multiple member actions (show, update, destroy) are included
34
+ (only_actions & %i[show update destroy]).size >= 2
35
+ elsif except_actions
36
+ # If actions are excluded, only add if member actions aren't excluded
37
+ !except_actions.intersect?(%i[show update destroy])
38
+ else
39
+ true
40
+ end
41
+ end
42
+
43
+ def add_jsonapi_relationship_routes(_merged_options)
44
+ # These routes handle relationship management as per JSON:API spec
45
+ member do
46
+ # Routes for fetching and updating relationships
47
+ # Pattern: /resources/:id/relationships/:relationship_name
48
+ get 'relationships/*relationship_name', action: :show_relationship, as: :relationship
49
+ patch 'relationships/*relationship_name', action: :update_relationship
50
+ post 'relationships/*relationship_name', action: :create_relationship
51
+ delete 'relationships/*relationship_name', action: :destroy_relationship
52
+
53
+ # Routes for fetching related resources
54
+ # Pattern: /resources/:id/:relationship_name
55
+ get '*relationship_name', action: :show_related, as: :related
56
+ end
57
+ end
58
+ end
59
+ end
data/lib/jpie/rspec.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+
5
+ module JPie
6
+ # RSpec matchers and helpers for testing JPie resources
7
+ module RSpec
8
+ # Configure RSpec with JPie helpers and matchers
9
+ def self.configure!
10
+ ::RSpec.configure do |config|
11
+ config.include JPie::RSpec::Matchers
12
+ config.include JPie::RSpec::Helpers
13
+ end
14
+ end
15
+
16
+ # Custom matchers for JPie resources
17
+ module Matchers
18
+ extend ::RSpec::Matchers::DSL
19
+
20
+ matcher :have_attribute do |attribute_name|
21
+ match do |actual|
22
+ actual.respond_to?(attribute_name) &&
23
+ actual.attributes.key?(attribute_name.to_s)
24
+ end
25
+
26
+ failure_message do |actual|
27
+ "expected #{actual.inspect} to have attribute '#{attribute_name}'"
28
+ end
29
+ end
30
+
31
+ matcher :have_relationship do |relationship_name|
32
+ match do |actual|
33
+ actual.respond_to?(relationship_name) &&
34
+ actual.relationships.key?(relationship_name.to_s)
35
+ end
36
+
37
+ failure_message do |actual|
38
+ "expected #{actual.inspect} to have relationship '#{relationship_name}'"
39
+ end
40
+ end
41
+ end
42
+
43
+ # Helper methods for testing JPie resources
44
+ module Helpers
45
+ # Build a JPie resource without saving it
46
+ def build_jpie_resource(type, attributes = {}, relationships = {})
47
+ JPie::Resource.new(
48
+ type: type,
49
+ attributes: attributes,
50
+ relationships: relationships
51
+ )
52
+ end
53
+
54
+ # Create a JPie resource and save it
55
+ def create_jpie_resource(type, attributes = {}, relationships = {})
56
+ resource = build_jpie_resource(type, attributes, relationships)
57
+ resource.save
58
+ resource
59
+ end
60
+
61
+ # Clean up test data after specs
62
+ def cleanup_jpie_resources(resources)
63
+ Array(resources).each do |resource|
64
+ resource.destroy if resource.persisted?
65
+ rescue StandardError => e
66
+ warn "Failed to cleanup resource #{resource.inspect}: #{e.message}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/jpie/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JPie
4
- VERSION = '0.4.2'
4
+ VERSION = '0.4.4'
5
5
  end
data/lib/jpie.rb CHANGED
@@ -3,14 +3,19 @@
3
3
  require 'active_support'
4
4
  require 'active_support/core_ext'
5
5
  require 'jpie/version'
6
+ require_relative 'jpie/resource'
7
+ require_relative 'jpie/client'
8
+ require_relative 'jpie/errors'
9
+
10
+ # Load RSpec support if RSpec is defined
11
+ require_relative 'jpie/rspec' if defined?(RSpec)
6
12
 
7
13
  module JPie
8
- autoload :Resource, 'jpie/resource'
9
14
  autoload :Serializer, 'jpie/serializer'
10
15
  autoload :Deserializer, 'jpie/deserializer'
11
16
  autoload :Controller, 'jpie/controller'
12
17
  autoload :Configuration, 'jpie/configuration'
13
- autoload :Errors, 'jpie/errors'
18
+ autoload :Routing, 'jpie/routing'
14
19
 
15
20
  class << self
16
21
  def configuration
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jpie
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -169,7 +169,13 @@ executables: []
169
169
  extensions: []
170
170
  extra_rdoc_files: []
171
171
  files:
172
- - ".cursorrules"
172
+ - ".cursor/rules/dependencies.mdc"
173
+ - ".cursor/rules/examples.mdc"
174
+ - ".cursor/rules/git.mdc"
175
+ - ".cursor/rules/project_structure.mdc"
176
+ - ".cursor/rules/security.mdc"
177
+ - ".cursor/rules/style.mdc"
178
+ - ".cursor/rules/testing.mdc"
173
179
  - ".overcommit.yml"
174
180
  - ".rubocop.yml"
175
181
  - CHANGELOG.md
@@ -179,6 +185,7 @@ files:
179
185
  - examples/basic_example.md
180
186
  - examples/including_related_resources.md
181
187
  - examples/pagination.md
188
+ - examples/relationships.md
182
189
  - examples/resource_attribute_configuration.md
183
190
  - examples/resource_meta_configuration.md
184
191
  - examples/single_table_inheritance.md
@@ -192,6 +199,9 @@ files:
192
199
  - lib/jpie/controller/error_handling/handlers.rb
193
200
  - lib/jpie/controller/json_api_validation.rb
194
201
  - lib/jpie/controller/parameter_parsing.rb
202
+ - lib/jpie/controller/related_actions.rb
203
+ - lib/jpie/controller/relationship_actions.rb
204
+ - lib/jpie/controller/relationship_validation.rb
195
205
  - lib/jpie/controller/rendering.rb
196
206
  - lib/jpie/deserializer.rb
197
207
  - lib/jpie/errors.rb
@@ -202,6 +212,8 @@ files:
202
212
  - lib/jpie/resource/attributable.rb
203
213
  - lib/jpie/resource/inferrable.rb
204
214
  - lib/jpie/resource/sortable.rb
215
+ - lib/jpie/routing.rb
216
+ - lib/jpie/rspec.rb
205
217
  - lib/jpie/serializer.rb
206
218
  - lib/jpie/version.rb
207
219
  homepage: https://github.com/emk-klaay/jpie
data/.cursorrules DELETED
@@ -1,81 +0,0 @@
1
- # AI Assistant Configuration
2
- # This file contains rules and guidelines for AI assistants working on the jpie project
3
-
4
- # Follow these code style guidelines
5
- - Follow Ruby style guide and RuboCop rules defined in .rubocop.yml
6
- - Prefer rubocop autocorrect
7
- - Always pass rubocop before committing to git
8
- - Use `{data:}` rather than `{data: data}`
9
- - Only use code comments when abasolutely necessary
10
- - Keep any code comments short and concise
11
- - Don't update the .rubocop.yml unless it's absolutely necessarry
12
-
13
- # Documentation requirements
14
- - Keep README.md up to date with installation and usage instructions
15
- - Include example usage in documentation
16
-
17
- # Testing guidelines
18
- - Write RSpec tests for all new features
19
- - Maintain test coverage for all public methods
20
- - Use meaningful test descriptions
21
- - Follow the existing test structure in spec/
22
- - Test both success and error cases
23
- - Use modern RSpec features and syntax
24
- - Don't reduce test coverage (line, file, branch, or other)
25
-
26
- # Git commit guidelines
27
- - Write clear, descriptive commit messages
28
- - Keep commits focused and atomic
29
- - Run and pass tests before committing
30
- - Update documentation in the same commit as code changes
31
- - Ignore spec/examples.txt and other files listed in .gitignore
32
- - Include Ruby/Rails version requirements in relevant commits
33
- - Update tests in the same commit as code changes
34
-
35
- # Maintain the following project structure
36
- - Keep core functionality in lib/jpie/
37
- - Place tests in spec/jpie/
38
- - Use proper namespacing (JPie module)
39
- - Follow Ruby gem best practices
40
- - Do not update the spec/jpie/database.rb unless it's absolutely necessarry
41
- - Do not update the spec/jpie/resources.rb unless it's absolutely necessarry
42
-
43
- # Dependency management
44
- - Keep dependencies minimal and justified
45
- - Document new dependencies in README.md
46
- - Keep development dependencies in Gemfile
47
- - Ensure compatibility with Ruby 3.4+
48
- - Only support Rails 8+ features
49
- - Use modern gem versions
50
-
51
- # Security guidelines
52
- - Never commit sensitive data or credentials
53
- - Use environment variables for configuration
54
- - Follow secure coding practices
55
- - Keep dependencies up to date
56
- - Follow Rails 8+ security best practices
57
- - Use Ruby 3.4+ security features
58
- - Implement proper input validation
59
-
60
- # Compatibility requirements
61
- - Maintain Ruby 3.4+ compatibility
62
- - Support Rails 8+ integration
63
- - Follow JSON:API specification strictly
64
-
65
- # When implementing new features or refactoring
66
- - Always read the .aiconfig file
67
- - Always implement code slowly and methodically
68
- - Always test as you go
69
- - Always make sure rubocop passes
70
- - Do not add any functionality for the new feature to existing tests unless absolutely necessarry.
71
-
72
- # Examples
73
- - The examples must _only_ include _required_ code
74
- - The examples must not include any unrelated or supurflous code
75
- - Examples must be a single markdown file
76
- - Examples must use the `http` code blocks for its exampels
77
- - Examples must only include the minium number of examples
78
- - Examples must never include migrations.
79
- - Examples must not include a features section or similar
80
- - Examples must include an introduction to the example
81
- - Examples live in /examples/*.md