jason-rails 0.5.0 → 0.6.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -1
  3. data/Gemfile.lock +1 -1
  4. data/README.md +141 -5
  5. data/app/controllers/jason/api/pusher_controller.rb +15 -0
  6. data/app/controllers/jason/api_controller.rb +46 -4
  7. data/client/lib/JasonContext.d.ts +1 -1
  8. data/client/lib/JasonContext.js +4 -1
  9. data/client/lib/JasonProvider.js +1 -1
  10. data/client/lib/createJasonReducers.js +7 -0
  11. data/client/lib/createPayloadHandler.d.ts +6 -3
  12. data/client/lib/createPayloadHandler.js +8 -4
  13. data/client/lib/createTransportAdapter.d.ts +5 -0
  14. data/client/lib/createTransportAdapter.js +20 -0
  15. data/client/lib/index.d.ts +2 -0
  16. data/client/lib/index.js +3 -1
  17. data/client/lib/makeEager.js +2 -2
  18. data/client/lib/pruneIdsMiddleware.js +9 -11
  19. data/client/lib/restClient.d.ts +1 -1
  20. data/client/lib/transportAdapters/actionCableAdapter.d.ts +5 -0
  21. data/client/lib/transportAdapters/actionCableAdapter.js +35 -0
  22. data/client/lib/transportAdapters/pusherAdapter.d.ts +5 -0
  23. data/client/lib/transportAdapters/pusherAdapter.js +68 -0
  24. data/client/lib/useEager.d.ts +1 -0
  25. data/client/lib/useEager.js +12 -0
  26. data/client/lib/useJason.js +30 -35
  27. data/client/lib/useJason.test.js +8 -2
  28. data/client/lib/useSub.d.ts +1 -1
  29. data/client/lib/useSub.js +5 -3
  30. data/client/package.json +2 -1
  31. data/client/src/JasonContext.ts +4 -1
  32. data/client/src/JasonProvider.tsx +1 -1
  33. data/client/src/createJasonReducers.ts +7 -0
  34. data/client/src/createPayloadHandler.ts +9 -4
  35. data/client/src/createTransportAdapter.ts +13 -0
  36. data/client/src/index.ts +3 -1
  37. data/client/src/makeEager.ts +2 -2
  38. data/client/src/pruneIdsMiddleware.ts +11 -11
  39. data/client/src/restClient.ts +2 -1
  40. data/client/src/transportAdapters/actionCableAdapter.ts +38 -0
  41. data/client/src/transportAdapters/pusherAdapter.ts +72 -0
  42. data/client/src/useEager.ts +9 -0
  43. data/client/src/useJason.test.ts +8 -2
  44. data/client/src/useJason.ts +31 -36
  45. data/client/src/useSub.ts +5 -3
  46. data/client/yarn.lock +12 -0
  47. data/config/routes.rb +5 -1
  48. data/lib/jason.rb +56 -8
  49. data/lib/jason/broadcaster.rb +19 -0
  50. data/lib/jason/channel.rb +10 -3
  51. data/lib/jason/graph_helper.rb +165 -0
  52. data/lib/jason/includes_helper.rb +108 -0
  53. data/lib/jason/lua_generator.rb +23 -1
  54. data/lib/jason/publisher.rb +21 -17
  55. data/lib/jason/subscription.rb +208 -179
  56. data/lib/jason/version.rb +1 -1
  57. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e6d1d960c2bb555ccaa555a17de7730ce90c35aa57594256bcb73bc96dade09
4
- data.tar.gz: b096c733bd15195ac383097a8138507915caa5fba29d9099325755972a64fe2a
3
+ metadata.gz: e62faa4e4f1246fa42810c324eec1f8fe96b8026ccdbe12f5b8bd7bc2df3bd4a
4
+ data.tar.gz: e4f829ce397c7192c173ba6e712a4b5873bd065d687cebf5ad72025f0436f51a
5
5
  SHA512:
6
- metadata.gz: 4ce914a0e658fe97051ff6a64a48a29bccde202ad3fa60bc3aadc287f4a50a4f169d647b66f456e3147da8551b5b321185caa05c9790c964202d5275b20a8732
7
- data.tar.gz: ebde6f3323cb516915e3ddea2f747595a864df2cac9f52e05c67b1320c46f20693c479d7b39b11f391638dc9e5027b92e86ad65089282c6c190b6ba4982b3de3
6
+ metadata.gz: 6e3abfbfd6e0fbbe6c40dc7c0540448091cd440aaeec3580951a6148d6f1f92e61d5808b39ca02e8c564e1aa57beaf1de4d4cddb34e27b96ace09ca61d856d9a
7
+ data.tar.gz: b58a34f35981aea9b991f036cb83f9e059fa17a5932d9e9df78b6e86b5532484fe0dc115531365d5044d2f7df42355b5481a353233dcbd6744077b8fcbd58f18
data/.gitignore CHANGED
@@ -11,4 +11,7 @@
11
11
  .rspec_status
