mk_framework 0.2.3 → 0.2.5

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: c68f913eebaf1aa8a9b4d423b528e4720a6508b5f0f130c03b15d039458ec6d1
4
- data.tar.gz: be858c42ce6d158eb5e658bb9a7e20ec9a775795f0f5956b03100ee733dd4c2f
3
+ metadata.gz: 28e516ad56933bcfe68ce986d9bc872148ef289ef9fabfe90f9315786fae471e
4
+ data.tar.gz: ab4fda8fc53f07a5fd2df9921c40fc95182caf6be21542207d06c7716f0e255d
5
5
  SHA512:
6
- metadata.gz: 79a22c1dcd019cfcb8ad3653a9fdd84c4246de7cdc95faa21132be72dc1306285c49bc5a37f29a3bdd64342718024ac126fcf33e7fe38dcdbde673dca3e73536
7
- data.tar.gz: f42544ff916968bf3cfc5152b9ed3cd866050b301407e5f1b840637db4b73226f1a3a604b36102b0195b8a9e584c4e7ab90cb590afee744bf9462b1b0ac79538
6
+ metadata.gz: 2eeebeca751cec2bc9f5a372d4a927d72fcfc28fb86e859c56f0557e905067f3c4dabf6dc57ff28ea118d7d5813c696857a89c75ac7abe4441581a0f6c8efc03
7
+ data.tar.gz: 14dc30546bdbecd0e3eb3bf6e386565c7fc09813353c7b79d2792185c8a15689518221c7e7e4f136f893834a51f4691a90cc0e802f04e28286c440fbdc7e5939
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5 — 2026-09-12
4
+
5
+ - Generate Gemfile dependencies in alphabetical order within each group.
6
+ - Align generated database connection keyword arguments for RuboCop.
7
+ - Generate bracketed symbol arrays in model validations and public attribute lists.
8
+
3
9
  ## 0.2.3 — 2026-09-12
4
10
 
5
11
  - Use `rack-test` and `Rack::Test::Methods` in generated request specs.
data/README.md CHANGED
@@ -123,7 +123,7 @@ blog_api/
123
123
  explicit and run before models load. Tests always migrate a private in-memory
124
124
  database. The controller permits the chosen fields and returns `Post.new(...)`;
125
125
  MK saves once, then the handler returns `{post: ...}` with status 201. Index returns
126
- `{posts: [...]}`; show, update, and delete return `{post: ...}` with status 200.
126
+ `{posts: [...]}` with bounded `limit`/`offset` pagination; show, update, and delete return `{post: ...}` with status 200.
127
127
  PATCH and PUT preserve omitted fields; missing records return 404. The generated
128
128
  README includes a local server command, an example request, and extension guidance.
129
129
 
@@ -140,10 +140,10 @@ task in another project's Rakefile, add `require 'mk_framework/generator/tasks'`
140
140
 
141
141
  ## Try the examples
142
142
 
