model_driven_api 3.6.4 → 3.7.1

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: b4757c81ca4ae3c4e59c12a96f5ddf23801121fc8ac3a7cde16ca3a8f2d47208
4
- data.tar.gz: 2c342468dd9879aec91f2dd6bc49c5dba639dab0a8841583d6032377ef937e78
3
+ metadata.gz: ad2abbdb4e4fd783d5e4fadde50593be43e265aa857a0077655513b4e73f522e
4
+ data.tar.gz: 323ffab731877ae6c4f8adb6b75bb358e12319a7b2d55ccc77aadd5e6e52d41d
5
5
  SHA512:
6
- metadata.gz: 0b36be5d13960e0166eaa31fc698745a1ba9d3d69ed4b49ca91540af7d112e7ce0bf39ff0cc87cb2e1c973b21add87a1e9ef9e18eaa3fc7adf81b82c268f745f
7
- data.tar.gz: fe831b9b04fe80dfbd3db57e41d1b999035ab676f5d602904fa8e759af95504b9ff6d4d6ea91d947eef4dbe85a54afa62a1f9c30c17aa092191cbec960dc3ff0
6
+ metadata.gz: 17322346aef3972a55db2fce8703abfe9b2148076509c3a14aa465c0d34e4d5ec41294366496a77fc7dc44c5dd324fe9513f77961338591ae035ac5d03e8575d
7
+ data.tar.gz: f11a3a215784201aa30fb20b45b29a1df75ef8d1e8f045d2d15c6b8f45425487d40a9ba6315db679a827dabd2e54846ef78ee9950f5533861befb6342d59b150
data/README.md CHANGED
@@ -528,6 +528,317 @@ fetch(`/api/v2/products/${id}`, { method: 'PATCH', body: formData });
528
528
 
529
529
  ---
530
530
 