12
12
  client/node_modules/
13
13
 
14
- client/yarn-error.log
14
+ client/yarn-error.log
15
+ *.log
16
+ *.gem
17
+ .DS_Store
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jason-rails (0.4.1)
4
+ jason-rails (0.6.0)
5
5
  connection_pool (>= 2.2.3)
6
6
  jsondiff
7
7
  rails (>= 5)
data/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Jason
2
2
 
3
+ Jason is still in an experimental phase with a rapidly changing API. It is being used in some production applications, however it is still in 0.x.x series versions, which means that any 0.x version bump could introduce breaking changes.
4
+
3
5
  ## The goal
4
6
 
5
7
  I wanted:
6
8
  - Automatic updates to client state based on database state
7
- - Automatic persistence to database
9
+ - Persistence to the database without many layers of passing parameters
8
10
  - Redux for awesome state management
9
11
  - Optimistic updates
10
12
 
@@ -12,6 +14,8 @@ I also wanted to avoid writing essentially the same code multiple times in diffe
12
14
 
13
15
  Jason attempts to minimize this repitition by auto-generating API endpoints, redux stores and actions from a single schema definition. Further it adds listeners to ActiveRecord models allowing the redux store to be subscribed to updates from a model or set of models.
14
16
 
17
+ An alternative way of thinking about Jason is "what if we applied the Flux/Redux state update pattern to make the _database_ the store?".
18
+
15
19
  ## Installation
16
20
 
17
21
  Add the gem and the NPM package
@@ -24,22 +28,154 @@ gem 'jason-rails'
24
28
  yarn add @jamesr2323/jason