143
- The seven sample applications live in
143
+ Twenty paired sample applications (JSON APIs and MkFrame frontends) live in
144
144
  [mk_framework_sample_apps](https://github.com/makevoid/mk_framework_sample_apps)
145
- and install MK from RubyGems. Both GitHub repositories are private; installing the
146
- published gem does not require access to the framework repository.
145
+ and install MK from RubyGems. Each sample has its own README, database migrations
146
+ and isolated request specs. Backend use does not require a framework source checkout.
147
147
 
148
148
  From a sample-app checkout:
149
149
 
@@ -166,9 +166,22 @@ stub HTTP and require no personal API key or internet connection.
166
166
  | [2](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app2/README.md) | Todo validation and request specs |
167
167
  | [3](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app3/README.md) | Custom response envelopes |
168
168
  | [4](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app4/README.md) | Blog posts, nested comments, parent scoping |
169
- | [5](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app5/README.md) | Kanban cards, status validation, nested comments |
169
+ | [5](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app5/README.md) | Kanban cards, validation, nested comments |
170
170
  | [6](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app6/README.md) | Weather client, deadlines, atomic cache refresh |
171
- | [7](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app7/README.md) | Three-column Kanban board, ordering, priorities, filters, archive and comments |
171
+ | [7](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app7/README.md) | Kanban board, ordering, priorities, filters, archive and comments |
172
+ | [8](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app8/README.md) | Single-user ecommerce, product image uploads and a persistent cart |
173
+ | [9](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app9/README.md) | Fictional electric car inventory, charging specs and reservations |
174
+ | [10](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app10/README.md) | Buildlog developer journal, project portfolio and private publishing workbench |
175
+ | [11](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app11/README.md) | Rolecraft job board, employer submissions, moderation and private applications |
176
+ | [12](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app12/README.md) | Reboot Market electronics classifieds, photo uploads, private inquiries, saved searches and moderation |
177
+ | [13](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app13/README.md) | Pinpoint map directory, geographic filters, contributor history and transactional approval |
178
+ | [14](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app14/README.md) | Studio Hours timed resource bookings, Zurich availability, maintenance and transactional conflict prevention |
179
+ | [15](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app15/README.md) | Pantry Table recipes, private meal plans, pantry stock and derived shopping quantities |
180
+ | [16](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app16/README.md) | Assembly community events, workshop capacity, FIFO waitlists, organizer management and check-in |
181
+ | [17](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app17/README.md) | Fairshare private expense groups, exact splits, receipts, balances and reimbursements |
182
+ | [18](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app18/README.md) | Signalboard feedback, voting, moderation, duplicate merges, roadmap and releases |
183
+ | [19](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app19/README.md) | Night Archive NASA discovery, private collections and observing journal |
184
+ | [20](https://github.com/makevoid/mk_framework_sample_apps/blob/main/sample_app20/README.md) | Fieldwork private plant collection, care schedules and growth journals |
172
185
 
173
186
  ## Walkthrough: sample app 4, a blog API
174
187
 
@@ -186,16 +199,14 @@ parent scoping checks the relationship, while user access rules belong in your a
186
199
 
187
200
  ### Directory structure
188
201
 
189
- Each sample carries its own namespaced database and Rake helpers in `support/`,
190
- so you can copy `sample_app4/` alone to start a separate project. This tree lists
202
+ The samples share database and Rake helpers in the repository-level `support/`
203
+ directory. Keep that directory when copying a sample; use the CLI above for a
204
+ self-contained starter. This tree lists
191
205
  the six action files explained below; the sample also includes the remaining CRUD
192
206
  controllers and handlers for both resources.
193
207
 
194
208
  ```text
195
209
  sample_app4/
196
- ├── support/
197
- │ ├── database.rb # SampleApp4::Database: connections and migrations
198
- │ └── tasks.rb # SampleApp4::Tasks: db:migrate, routes, and specs
199
210
  ├── Gemfile
200
211
  ├── Rakefile
201
212
  ├── database.rb # SampleApp4::ROOT and SampleApp4::DB
@@ -235,7 +246,7 @@ and request-test dependencies:
235
246
  ```ruby
236
247
  source 'https://rubygems.org'
237
248
 
238
- gem 'mk_framework', '~> 0.2.0'
249
+ gem 'mk_framework', '~> 0.2.2'
239
250
  gem 'sequel', '>= 5.92', '< 6'
240
251
  gem 'sqlite3', '~> 2.9'
241
252
  gem 'rake', '~> 13.4'
@@ -257,8 +268,8 @@ bundle exec rake routes
257
268
  bundle exec rspec
258
269
  ```
259
270
 
260
- `Rakefile` loads `support/tasks.rb` and installs the local tasks with
261
- `SampleApp4::Tasks.install(__dir__)`.
271
+ `Rakefile` loads `../support/tasks.rb` and installs the shared tasks with
272
+ `SampleTasks.install(__dir__)`.
262
273
  `db:migrate` loads `database.rb` and applies `db/migrations/001_initial.rb` before
263
274
  any models are loaded. The migration creates `posts` and `comments`, including
264
275
  required timestamps and a non-null `comments.post_id` foreign key with cascading
@@ -269,15 +280,15 @@ deletion. Schema changes are an explicit step; starting the app never migrates i
269
280
  ```ruby
270
281
  # frozen_string_literal: true
271
282
 
272
- require_relative 'support/database'
283
+ require_relative '../support/database'
273
284
 
274
285
  module SampleApp4
275
286
  ROOT = __dir__.freeze
276
- DB = Database.connect(root: ROOT, filename: 'blog.db')
287
+ DB = SampleDatabase.connect(root: ROOT, filename: 'blog.db')
277
288
  end
278
289
  ```
279
290
 
280
- `SampleApp4::Database.connect` defaults to `sample_app4/blog.db`, using an absolute path.
291
+ `SampleDatabase.connect` defaults to `sample_app4/blog.db`, using an absolute path.
281
292
  It accepts `DATABASE_URL`, `DB_POOL_SIZE`, and `DB_POOL_TIMEOUT` for deployment.
282
293
  In tests it always opens a private in-memory SQLite database; `spec_helper.rb`
283
294
  migrates that database before requiring `app.rb`.
@@ -599,25 +610,196 @@ Known paths with unsupported methods return 405 and `Allow`; unknown paths retur
599
610
  Read [nested routes and authorization](docs/routing.md) for scopes, namespaces,
600
611
  shallow routes, custom actions, explicit class mappings, and parent ownership.
601
612
 
602
- ## Inputs, responses, and errors
603
-
604
- - `r.path_params` is a frozen symbol-keyed hash containing only URL captures.
605
- - `r.input` validates body/query input independently of path IDs. `require(:name)`
606
- requires a typed field; `permit(name: String)` allows only declared fields.
607
- - `:boolean` accepts booleans and the form strings `true`, `false`, `1`, `0`.
608
- Nullable fields must explicitly include `NilClass` in their accepted types.
609
- - `r.params` remains compatible with older controllers, with path IDs taking
610
- precedence. Never use client input to establish a parent relationship.
611
- - `r.page` validates `limit` (default 25, maximum 100) and `offset` (maximum 10,000).
612
- `paginate(dataset, r)` in `MK::Persistence` uses an ordered, bounded query.
613
- Implement cursor pagination in your application when large offsets are needed.
614
- - JSON request bodies must be objects. Malformed JSON and malformed query inputs
615
- produce 400. Bodies above 1 MiB produce 413, including bodies without a length.
616
- - `MK::BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`,
617
- `ValidationError`, and `BadGateway` represent intentional public errors.
618
- Their messages are public: never place secrets in them.
619
- - Unexpected errors return JSON with `error: "Server error"` and a request ID.
620
- The same ID appears in `X-Request-ID` and the structured error log.
613
+ ## Working with the request (`r`)
614
+
615
+ `r` is the current Roda request, extended with MK's input and pagination helpers.
616
+ Controllers use `route do |r|` to load records and prepare changes; handlers use
617
+ `handler do |r|` to format the resulting raw data and choose a response status.
618
+ The snippets below belong inside the application's namespace, after defining its
619
+ models. Define routes and configuration before calling `App.boot!`.
620
+
621
+ ### Declare routes
622
+
623
+ Inside your `MK::Application` subclass:
624
+
625
+ ```ruby
626
+ configure root: ROOT, namespace: Blog, legacy_post_routes: false
627
+
628
+ resource_routes do
629
+ resources :posts do
630
+ resources :comments
631
+ end
632
+ end
633
+ ```
634
+
635
+ `resources :posts` maps the CRUD URLs in the table above to
636
+ `PostsIndexController`/`PostsIndexHandler`, `PostsShowController`/`PostsShowHandler`,
637
+ and the corresponding create, update and delete pairs. Files live under
638
+ `routes/posts/controllers/` and `routes/posts/handlers/`. Nested comments still
639
+ use action files under `routes/comments/`; nesting changes the URL, not the files.
640
+
641
+ Restrict actions or add a named endpoint explicitly:
642
+
643
+ ```ruby
644
+ resource_routes do
645
+ resources :posts, only: %i[index show] do
646
+ member :publish, via: :post
647
+ end
648
+ end
649
+ ```
650
+
651
+ The member action maps `POST /posts/:id/publish` to
652
+ `PostsPublishController` and `PostsPublishHandler`. Supply both classes before
653
+ boot. Custom actions do not automatically save their returned models; persistence
654
+ must be explicit. Inspect the compiled mapping with `puts App.route_table`.
655
+
656
+ ### URL identifiers: `r.path_params`
657
+
658
+ For `PUT /posts/12/comments/34`, the nested declaration captures:
659
+
660
+ ```ruby
661
+ r.path_params # => { post_id: '12', id: '34' }
662
+ ```
663
+
664
+ This is a frozen hash with symbol keys and string values. IDs may be slugs or
665
+ UUIDs; MK does not coerce them to integers. Collection routes contain parent IDs
666
+ but no member `:id`. Body and query fields cannot overwrite these captures.
667
+
668
+ Load a nested record through its parent in the controller:
669
+
670
+ ```ruby
671
+ route do |r|
672
+ post = Post[r.path_params.fetch(:post_id)] or raise MK::NotFound
673
+ comment = post.comments_dataset.where(id: r.path_params.fetch(:id)).first
674
+ raise MK::NotFound unless comment
675
+
676
+ comment.set(r.input.permit(content: String))
677
+ end
678
+ ```
679
+
680
+ The URL describes the relationship; it does not load or authorize a parent.
681
+ In an authenticated app, first scope the parent query to the current user's
682
+ allowed records. Assign foreign keys from that parent, not from client input.
683
+
684
+ ### Body and query fields: `r.input`
685
+
686
+ Use `permit` to select fields and validate their types. It returns a symbol-keyed
687
+ hash, drops unknown fields, and omits fields the client did not send:
688
+
689
+ ```ruby
690
+ route do |r|
691
+ attributes = r.input.permit(
692
+ title: String,
693
+ description: [String, NilClass],
694
+ published: :boolean
695
+ )
696
+ Post.new(attributes)
697
+ end
698
+ ```
699
+
700
+ `permit` does not require a field to be present. Model validation can reject a
701
+ missing title with 422 when MK saves the returned model. A present value of the
702
+ wrong type raises `MK::BadRequest` (400). `NilClass` explicitly permits JSON null;
703
+ `String` alone does not. Length, uniqueness and other domain rules belong in the
704
+ model or controller. Nested objects are not recursively allowlisted for you.
705
+
706
+ For a required input, call `require` (its default type is `String`):
707
+
708
+ ```ruby
709
+ query = r.input.require(:query)
710
+ quantity = r.input.require(:quantity, type: Integer)
711
+ ```
712
+
713
+ A missing key or invalid type returns 400. An empty string still has type String;
714
+ check whether it is meaningful in your application. `Integer` accepts a JSON
715
+ integer, not the query string `"3"`. Use the integer helper for numeric query
716
+ parameters or form fields:
717
+
718
+ ```ruby
719
+ quantity = r.input.integer(:quantity, default: 1, min: 1, max: 20)
720
+ ```
721
+
722
+ This accepts integers and strings of decimal digits, applies the default only
723
+ when the field is absent, and returns 400 for invalid values or bounds. It does
724
+ not silently clamp them. `:boolean` accepts JSON `true`/`false` and the strings
725
+ `"true"`, `"false"`, `"1"`, `"0"`; false is retained in permitted attributes.
726
+ Dates arrive as strings: parse them explicitly, as the CLI's date fields do.
727
+
728
+ `r.input` reads body/query input separately from path captures. The compatibility
729
+ `r.params` hash combines input with string-keyed path captures, and path values
730
+ win on collision. Prefer `r.path_params` for identity and `r.input` for attributes.
731
+
732
+ ### Bounded pagination: `r.page`
733
+
734
+ For `GET /posts?limit=10&offset=20`, `r.page` returns
735
+ `{ limit: 10, offset: 20 }`. Apply it to an ordered query:
736
+
737
+ ```ruby
738
+ route do |r|
739
+ page = r.page
740
+ Post.order(:id).limit(page[:limit], page[:offset])
741
+ end
742
+ ```
743
+
744
+ The helper validates parameters; it does not query the database or add response
745
+ metadata. Defaults are `limit: 25` and `offset: 0`. Allowed limits are 1–100 and
746
+ allowed offsets are 0–10,000. Invalid inputs return 400. Override the bounds in
747
+ the application before boot:
748
+
749
+ ```ruby
750
+ configure page_size: 20, max_page_size: 50, max_offset: 5_000
751
+ ```
752
+
753
+ All three settings must be positive integers, and `page_size` cannot exceed
754
+ `max_page_size`. Controllers that include `MK::Persistence` can instead use
755
+ `paginate(Post.dataset, r)`, which orders by `id`, applies these bounds and returns
756
+ an array. For large datasets, implement cursor pagination in your application.
757
+
758
+ ### Response status, headers and early returns
759
+
760
+ Handlers receive prepared raw data through `model` and return hashes or arrays.
761
+ MK serializes the response; do not call `to_json` in a handler:
762
+
763
+ ```ruby
764
+ handler do |r|
765
+ r.response.status = 201
766
+ r.response['location'] = "/posts/#{model.fetch(:id)}"
767
+ { post: model.slice(:id, :title) }
768
+ end
769
+ ```
770
+
771
+ Use an MK error for an intentional failure such as `raise MK::NotFound` or
772
+ `raise MK::Forbidden`. Use `r.halt` when you need an explicit early response:
773
+
774
+ ```ruby
775
+ r.halt(409, { error: 'Post is already published' })
776
+ ```
777
+
778
+ Request-scoped application data can be stored in `r.env`, for example a verified
779
+ user set by `before_request`. Native Roda routes also work and run before resource
780
+ routes; return `nil` when they should fall through:
781
+
782
+ ```ruby
783
+ route do |r|
784
+ r.get('health') { { ok: true } }
785
+ nil
786
+ end
787
+ ```
788
+
789
+ See [the routing guide](docs/routing.md) for authentication hooks, scopes,
790
+ namespaces, shallow routes and custom action mappings.
791
+
792
+ ## Errors and request limits
793
+
794
+ JSON bodies must be objects. Malformed JSON and malformed query inputs produce
795
+ 400. Bodies above 1 MiB produce 413, including bodies without a content length;
796
+ `max_body_bytes` configures the limit.
797
+
798
+ `MK::BadRequest`, `MK::Unauthorized`, `MK::Forbidden`, `MK::NotFound`,
799
+ `MK::Conflict`, `MK::ValidationError` and `MK::BadGateway` represent intentional
800
+ public errors. Their messages are public, so never put secrets in them.
801
+ Unexpected errors return JSON with `error: "Server error"` and a request ID.
802
+ The same ID appears in `X-Request-ID` and the structured error log.
621
803
 
622
804
  Production is the safe default. Set `RACK_ENV=development` explicitly for debug
623
805
  details. Configure a logger using `configure logger: Logger.new($stdout)`, or
@@ -1,13 +1,13 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gem 'mk_framework', '~> <%= MK::VERSION %>'
4
+ gem 'puma', '~> 8.0'
5
+ gem 'rackup', '~> 2.3'
6
+ gem 'rake', '~> 13.4'
4
7
  gem 'sequel', '>= 5.92', '< 6'
5
8
  gem 'sqlite3', '~> 2.9'
6
- gem 'rake', '~> 13.4'
7
- gem 'rackup', '~> 2.3'
8
- gem 'puma', '~> 8.0'
9
9
 
10
10
  group :test do
11
- gem 'rspec', '~> 3.13'
12
11
  gem 'rack-test', '~> 2.2'
12
+ gem 'rspec', '~> 3.13'
13
13
  end
@@ -8,7 +8,8 @@ module <%= config.namespace %>
8
8
  class <%= config.action_prefix %><%= action.capitalize %>Controller < MK::Controller
9
9
  route do |r|
10
10
  <% if action == 'index' -%>
11
- <%= config.model_class %>.order(:id)
11
+ page = r.page
12
+ <%= config.model_class %>.order(:id).limit(page[:limit], page[:offset])
12
13
  <% elsif %w[show delete].include?(action) -%>
13
14
  <%= config.model_class %>[r.path_params.fetch(:id)] or raise MK::NotFound
14
15
  <% else -%>
@@ -12,6 +12,6 @@ module <%= config.namespace %>
12
12
  else
13
13
  url = ENV.fetch('DATABASE_URL') { "sqlite://#{File.join(ROOT, '<%= config.app_name %>.db')}" }
14
14
  Sequel.connect(url, max_connections: Integer(ENV.fetch('DB_POOL_SIZE', '5')),
15
- pool_timeout: Integer(ENV.fetch('DB_POOL_TIMEOUT', '5')))
15
+ pool_timeout: Integer(ENV.fetch('DB_POOL_TIMEOUT', '5')))
16
16
  end
17
17
  end
@@ -7,15 +7,15 @@ module <%= config.namespace %>
7
7
 
8
8
  def validate
9
9
  super
10
- validates_not_null %i[<%= config.field_names.join(' ') %>]
10
+ validates_not_null <%= config.field_names.map(&:to_sym).inspect %>
11
11
  <% text_fields = config.fields.select { |field| %w[string text].include?(field[:type]) }.map { |field| field[:name] } -%>
12
12
  <% unless text_fields.empty? -%>
13
- validates_presence %i[<%= text_fields.join(' ') %>]
13
+ validates_presence <%= text_fields.map(&:to_sym).inspect %>
14
14
  <% end -%>
15
15
  end
16
16
 
17
17
  def self.public_attributes_list
18
- %i[id <%= config.field_names.join(' ') %> created_at updated_at]
18
+ <%= ([:id] + config.field_names.map(&:to_sym) + [:created_at, :updated_at]).inspect %>
19
19
  end
20
20
  end
21
21
  end
@@ -1,23 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../spec_helper'
4
+ <% if config.namespace == config.model_class -%>
4
5
 
5
- RSpec.describe '<%= config.model_class %> CRUD' do
6
+ module <%= config.namespace %>
7
+ <% end -%>
8
+
9
+ describe '<%= config.model_class %> CRUD' do
6
10
  include Rack::Test::Methods
7
11
 
8
12
  let(:app) { Rack::Builder.parse_file(File.join(<%= config.namespace %>::ROOT, 'config.ru')) }
9
13
  let(:attributes) { JSON.parse(<%= config.example.to_json.inspect %>) }
10
14
 
11
15
  before do
12
- <%= config.namespace %>::<%= config.model_class %>.dataset.delete
16
+ <%= config.model_class %>.dataset.delete
13
17
  header 'CONTENT_TYPE', 'application/json'
14
18
  end
15
19
 
16
- it 'persists permitted fields and returns a 201 response through the Rack entrypoint' do
20
+ def create_record
21
+ post('/<%= config.resource %>', attributes.to_json)
22
+ expect(last_response.status).to eq(201)
23
+ JSON.parse(last_response.body).fetch('<%= config.model_name %>')
24
+ end
25
+
26
+ it 'creates a record with POST /<%= config.resource %>' do
17
27
  post('/<%= config.resource %>', attributes.merge('id' => 999).to_json)
18
28
 
19
29
  expect(last_response.status).to eq(201)
20
- record = <%= config.namespace %>::<%= config.model_class %>.first
30
+ record = <%= config.model_class %>.first
21
31
  expect(record).not_to be_nil
22
32
  expect(record.id).not_to eq(999)
23
33
  payload = JSON.parse(last_response.body).fetch('<%= config.model_name %>')
@@ -25,37 +35,18 @@ RSpec.describe '<%= config.model_class %> CRUD' do
25
35
  expect(payload.keys).to match_array(%w[id <%= config.field_names.join(' ') %> created_at updated_at])
26
36
  end
27
37
 
28
- it 'rejects missing required fields without inserting a record' do
29
- post('/<%= config.resource %>', {}.to_json)
30
-
31
- expect(last_response.status).to eq(422)
32
- expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(0)
33
- end
34
-
35
- it 'rejects an invalid field type without inserting a record' do
36
- attributes['<%= config.fields.first[:name] %>'] = []
37
- post('/<%= config.resource %>', attributes.to_json)
38
-
39
- expect(last_response.status).to eq(400)
40
- expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(0)
41
- end
42
-
43
- def create_record
44
- post('/<%= config.resource %>', attributes.to_json)
45
- expect(last_response.status).to eq(201)
46
- JSON.parse(last_response.body).fetch('<%= config.model_name %>')
47
- end
48
-
49
- it 'lists records in ID order, including an empty collection' do
38
+ it 'lists records with GET /<%= config.resource %>' do
50
39
  get('/<%= config.resource %>')
51
40
  expect(JSON.parse(last_response.body)).to eq('<%= config.resource %>' => [])
52
- records = [create_record, create_record]
41
+ records = [create_record, create_record, create_record]
53
42
  get('/<%= config.resource %>')
54
43
  expect(last_response.status).to eq(200)
55
44
  expect(JSON.parse(last_response.body).fetch('<%= config.resource %>')).to eq(records)
45
+ get('/<%= config.resource %>?limit=1&offset=1')
46
+ expect(JSON.parse(last_response.body).fetch('<%= config.resource %>')).to eq([records[1]])
56
47
  end
57
48
 
58
- it 'shows a single record' do
49
+ it 'shows a record with GET /<%= config.resource %>/:id' do
59
50
  record = create_record
60
51
  get("/<%= config.resource %>/#{record.fetch('id')}")
61
52
  expect(last_response.status).to eq(200)
@@ -63,11 +54,10 @@ RSpec.describe '<%= config.model_class %> CRUD' do
63
54
  end
64
55
 
65
56
  <% updated_value = { 'string' => 'Updated', 'text' => 'Updated text', 'integer' => 2, 'float' => 2.5, 'boolean' => true, 'date' => '2027-02-03', 'datetime' => '2027-02-03T14:30:00Z' }.fetch(config.fields.first[:type]) -%>
66
- it 'updates permitted fields with PUT, preserving omitted fields and the ID' do
57
+ it 'updates a record with PUT /<%= config.resource %>/:id' do
67
58
  record = create_record
68
59
  changes = { '<%= config.fields.first[:name] %>' => <%= updated_value.inspect %>, 'id' => 999 }
69
- put("/<%= config.resource %>/#{record.fetch('id')}",
70
- changes.to_json)
60
+ put("/<%= config.resource %>/#{record.fetch('id')}", changes.to_json)
71
61
  expect(last_response.status).to eq(200)
72
62
  payload = JSON.parse(last_response.body).fetch('<%= config.model_name %>')
73
63
  expect(payload.fetch('id')).to eq(record.fetch('id'))
@@ -79,36 +69,22 @@ RSpec.describe '<%= config.model_class %> CRUD' do
79
69
  expect(payload.except('<%= config.fields.first[:name] %>', 'updated_at')).to eq(record.except('<%= config.fields.first[:name] %>', 'updated_at'))
80
70
  get("/<%= config.resource %>/#{record.fetch('id')}")
81
71
  expect(JSON.parse(last_response.body).fetch('<%= config.model_name %>')).to eq(payload)
82
- expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(1)
83
- end
72
+ expect(<%= config.model_class %>.count).to eq(1)
84
73
 
85
- it 'rejects invalid updates without changing stored data' do
86
- record = create_record
87
- <%= %w[string text].include?(config.fields.first[:type]) ? "[[[], 400], [nil, 400], ['', 422]]" : '[[[], 400], [nil, 400]]' %>.each do |value, status|
88
- put("/<%= config.resource %>/#{record.fetch('id')}",
89
- { '<%= config.fields.first[:name] %>' => value }.to_json)
90
- expect(last_response.status).to eq(status)
91
- get("/<%= config.resource %>/#{record.fetch('id')}")
92
- expect(JSON.parse(last_response.body).fetch('<%= config.model_name %>')).to eq(record)
93
- end
74
+ put("/<%= config.resource %>/#{record.fetch('id')}", { '<%= config.fields.first[:name] %>' => [] }.to_json)
75
+ expect(last_response.status).to eq(400)
76
+ get("/<%= config.resource %>/#{record.fetch('id')}")
77
+ expect(JSON.parse(last_response.body).fetch('<%= config.model_name %>')).to eq(payload)
94
78
  end
95
79
 
96
- it 'deletes a record and returns its public attributes' do
80
+ it 'deletes a record with DELETE /<%= config.resource %>/:id' do
97
81
  record = create_record
98
82
  delete("/<%= config.resource %>/#{record.fetch('id')}")
99
83
  expect(last_response.status).to eq(200)
100
84
  expect(JSON.parse(last_response.body).fetch('<%= config.model_name %>')).to eq(record)
101
- expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(0)
102
- get("/<%= config.resource %>/#{record.fetch('id')}")
103
- expect(last_response.status).to eq(404)
104
- end
105
-
106
- %w[get put delete].each do |verb|
107
- it "returns 404 for #{verb.upcase} of a missing record" do
108
- public_send(verb, '/<%= config.resource %>/999999',
109
- attributes.to_json)
110
- expect(last_response.status).to eq(404)
111
- expect(<%= config.namespace %>::<%= config.model_class %>.count).to eq(0)
112
- end
85
+ expect(<%= config.model_class %>.count).to eq(0)
113
86
  end
114
87
  end
88
+ <% if config.namespace == config.model_class -%>
89
+ end
90
+ <% end -%>
@@ -9,6 +9,8 @@ require_relative '../database'
9
9
  Sequel::Migrator.run(<%= config.namespace %>::DB, File.join(<%= config.namespace %>::ROOT, 'db/migrations'))
10
10
  require_relative '../app'
11
11
 
12
+ include <%= config.namespace %>
13
+
12
14
  RSpec.configure do |config|
13
15
  config.after(:suite) { <%= config.namespace %>::DB.disconnect }
14
16
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MK
4
- VERSION = '0.2.3'
4
+ VERSION = '0.2.5'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mk_framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Francesco Canessa