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 +4 -4
- data/.cursor/rules/dependencies.mdc +19 -0
- data/.cursor/rules/examples.mdc +16 -0
- data/.cursor/rules/git.mdc +14 -0
- data/.cursor/rules/project_structure.mdc +30 -0
- data/.cursor/rules/security.mdc +14 -0
- data/.cursor/rules/style.mdc +15 -0
- data/.cursor/rules/testing.mdc +16 -0
- data/examples/relationships.md +114 -0
- data/lib/jpie/controller/related_actions.rb +45 -0
- data/lib/jpie/controller/relationship_actions.rb +291 -0
- data/lib/jpie/controller/relationship_validation.rb +117 -0
- data/lib/jpie/controller.rb +25 -0
- data/lib/jpie/errors.rb +6 -0
- data/lib/jpie/railtie.rb +6 -0
- data/lib/jpie/resource/attributable.rb +2 -2
- data/lib/jpie/routing.rb +59 -0
- data/lib/jpie/rspec.rb +71 -0
- data/lib/jpie/version.rb +1 -1
- data/lib/jpie.rb +7 -2
- metadata +14 -2
- data/.cursorrules +0 -81
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8d3cbfd72c12a023879f10c3314c321bf0210c861cffd838dc860d0497b53ae5
|
4
|
+
data.tar.gz: c8caf96a60fe557f7f07ce74e9cb725370b2d410856e33c2138f0bef7f1ba141
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/jpie/controller.rb
CHANGED
@@ -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
|
data/lib/jpie/routing.rb
ADDED
@@ -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
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 :
|
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.
|
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
|
-
- ".
|
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
|