531
+ ---
532
+
533
+ ## Web Push (VAPID) from a React client
534
+
535
+ This section is the complete integration guide for React apps that want to receive browser push notifications from a Thecore backend. The server-side setup (models, service, ActionCable channel) is documented in the [`thecore_backend_commons` README](../thecore_backend_commons/README.md#web-push-notifications-vapid).
536
+
537
+ ### Prerequisites
538
+
539
+ 1. Run `rails db:seed` on the backend — this generates the VAPID key pair automatically.
540
+ 2. Set `vapid.contact_email` in RailsAdmin → Settings (e.g. `admin@yourapp.com`).
541
+ 3. Your site must be served over **HTTPS** (required by the Push API in all browsers). `localhost` is exempt for development.
542
+
543
+ ### Endpoint reference
544
+
545
+ All endpoints live under `/api/v2/push_subscribers/custom_action/`.
546
+
547
+ | Method | Path | Auth | Description |
548
+ |--------|------|------|-------------|
549
+ | `GET` | `vapid_public_key` | **No** | Returns the VAPID public key for `PushManager.subscribe` |
550
+ | `POST` | `subscribe` | Yes (JWT) | Registers or updates a push subscription for the current user |
551
+ | `POST` | `send_push` | Yes (JWT) | Creates a `PushMessage` and dispatches it to an active subscriber |
552
+ | `POST` | `acknowledge` | Yes (JWT) | Marks a message as `received` and/or `read` |
553
+
554
+ ### Step 1 — Register a service worker
555
+
556
+ Create `public/sw.js` in your React app:
557
+
558
+ ```javascript
559
+ // public/sw.js
560
+
561
+ self.addEventListener('push', event => {
562
+ const data = event.data?.json() ?? {};
563
+ event.waitUntil(
564
+ self.registration.showNotification(data.title ?? 'Notification', {
565
+ body: data.body,
566
+ icon: data.icon ?? '/favicon.ico',
567
+ data: { url: data.url },
568
+ })
569
+ );
570
+ });
571
+
572
+ self.addEventListener('notificationclick', event => {
573
+ event.notification.close();
574
+ const url = event.notification.data?.url;
575
+ if (url) {
576
+ event.waitUntil(clients.openWindow(url));
577
+ }
578
+ });
579
+ ```
580
+
581
+ ### Step 2 — Subscribe to push notifications
582
+
583
+ ```javascript
584
+ // src/usePushSubscription.js
585
+ const API_BASE = process.env.REACT_APP_API_URL ?? '/api/v2';
586
+
587
+ // Convert a base64url string to a Uint8Array (required by PushManager)
588
+ function urlBase64ToUint8Array(base64String) {
589
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
590
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
591
+ const raw = atob(base64);
592
+ return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
593
+ }
594
+
595
+ // Convert a PushSubscription key to a base64url string (required by the backend)
596
+ function keyToBase64(subscription, key) {
597
+ return btoa(String.fromCharCode(...new Uint8Array(subscription.getKey(key))));
598
+ }
599
+
600
+ export async function subscribeToPush(jwtToken) {
601
+ // 1. Register service worker
602
+ const registration = await navigator.serviceWorker.register('/sw.js');
603
+
604
+ // 2. Fetch the VAPID public key (no auth needed)
605
+ const res = await fetch(`${API_BASE}/push_subscribers/custom_action/vapid_public_key`);
606
+ const { vapid_public_key } = await res.json();
607
+
608
+ // 3. Subscribe via the Push API
609
+ const subscription = await registration.pushManager.subscribe({
610
+ userVisibleOnly: true,
611
+ applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
612
+ });
613
+
614
+ // 4. Register the subscription with the backend
615
+ const registerRes = await fetch(
616
+ `${API_BASE}/push_subscribers/custom_action/subscribe`,
617
+ {
618
+ method: 'POST',
619
+ headers: {
620
+ 'Content-Type': 'application/json',
621
+ Authorization: `Bearer ${jwtToken}`,
622
+ },
623
+ body: JSON.stringify({
624
+ endpoint: subscription.endpoint,
625
+ p256dh: keyToBase64(subscription, 'p256dh'),
626
+ auth: keyToBase64(subscription, 'auth'),
627
+ user_agent: navigator.userAgent,
628
+ }),
629
+ }
630
+ );
631
+
632
+ const subscriber = await registerRes.json();
633
+ // subscriber.id is the push_subscriber_id to use with ActionCable and send_push
634
+ return subscriber;
635
+ }
636
+ ```
637
+
638
+ Call this after the user logs in (a JWT is required for `subscribe`):
639
+
640
+ ```javascript
641
+ import { subscribeToPush } from './usePushSubscription';
642
+
643
+ const subscriber = await subscribeToPush(jwtToken);
644
+ localStorage.setItem('push_subscriber_id', subscriber.id);
645
+ ```
646
+
647
+ ### Step 3 — Listen via ActionCable
648
+
649
+ Install the ActionCable client if you haven't already:
650
+
651
+ ```bash
652
+ npm install @rails/actioncable
653
+ # or
654
+ yarn add @rails/actioncable
655
+ ```
656
+
657
+ ```javascript
658
+ // src/usePushChannel.js
659
+ import { createConsumer } from '@rails/actioncable';
660
+
661
+ const WS_URL = process.env.REACT_APP_WS_URL ?? 'ws://localhost:3000/cable';
662
+
663
+ export function connectPushChannel(jwtToken, subscriberId, onMessage) {
664
+ // The JWT token is passed as a query parameter so the ActionCable
665
+ // connection.rb can authenticate the WebSocket handshake.
666
+ const consumer = createConsumer(`${WS_URL}?token=${jwtToken}`);
667
+
668
+ const subscription = consumer.subscriptions.create(
669
+ { channel: 'PushNotificationChannel', subscriber_id: subscriberId },
670
+ {
671
+ received(data) {
672
+ // data is a PushMessage serialised as JSON:
673
+ // { id, title, body, url, icon, sent_at, received_at, read_at }
674
+ onMessage(data);
675
+ },
676
+ connected() {
677
+ console.log('[PushNotificationChannel] connected');
678
+ },
679
+ disconnected() {
680
+ console.log('[PushNotificationChannel] disconnected');
681
+ },
682
+ }
683
+ );
684
+
685
+ // Return a cleanup function
686
+ return () => {
687
+ subscription.unsubscribe();
688
+ consumer.disconnect();
689
+ };
690
+ }
691
+ ```
692
+
693
+ Use it in a React component or hook:
694
+
695
+ ```javascript
696
+ import { useEffect } from 'react';
697
+ import { connectPushChannel } from './usePushChannel';
698
+
699
+ function App() {
700
+ const jwtToken = localStorage.getItem('token');
701
+ const subscriberId = localStorage.getItem('push_subscriber_id');
702
+
703
+ useEffect(() => {
704
+ if (!jwtToken || !subscriberId) return;
705
+
706
+ const disconnect = connectPushChannel(jwtToken, subscriberId, message => {
707
+ console.log('New push message via ActionCable:', message);
708
+ // Optionally acknowledge receipt immediately
709
+ acknowledgeMessage(jwtToken, message.id, { received: true });
710
+ });
711
+
712
+ return disconnect; // cleanup on unmount
713
+ }, [jwtToken, subscriberId]);
714
+ }
715
+ ```
716
+
717
+ > **Tip:** use `user_id` instead of `subscriber_id` if you want to receive messages across all active subscriptions for the current user (e.g. multiple tabs):
718
+ > ```javascript
719
+ > { channel: 'PushNotificationChannel', user_id: currentUserId }
720
+ > ```
721
+
722
+ ### Step 4 — Acknowledge receipt and read
723
+
724
+ ```javascript
725
+ // src/pushApi.js
726
+ const API_BASE = process.env.REACT_APP_API_URL ?? '/api/v2';
727
+
728
+ export async function acknowledgeMessage(jwtToken, messageId, { received = false, read = false } = {}) {
729
+ const res = await fetch(
730
+ `${API_BASE}/push_subscribers/custom_action/acknowledge`,
731
+ {
732
+ method: 'POST',
733
+ headers: {
734
+ 'Content-Type': 'application/json',
735
+ Authorization: `Bearer ${jwtToken}`,
736
+ },
737
+ body: JSON.stringify({
738
+ push_message_id: messageId,
739
+ received,
740
+ read,
741
+ }),
742
+ }
743
+ );
744
+ return res.json();
745
+ // Response: { id, title, body, url, icon, sent_at, received_at, read_at, ... }
746
+ }
747
+ ```
748
+
749
+ Call `received: true` as soon as the message arrives via ActionCable. Call `read: true` when the user opens or dismisses it. Fields are set only once — a second call with the same flag is a no-op (idempotent).
750
+
751
+ ### Step 5 — Send a push from the backend (optional)
752
+
753
+ Normally the backend triggers pushes from jobs or model callbacks. If you need to trigger a push from a privileged frontend (e.g. an admin panel), use `send_push`:
754
+
755
+ ```javascript
756
+ export async function sendPush(jwtToken, { subscriberId, title, body, url, icon }) {
757
+ const res = await fetch(
758
+ `${API_BASE}/push_subscribers/custom_action/send_push`,
759
+ {
760
+ method: 'POST',
761
+ headers: {
762
+ 'Content-Type': 'application/json',
763
+ Authorization: `Bearer ${jwtToken}`,
764
+ },
765
+ body: JSON.stringify({
766
+ push_subscriber_id: subscriberId,
767
+ title,
768
+ body,
769
+ url, // optional
770
+ icon, // optional
771
+ }),
772
+ }
773
+ );
774
+ if (!res.ok) {
775
+ const err = await res.json();
776
+ throw new Error(err.error ?? `HTTP ${res.status}`);
777
+ }
778
+ return res.json(); // PushMessage record
779
+ }
780
+ ```
781
+
782
+ ### Full flow summary
783
+
784
+ ```
785
+ React app Thecore backend
786
+ │ │
787
+ │── GET vapid_public_key ──────────►│ (no auth)
788
+ │◄── { vapid_public_key: "..." } ───│
789
+ │ │
790
+ │ navigator.serviceWorker.register('/sw.js')
791
+ │ registration.pushManager.subscribe({ applicationServerKey })
792
+ │ │
793
+ │── POST subscribe ────────────────►│ creates/updates PushSubscriber
794
+ │◄── { id: 42, endpoint, ... } ─────│
795
+ │ │
796
+ │ createConsumer(wsUrl?token=jwt) │
797
+ │── WS upgrade ────────────────────►│ PushNotificationChannel#subscribed
798
+ │◄── stream: push_notifications_subscriber_42
799
+ │ │
800
+ │ [backend dispatches push] │
801
+ │◄── Web Push payload (sw.js) ──────│ Webpush.payload_send via VAPID
802
+ │ showNotification(title, body) │
803
+ │ │
804
+ │◄── ActionCable data ──────────────│ PushNotificationChannel.broadcast_to
805
+ │ onMessage(data) │
806
+ │ │
807
+ │── POST acknowledge (received) ───►│ message.received_at = now
808
+ │── POST acknowledge (read) ────────►│ message.read_at = now
809
+ ```
810
+
811
+ ### Handling subscription expiry
812
+
813
+ Push service endpoints expire (the push provider returns HTTP 410). The backend automatically calls `subscriber.expire!` when this happens, but the client needs to re-subscribe on the next boot:
814
+
815
+ ```javascript
816
+ export async function ensureSubscription(jwtToken) {
817
+ // Re-subscribe unconditionally — subscribe_for upserts on endpoint,
818
+ // so re-registering the same browser is always safe and clears expired_at.
819
+ const sub = await subscribeToPush(jwtToken);
820
+ localStorage.setItem('push_subscriber_id', sub.id);
821
+ return sub;
822
+ }
823
+ ```
824
+
825
+ ### Permissions check
826
+
827
+ Before calling `subscribeToPush`, check that the browser supports push and the user has granted permission:
828
+
829
+ ```javascript
830
+ export async function requestPushPermission() {
831
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
832
+ console.warn('Web Push not supported in this browser');
833
+ return false;
834
+ }
835
+ const permission = await Notification.requestPermission();
836
+ return permission === 'granted';
837
+ }
838
+ ```
839
+
840
+ ---
841
+
531
842
  ## License
532
843
 
533
844
  MIT
@@ -13,7 +13,7 @@ class Api::V2::ApplicationController < ActionController::API
13
13
 
14
14
  # GET :controller/
15
15
  def index
16
- authorize! :index, @model
16
+ authorize! :index, @model unless public_custom_action?
17
17
 
18
18
  # Custom Action
19
19
  status, result, status_number = check_for_custom_action
@@ -67,7 +67,7 @@ class Api::V2::ApplicationController < ActionController::API
67
67
  def create
68
68
  # Normal Create Action
69
69
  Rails.logger.debug("Creating a new record #{@record}")
70
- authorize! :create, @record.presence || @model
70
+ authorize! :create, @record.presence || @model unless public_custom_action?
71
71
  # Custom Action
72
72
  status, result, status_number = check_for_custom_action
73
73
  return render json: result, status: (status_number.presence || 200) if status == true
@@ -127,6 +127,19 @@ class Api::V2::ApplicationController < ActionController::API
127
127
 
128
128
  private
129
129
 
130
+ # Returns true if the current request is for a NonCrudEndpoints custom action
131
+ # that has been declared as public (no authentication required).
132
+ # Forces autoloading of the Endpoints::<Model> class so the public_action_registry
133
+ # is populated before authenticate_request checks it.
134
+ def public_custom_action?
135
+ return false unless request.url.include?("/custom_action/")
136
+ model_name = params[:ctrl].to_s.classify
137
+ action_name = params[:action_name].to_s
138
+ # Ensure the endpoint class is loaded so its public_action declarations are registered.
139
+ ("Endpoints::#{model_name}".constantize rescue nil)
140
+ NonCrudEndpoints.public_action?(model_name, action_name)
141
+ end
142
+
130
143
  ## CUSTOM ACTION
131
144
  # [GET|PUT|POST|DELETE] :controller?do=:custom_action
132
145
  # or
@@ -159,6 +172,9 @@ class Api::V2::ApplicationController < ActionController::API
159
172
  end
160
173
 
161
174
  def authenticate_request
175
+ # Skip auth for public NonCrudEndpoints actions (e.g. vapid_public_key).
176
+ return if public_custom_action?
177
+
162
178
  @current_user = nil
163
179
  Settings.ns(:security).allowed_authorization_headers.split(",").each do |header|
164
180
  # puts "Found header #{header}: #{request.headers[header]}"
@@ -2,7 +2,7 @@ class Api::V3::ApplicationController < Api::V2::ApplicationController
2
2
  include Pagy::Backend
3
3
 
4
4
  def index
5
- authorize! :index, @model
5
+ authorize! :index, @model unless public_custom_action?
6
6
 
7
7
  status, result, status_number = check_for_custom_action
8
8
  return render json: result, status: (status_number.presence || 200) if status == true
@@ -0,0 +1,110 @@
1
+ class Endpoints::PushSubscriber < NonCrudEndpoints
2
+ # vapid_public_key is public — no authentication required.
3
+ public_action :vapid_public_key
4
+
5
+ self.desc 'PushSubscriber', :vapid_public_key, {
6
+ get: {
7
+ summary: 'Returns the VAPID public key for browser push subscription',
8
+ parameters: [],
9
+ responses: {
10
+ '200' => {
11
+ description: 'VAPID public key',
12
+ content: {
13
+ 'application/json' => {
14
+ schema: {
15
+ type: 'object',
16
+ properties: {
17
+ vapid_public_key: { type: 'string' }
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ def vapid_public_key(params)
28
+ key = ThecoreSettings::Setting.where(ns: :vapid, key: :public_key).pluck(:raw).first
29
+ [{ vapid_public_key: key }, 200]
30
+ end
31
+
32
+ self.desc 'PushSubscriber', :subscribe, {
33
+ post: {
34
+ summary: 'Register or update a push subscription for the current user',
35
+ parameters: [],
36
+ responses: {
37
+ '200' => { description: 'Updated subscriber' },
38
+ '201' => { description: 'Created subscriber' }
39
+ }
40
+ }
41
+ }
42
+
43
+ def subscribe(params)
44
+ user = User.find(params[:current_user_id])
45
+ subscriber = PushSubscriber.subscribe_for(
46
+ user,
47
+ endpoint: params[:endpoint],
48
+ p256dh: params[:p256dh],
49
+ auth: params[:auth],
50
+ user_agent: params[:user_agent]
51
+ )
52
+ status = subscriber.previously_new_record? ? 201 : 200
53
+ [subscriber, status]
54
+ rescue ActiveRecord::RecordInvalid => e
55
+ [{ error: e.message }, 422]
56
+ end
57
+
58
+ self.desc 'PushSubscriber', :send_push, {
59
+ post: {
60
+ summary: 'Send a Web Push notification to a specific subscriber',
61
+ parameters: [],
62
+ responses: {
63
+ '201' => { description: 'Message created and dispatched' },
64
+ '422' => { description: 'Validation error' },
65
+ '404' => { description: 'Subscriber not found' }
66
+ }
67
+ }
68
+ }
69
+
70
+ def send_push(params)
71
+ subscriber = PushSubscriber.active.find_by(id: params[:push_subscriber_id])
72
+ return [{ error: 'Subscriber not found' }, 404] unless subscriber
73
+ message = subscriber.push_messages.build(
74
+ title: params[:title],
75
+ body: params[:body],
76
+ url: params[:url],
77
+ icon: params[:icon]
78
+ )
79
+ unless message.save
80
+ return [{ error: message.errors.full_messages.join(', ') }, 422]
81
+ end
82
+ ThecoreBackendCommons::PushNotificationService.dispatch(subscriber, message)
83
+ [message, 201]
84
+ rescue => e
85
+ [{ error: e.message }, 500]
86
+ end
87
+
88
+ self.desc 'PushSubscriber', :acknowledge, {
89
+ post: {
90
+ summary: 'Mark a push message as received or read',
91
+ parameters: [],
92
+ responses: {
93
+ '200' => { description: 'Message updated' },
94
+ '404' => { description: 'Message not found' }
95
+ }
96
+ }
97
+ }
98
+
99
+ def acknowledge(params)
100
+ message = PushMessage.find_by(id: params[:push_message_id])
101
+ return [{ error: 'Message not found' }, 404] unless message
102
+
103
+ now = Time.current
104
+ message.update!(received_at: now) if params[:received] && message.received_at.nil?
105
+ message.update!(read_at: now) if params[:read] && message.read_at.nil?
106
+ [message, 200]
107
+ rescue ActiveRecord::RecordInvalid => e
108
+ [{ error: e.message }, 422]
109
+ end
110
+ end
@@ -44,7 +44,10 @@ module ApiExceptionManagement
44
44
 
45
45
  def api_error(status: 501, errors: [])
46
46
  # puts errors.full_messages if !Rails.env.production? && errors.respond_to?(:full_messages)
47
- head status && return if errors.blank?
47
+ if errors.blank?
48
+ head status
49
+ return
50
+ end
48
51
 
49
52
  # For retrocompatibility, I try to send back only strings, as errors
50
53
  errors_response = if errors.respond_to?(:full_messages)
@@ -1,3 +1,3 @@
1
1
  module ModelDrivenApi
2
- VERSION = "3.6.4".freeze
2
+ VERSION = "3.7.1".freeze
3
3
  end
@@ -2,6 +2,24 @@ class NonCrudEndpoints
2
2
  attr_accessor :result
3
3
  cattr_accessor :definitions
4
4
  self.definitions = {}
5
+
6
+ # Registry of actions that do not require authentication.
7
+ # Populated by subclasses via `self.public_action :action_name`.
8
+ # Used by CustomActionDispatcher and ApplicationController to skip
9
+ # authenticate_request for these endpoints.
10
+ cattr_accessor :public_action_registry
11
+ self.public_action_registry = {}
12
+
13
+ def self.public_action(action_name)
14
+ self.public_action_registry[name] ||= []
15
+ self.public_action_registry[name] << action_name.to_sym
16
+ end
17
+
18
+ def self.public_action?(model_name, action_name)
19
+ registry = public_action_registry["Endpoints::#{model_name}"] || []
20
+ registry.include?(action_name.to_sym)
21
+ end
22
+
5
23
  # Add a validation method which will be inherited by all the instances, and automatically run before any method call
6
24
  def initialize(m, params)
7
25
  # Rails.logger.debug "Initializing NonCrudEndpoints"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: model_driven_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.6.4
4
+ version: 3.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriele Tassoni
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '2.4'
32
+ version: '3.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '2.4'
39
+ version: '3.0'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: simple_command
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -188,6 +188,7 @@ files:
188
188
  - app/controllers/api/v3/info_controller.rb
189
189
  - app/controllers/api/v3/raw_controller.rb
190
190
  - app/controllers/api/v3/users_controller.rb
191
+ - app/models/endpoints/push_subscriber.rb
191
192
  - app/models/endpoints/test_api.rb
192
193
  - app/models/test_api.rb
193
194
  - app/models/used_token.rb