25
29
  ```
26
30
 
31
+ You will also need have peer dependencies of `redux`, `react-redux` and `@reduxjs/toolkit`.
32
+
33
+ ### In Rails
34
+
35
+ Include the module `Jason::Publisher` in all models you want to publish via Jason.
36
+
37
+ Create a new initializer e.g. `jason.rb` which defines your schema
38
+
39
+ ```ruby
40
+ Jason.setup do |config|
41
+ config.schema = {
42
+ post: {
43
+ subscribed_fields: [:id, :name]
44
+ },
45
+ comment: {
46
+ subscribed_fields: [:id]
47
+ },
48
+ user: {
49
+ subscribed_fields: [:id]
50
+ }
51
+ }
52
+ end
53
+ ```
54
+
55
+ ### In your frontend code
56
+
57
+ First you need to wrap your root component in a `JasonProvider`.
58
+
59
+ ```jsx
60
+ import { JasonProvider } from '@jamesr2323/jason'
61
+
62
+ return <JasonProvider>
63
+ <YourApp />
64
+ </JasonProvider>
65
+ ```
66
+
67
+ This is a wrapper around `react-redux` Provider component. This accepts the following props (all optional):
68
+
69
+ - `reducers` - An object of reducers that will be included in `configureStore`. Make sure these do not conflict with the names of any of the models you are configuring for use with Jason
70
+ - `extraActions` - Extra actions you want to be available via the `useAct` hook. (See below)
71
+ This must be a function which returns an object which will be merged with the main Jason actions. The function will be passed a dispatch function, store, axios instance and the Jason actions. For example you can add actions for one of your custom slices:
72
+
73
+ ```js
74
+ function extraActions(dispatch, store, restClient, act) {
75
+ return {
76
+ local: {
77
+ upsert: payload => dis({ type: 'local/upsert', payload })
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ - `middleware` - Passed directly to `configureStore` with additional Jason middleware
84
+
27
85
  ## Usage
86
+ Jason provides three custom hooks to access functionality.
87
+
88
+ ### useAct
89
+ This returns an object which allows you to access actions which both update models on the server, and perform an optimistic update to the Redux store.
90
+
91
+ Example
92
+ ```jsx
93
+ import React, { useState } from 'react'
94
+ import { useAct } from '@jamesr2323/jason'
28
95
 
29
- ### Define your schema
96
+ export default function PostCreator() {
97
+ const act = useAct()
98
+ const [name, setName] = useState('')
30
99
 
100
+ function handleClick() {
101
+ act.posts.add({ name })
102
+ }
103
+
104
+ return <div>
105
+ <input value={name} onChange={e => setName(e.target.value)} />
106
+ <button onClick={handleClick}>Add</button>
107
+ </div>
108
+ }
109
+ ```
110
+
111
+ ### useSub
112
+ This subscribes your Redux store to a model or set of models. It will automatically unsubscribe when the component unmounts.
113
+
114
+ Example
115
+ ```jsx
116
+ import React from 'react'
117
+ import { useSelector } from 'react-redux'
118
+ import { useSub } from '@jamesr2323/jason'
119
+ import _ from 'lodash'
120
+
121
+ export default function PostsList() {
122
+ useSub({ model: 'post', includes: ['comments'] })
123
+ const posts = useSelector(s => _.values(s.posts.entities))
124
+
125
+ return <div>
126
+ { posts.map(({ id, name }) => <div key={id}>{ name }</div>) }
127
+ </div>
128
+ }
129
+ ```
130
+
131
+ ### useEager
132
+ Jason stores all the data in a normalized form - one redux slice per model. Often you might want to get nested data from several slices for use in components. The `useEager` hook provides an API for doing that. Under the hood it's just a wrapper around useSelector, which aims to mimic the behaviour of Rails eager loading.
133
+
134
+ Example
135
+ This will fetch the comment as well as the post and user linked to it.
136
+
137
+ ```jsx
138
+ import React from 'react'
139
+ import { useSelector } from 'react-redux'
140
+ import { useEager } from '@jamesr2323/jason'
141
+ import _ from 'lodash'
142
+
143
+ export default function Comment({ id }) {
144
+ const comment = useEager('comments', id, ['post', 'user'])
145
+
146
+ return <div>
147
+ <p>{ comment.body }</p>
148
+ <p>Made on post { comment.post.name } by { comment.user.name }</p>
149
+ </div>
150
+ }
151
+ ```
152
+
153
+ ## Authorization
154
+
155
+ By default all models can be subscribed to and updated without authentication or authorization. Probably you want to lock down access.
156
+
157
+ ### Authorizing subscriptions
158
+ You can do this by providing an class to Jason in the initializer under the `subscription_authorization_service` key. This must be a class receiving a message `call` with the parameters `user`, `model`, `conditions`, `sub_models` and return true or false for whether the user is allowed to access a subscription with those parameters. You can decide the implementation details of this to be as simple or complex as your app requires.
159
+
160
+ ### Authorizing updates
161
+ Similarly to authorizing subscriptions, you can do this by providing an class to Jason in the initializer under the `update_authorization_service` key. This must be a class receiving a message `call` with the parameters `user`, `model`, `instance`, `update`, `remove` and return true or false for whether the user is allowed to access a subscription with those parameters.
162
+
163
+ ## Roadmap
164
+
165
+ Development is primarily driven by the needs of projects we're using Jason in. In no particular order, being considered is:
166
+ - Failure handling - rolling back local state in case of an error on the server
167
+ - Authorization - more thorough authorization integration, with utility functions for common authorizations. Allowing authorization of access to particular fields such as restricting the fields of a user that are publicly broadcast.
168
+ - Utilities for "Draft editing" - both storing client-side copies of model trees which can be committed or discarded, as well as persisting a shadow copy to the database (to allow resumable editing, or possibly collaborative editing features)
169
+ - Benchmark and migrate if necessary ConnectionPool::Wrapper vs ConnectionPool
31
170
 
32
171
  ## Development
33
172
 
34
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
35
173
 
36
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
37
174
 
38
175
  ## Contributing
39
176
 
40
177
  Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jason. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/jason/blob/master/CODE_OF_CONDUCT.md).
41
178
 
42
-
43
179
  ## License
44
180
 
45
181
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,15 @@
1
+ class Jason::Api::PusherController < ApplicationController
2
+ skip_before_action :verify_authenticity_token
3
+
4
+ def auth
5
+ channel_main_name = params[:channel_name].remove("private-#{Jason.pusher_channel_prefix}-")
6
+ subscription_id = channel_main_name.remove('jason-')
7
+
8
+ if Jason::Subscription.find_by_id(subscription_id).user_can_access?(current_user)
9
+ response = Pusher.authenticate(params[:channel_name], params[:socket_id])
10
+ return render json: response
11
+ else
12
+ return head :forbidden
13
+ end
14
+ end
15
+ end
@@ -1,6 +1,21 @@
1
1
  class Jason::ApiController < ::ApplicationController
2
- def schema
3
- render json: Jason.schema
2
+ before_action :load_and_authorize_subscription, only: [:create_subscription, :remove_subscription, :get_payload]
3
+ # config seems to be a reserved name, resulting in infinite loop
4
+ def configuration
5
+ payload = {
6
+ schema: Jason.schema,
7
+ transportService: Jason.transport_service,
8
+ }
9
+
10
+ if Jason.transport_service == :pusher
11
+ payload.merge!({
12
+ pusherKey: Jason.pusher_key,
13
+ pusherRegion: Jason.pusher_region,
14
+ pusherChannelPrefix: Jason.pusher_channel_prefix
15
+ })
16
+ end
17
+
18
+ render json: payload
4
19
  end
5
20
 
6
21
  def action
@@ -20,10 +35,10 @@ class Jason::ApiController < ::ApplicationController
20
35
  all_instance_ids.insert(priority.to_i, instance.id)
21
36
 
22
37
  all_instance_ids.each_with_index do |id, i|
23
- model.find(id).update!(priority: i, skip_publish_json: true)
38
+ model.find(id).update!(priority: i)
24
39
  end
25
40
 
26
- model.publish_all(model.find(all_instance_ids))
41
+ model.find(all_instance_ids).each(&:force_publish_json)
27
42
  elsif action == 'upsert' || action == 'add'
28
43
  payload = api_model.permit(params)
29
44
  return render json: model.find_or_create_by_id!(payload).as_json(api_model.as_json_config)
@@ -33,4 +48,31 @@ class Jason::ApiController < ::ApplicationController
33
48
 
34
49
  return head :ok
35
50
  end
51
+
52
+ def create_subscription
53
+ @subscription.add_consumer(params[:consumer_id])
54
+ render json: { channelName: @subscription.channel }
55
+ end
56
+
57
+ def remove_subscription
58
+ @subscription.remove_consumer(params[:consumer_id])
59
+ end
60
+
61
+ def get_payload
62
+ if params[:options].try(:[], :force_refresh)
63
+ @subscription.set_ids_for_sub_models
64
+ end
65
+
66
+ render json: @subscription.get
67
+ end
68
+
69
+ private
70
+
71
+ def load_and_authorize_subscription
72
+ config = params[:config].to_unsafe_h
73
+ @subscription = Jason::Subscription.upsert_by_config(config['model'], conditions: config['conditions'], includes: config['includes'])
74
+ if !@subscription.user_can_access?(current_user)
75
+ return head :forbidden
76
+ end
77
+ end
36
78
  end
@@ -2,6 +2,6 @@
2
2
  declare const context: import("react").Context<{
3
3
  actions: any;
4
4
  subscribe: null;
5
- eager: null;
5
+ eager: (entity: any, id: any, relations: any) => void;
6
6
  }>;
7
7
  export default context;
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const react_1 = require("react");
4
- const context = react_1.createContext({ actions: {}, subscribe: null, eager: null });
4
+ const eager = function (entity, id, relations) {
5
+ console.error("Eager called but is not implemented");
6
+ };
7
+ const context = react_1.createContext({ actions: {}, subscribe: null, eager });
5
8
  exports.default = context;
@@ -8,7 +8,7 @@ const useJason_1 = __importDefault(require("./useJason"));
8
8
  const react_redux_1 = require("react-redux");
9
9
  const JasonContext_1 = __importDefault(require("./JasonContext"));
10
10
  const JasonProvider = ({ reducers, middleware, extraActions, children }) => {
11
- const [store, value, connected] = useJason_1.default({ reducers, middleware, extraActions });
11
+ const [store, value] = useJason_1.default({ reducers, middleware, extraActions });
12
12
  if (!(store && value))
13
13
  return react_1.default.createElement("div", null); // Wait for async fetch of schema to complete
14
14
  return react_1.default.createElement(react_redux_1.Provider, { store: store },
@@ -42,6 +42,7 @@ function generateJasonSlices(models) {
42
42
  setSubscriptionIds(s, a) {
43
43
  const { payload } = a;
44
44
  const { subscriptionId, model, ids } = payload;
45
+ console.log({ initialState });
45
46
  s[model][subscriptionId] = ids;
46
47
  },
47
48
  addSubscriptionId(s, a) {
@@ -53,6 +54,12 @@ function generateJasonSlices(models) {
53
54
  const { payload } = a;
54
55
  const { subscriptionId, model, id } = payload;
55
56
  s[model][subscriptionId] = lodash_1.default.remove(s[model][subscriptionId] || [], id);
57
+ },
58
+ removeSubscription(s, a) {
59
+ const { payload: { subscriptionId } } = a;
60
+ lodash_1.default.map(models, model => {
61
+ delete s[model][subscriptionId];
62
+ });
56
63
  }
57
64
  }
58
65
  }).reducer;
@@ -1,6 +1,9 @@
1
- export default function createPayloadHandler({ dispatch, serverActionQueue, subscription, config }: {
1
+ export default function createPayloadHandler({ dispatch, serverActionQueue, transportAdapter, config }: {
2
2
  dispatch: any;
3
3
  serverActionQueue: any;
4
- subscription: any;
4
+ transportAdapter: any;
5
5
  config: any;
6
- }): (data: any) => void;
6
+ }): {
7
+ handlePayload: (data: any) => void;
8
+ tearDown: () => void;
9
+ };
@@ -11,7 +11,7 @@ function diffSeconds(dt2, dt1) {
11
11
  var diff = (dt2.getTime() - dt1.getTime()) / 1000;
12
12
  return Math.abs(Math.round(diff));
13
13
  }
14
- function createPayloadHandler({ dispatch, serverActionQueue, subscription, config }) {
14
+ function createPayloadHandler({ dispatch, serverActionQueue, transportAdapter, config }) {
15
15
  const subscriptionId = uuid_1.v4();
16
16
  let idx = {};
17
17
  let patchQueue = {};
@@ -19,7 +19,7 @@ function createPayloadHandler({ dispatch, serverActionQueue, subscription, confi
19
19
  let updateDeadline = null;
20
20
  let checkInterval;
21
21
  function getPayload() {
22
- setTimeout(() => subscription.send({ getPayload: config }), 1000);
22
+ setTimeout(() => transportAdapter.getPayload(config), 1000);
23
23
  }
24
24
  function camelizeKeys(item) {
25
25
  return deepCamelizeKeys_1.default(item, key => uuid_1.validate(key));
@@ -41,7 +41,7 @@ function createPayloadHandler({ dispatch, serverActionQueue, subscription, confi
41
41
  dispatch({ type: `jasonModels/setSubscriptionIds`, payload: { model, subscriptionId, ids } });
42
42
  }
43
43
  else if (destroy) {
44
- dispatch({ type: `${pluralize_1.default(model)}/remove`, payload: id });
44
+ // Middleware will determine if this model should be removed if it isn't in any other subscriptions
45
45
  dispatch({ type: `jasonModels/removeSubscriptionId`, payload: { model, subscriptionId, id } });
46
46
  }
47
47
  else {
@@ -87,6 +87,10 @@ function createPayloadHandler({ dispatch, serverActionQueue, subscription, confi
87
87
  }
88
88
  }
89
89
  tGetPayload();
90
- return handlePayload;
90
+ // Clean up after ourselves
91
+ function tearDown() {
92
+ dispatch({ type: `jasonModels/removeSubscription`, payload: { subscriptionId } });
93
+ }
94
+ return { handlePayload, tearDown };
91
95
  }
92
96
  exports.default = createPayloadHandler;
@@ -0,0 +1,5 @@
1
+ export default function createTransportAdapter(jasonConfig: any, handlePayload: any, dispatch: any, onConnect: any): {
2
+ getPayload: (config: any, options: any) => void;
3
+ createSubscription: (config: any) => void;
4
+ removeSubscription: (config: any) => void;
5
+ };
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const actionCableAdapter_1 = __importDefault(require("./transportAdapters/actionCableAdapter"));
7
+ const pusherAdapter_1 = __importDefault(require("./transportAdapters/pusherAdapter"));
8
+ function createTransportAdapter(jasonConfig, handlePayload, dispatch, onConnect) {
9
+ const { transportService } = jasonConfig;
10
+ if (transportService === 'action_cable') {
11
+ return actionCableAdapter_1.default(jasonConfig, handlePayload, dispatch, onConnect);
12
+ }
13
+ else if (transportService === 'pusher') {
14
+ return pusherAdapter_1.default(jasonConfig, handlePayload, dispatch);
15
+ }
16
+ else {
17
+ throw (`Transport adapter does not exist for ${transportService}`);
18
+ }
19
+ }
20
+ exports.default = createTransportAdapter;
@@ -1,6 +1,7 @@
1
1
  /// <reference types="react" />
2
2
  import _useAct from './useAct';
3
3
  import _useSub from './useSub';
4
+ import _useEager from './useEager';
4
5
  export declare const JasonProvider: ({ reducers, middleware, extraActions, children }: {
5
6
  reducers?: any;
6
7
  middleware?: any;
@@ -9,3 +10,4 @@ export declare const JasonProvider: ({ reducers, middleware, extraActions, child
9
10
  }) => JSX.Element;
10
11
  export declare const useAct: typeof _useAct;
11
12
  export declare const useSub: typeof _useSub;
13
+ export declare const useEager: typeof _useEager;