From 67f6c74ffc33d195b3fb4d4f7dd372b9417fb5b1 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 10 Dec 2021 16:00:50 +1100 Subject: [PATCH 1/3] Webhook model updates Reimplemented action cable so that data is requested through webhooks and then updated with any changes. --- Gemfile.lock | 7 +- active_sync.gemspec | 1 + app/channels/models_channel.rb | 41 ++------- .../active_sync/application_controller.rb | 2 +- .../active_sync/models_controller.rb | 16 +++- app/jobs/broadcast_change_job.rb | 19 +--- app/models/active_sync/model.rb | 25 +---- app/models/active_sync/sync.rb | 19 +++- config/routes.rb | 4 +- lib/active_sync/version.rb | 2 +- lib/javascript/active-sync.js | 42 +++++---- lib/javascript/model.js | 91 +++++++++---------- lib/javascript/package.json | 5 +- .../app/controllers/application_controller.rb | 2 +- test/dummy/app/javascript/customer.vue | 13 ++- test/dummy/public/packs/manifest.json | 8 +- yarn.lock | 6 +- 17 files changed, 141 insertions(+), 162 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3fb7dea..7ad49a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active-sync (0.1.1) + active-sync (0.2.0) rails (>= 5.1.3) GEM @@ -89,6 +89,8 @@ GEM nokogiri (1.11.3) mini_portile2 (~> 2.5.0) racc (~> 1.4) + puma (5.5.2) + nio4r (~> 2.0) racc (1.5.2) rack (2.2.3) rack-proxy (0.6.5) @@ -139,7 +141,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - websocket-driver (0.7.3) + websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) zeitwerk (2.4.2) @@ -150,6 +152,7 @@ PLATFORMS DEPENDENCIES active-sync! foreman + puma sqlite3 (~> 1.4) webpacker (>= 3.5.5) diff --git a/active_sync.gemspec b/active_sync.gemspec index b265af4..8550911 100644 --- a/active_sync.gemspec +++ b/active_sync.gemspec @@ -21,4 +21,5 @@ Gem::Specification.new do |s| s.add_development_dependency "webpacker", ">= 3.5.5" s.add_development_dependency "sqlite3", "~> 1.4" s.add_development_dependency "foreman" + s.add_development_dependency "puma" end diff --git a/app/channels/models_channel.rb b/app/channels/models_channel.rb index fcb797a..48b5831 100644 --- a/app/channels/models_channel.rb +++ b/app/channels/models_channel.rb @@ -1,12 +1,15 @@ # Rails currently doesn't allow namespacing channels in an engine # module ActiveSync class ModelsChannel < ActionCable::Channel::Base - # For providing DashData with data from rails models - # To change the data sent (like reducing how much is sent) - # implement broadcast_model in the respective modelc + # To change the data sent implement sync_record in the respective model def subscribed - subscribe_models + transmit(subscription_model.where(params[:filter]).map(&:sync_record)) + stream_from params[:model], coder: ActiveSupport::JSON do |message| + if (params[:filter].to_a - message.to_a).blank? + transmit([message]) + end + end end def unsubscribed @@ -14,35 +17,11 @@ def unsubscribed end private - def subscribe_models - if filter.nil? - - stream_from "#{subscription_model.name}_All" - transmit( subscription_model.sync_all ) - - else - - subscription_model.register_sync_subscription "#{subscription_model.name}_#{checksum}", filter - stream_from "#{subscription_model.name}_#{checksum}" - - # TODO ensure that params are safe to pass to the model then register for syncing to. - transmit( subscription_model.sync_filtered( filter.to_h ) ) - - end - end def subscription_model - model = params[:model].camelize.constantize - model.sync_model? ? model : raise "Model '#{params[:model]}' is not a registered sync model" - end - - def model_association - ActiveSync::Sync.get_model_association( subscription_model, filter[:association_name] ) - end - - def checksum - # A checksum is generated and used in the stream name so all of the same filtered subscriptions should be on the same Stream - Digest::MD5.hexdigest( Marshal::dump( filter ) ) + model = params[:model].singularize.camelize.constantize + raise "Model '#{params[:model]}' is not set up for syncing model" unless model.sync_model? + model end end # end diff --git a/app/controllers/active_sync/application_controller.rb b/app/controllers/active_sync/application_controller.rb index eec3652..84dafbf 100644 --- a/app/controllers/active_sync/application_controller.rb +++ b/app/controllers/active_sync/application_controller.rb @@ -1,5 +1,5 @@ module ActiveSync class ApplicationController < ActionController::Base - protect_from_forgery with: :exception + # protect_from_forgery with: :exception end end diff --git a/app/controllers/active_sync/models_controller.rb b/app/controllers/active_sync/models_controller.rb index 0d46c67..b999449 100644 --- a/app/controllers/active_sync/models_controller.rb +++ b/app/controllers/active_sync/models_controller.rb @@ -1,16 +1,22 @@ module ActiveSync class ModelsController < ApplicationController - def index - render json: model.sync_filtered(properties) + def update + #TODO some oversite on what can be edited for sync records + model.find(params[:id]).update(params.permit(model.sync_attributes)) + head :no_content end - def properties - params.permit(ActiveSync::Sync.model_descriptions[model.name][:attributes]) + def create + #TODO some oversite on what can be created for sync records + render json: model.create(params.permit(model.sync_attributes)).id end + private def model - params[:model].singularize.camelize.safe_constantize || params[:model].camelize.safe_constantize + m = params[:model].singularize.camelize.safe_constantize || params[:model].camelize.safe_constantize + raise "Cannot edit #{params[:model]} as it is not a sync model" unless m.sync_model? + m end end end diff --git a/app/jobs/broadcast_change_job.rb b/app/jobs/broadcast_change_job.rb index d2464dd..f6d6d76 100644 --- a/app/jobs/broadcast_change_job.rb +++ b/app/jobs/broadcast_change_job.rb @@ -1,20 +1,9 @@ class BroadcastChangeJob < ApplicationJob - queue_as :default + queue_as :active_sync - def perform record - SyncSubscriptions.all.each do | subscription | - unless filter[:IsReference] - - match = true - filter.each do | key, value | - unless self.send( key ) == value - match = false - break - end - end + include ActionCable::Channel::Broadcasting - ActionCable.server.broadcast( stream, ActiveSync::Sync.sync_record( self ) ) if match - end - end + def perform record + ActionCable.server.broadcast(record.class.name, record.sync_record) end end diff --git a/app/models/active_sync/model.rb b/app/models/active_sync/model.rb index 86f844e..f486716 100644 --- a/app/models/active_sync/model.rb +++ b/app/models/active_sync/model.rb @@ -24,15 +24,6 @@ def sync_model? true end - def register_sync_subscription stream, filter - @@sync_record_subscriptions[ self.name ] = {} if @@sync_record_subscriptions[ self.name ].nil? - @@sync_record_subscriptions[ self.name ][ stream ] = filter - end - - def sync_record_subscriptions - @@sync_record_subscriptions[ self.name ] || {} - end - # #sync sets the #sync_record method that renders the hash to create the JSON object that is broadcast and sets # #sync_associations which returns a list of associations that are permitted to be broadcast for this model. # define these methods directly in your model if the record sent to the font end needs to be different to what's @@ -57,6 +48,9 @@ def sync_record_subscriptions # :associations - an array of symbols def sync *attributes + self.class.define_method(:sync_attributes) do + ActiveSync::Sync.sync_attributes(self, attributes) + end define_method(:sync_record) do ActiveSync::Sync.sync_record(self, attributes) end @@ -64,19 +58,6 @@ def sync *attributes ActiveSync::Sync.sync_associations(self, attributes) end end - - # Sync hash for all of self records - def sync_all - self.all.map do |record| - ActiveSync::Sync.sync_record record - end - end - - def sync_filtered filter - self.where( filter ).map do |record| - ActiveSync::Sync.sync_record record - end - end end end end diff --git a/app/models/active_sync/sync.rb b/app/models/active_sync/sync.rb index a3a7518..6d067b5 100644 --- a/app/models/active_sync/sync.rb +++ b/app/models/active_sync/sync.rb @@ -1,17 +1,30 @@ module ActiveSync class Sync + + def self.sync_attributes model, args + @@sync_attributes ||= args.reduce([]) do |array, option| + case option + when :all_attributes_and_associations, :all_attributes + array + model.column_names.map(&:to_sym) + when ->(option){ option.is_a?(Hash) } + array + option[:attributes] + else + raise "Unknown sync record option #{option.inspect}" + end + end + end + #Hash used in all general sync communication for a given model. def self.sync_record record, args args.reduce({}) do |hash, option| case option when :all_attributes_and_associations, :all_attributes - hash.merge(model.attributes) + hash.merge(record.attributes) when ->(option){ option.is_a?(Hash) } - option[:attributes]&.each { |attribute| hash[attribute] = record.call(attribute) } + option[:attributes]&.reduce(hash) { |h, attr| h[attr] = record.call(attr) } else raise "Unknown sync record option #{option.inspect}" end - hash end end diff --git a/config/routes.rb b/config/routes.rb index a98f682..69bcbc7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ ActiveSync::Engine.routes.draw do - get '/:model', to: 'models#index' - get '/:model/:id', to: 'models#show' + put '/:model/:id', to: 'models#update' + post '/:model', to: 'models#create' end diff --git a/lib/active_sync/version.rb b/lib/active_sync/version.rb index 0a6ef89..5a9b833 100644 --- a/lib/active_sync/version.rb +++ b/lib/active_sync/version.rb @@ -1,3 +1,3 @@ module ActiveSync - VERSION = '0.1.1' + VERSION = '0.2.0' end diff --git a/lib/javascript/active-sync.js b/lib/javascript/active-sync.js index 100d56d..0f42b8d 100644 --- a/lib/javascript/active-sync.js +++ b/lib/javascript/active-sync.js @@ -1,6 +1,7 @@ import Model from './model.js' import CamelCase from 'camelcase' import Pluralize from 'pluralize' +import SnakeCase from "snake-case"; export default class ActiveSync { @@ -19,8 +20,6 @@ export default class ActiveSync { // Will add a 'sites' method to the Customer class and a 'customer' method to Site. // - - constructor( args ){ this._models = args.models || []; this.buildModels(args.modelDescriptions) @@ -34,7 +33,7 @@ export default class ActiveSync { return this._models } - // Creates the models from the modelDescription arg passed int to the constructor + // Creates the models from the modelDescription arg passed in to the constructor buildModels(modelDescriptions){ let modelNames = Object.keys(modelDescriptions || {}); @@ -45,21 +44,8 @@ export default class ActiveSync { }) this._models.forEach((model) => { - ((modelDescriptions[model.className] || {}).belongsTo || []).forEach((association) => { - let associatedModel = this._models.find((model) => model.className === CamelCase(association, {pascalCase: true})) - model[association] = function () { - return associatedModel.find(this[association + 'Id']) - } - }); - - ((modelDescriptions[model.className] || {}).hasMany || []).forEach((association) => { - let associatedModel = this._models.find((model) => model.className === CamelCase(Pluralize.singular(association), {pascalCase: true})) - model.prototype[association] = function () { - let associationQuery = {} - associationQuery[CamelCase(model.className) + 'Id'] = this.id - return associatedModel.where(associationQuery) - } - }); + this.addBelongsToModel(modelDescriptions[model.className], model) + this.addHasManyToModel(modelDescriptions[model.className], model) }) } @@ -67,6 +53,26 @@ export default class ActiveSync { let modelClass = Model return eval(`(class ${name} extends modelClass { static className = '${name}' })`) } + + addBelongsToModel(modelDescription, model){ + ((modelDescription || {}).belongsTo || []).forEach((association) => { + let associatedModel = this._models.find((model) => model.className === association) + model[association] = function () { + return associatedModel.find(this[association + '_id']) + } + }); + } + + addHasManyToModel(modelDescription, model){ + ((modelDescription || {}).hasMany || []).forEach((association) => { + let associatedModel = this._models.find((model) => model.className === CamelCase(Pluralize.singular(association), {pascalCase: true})) + model.prototype[association] = function () { + let associationQuery = {} + associationQuery[SnakeCase(model.className) + '_id'] = this.id + return associatedModel.where(associationQuery) + } + }); + } } diff --git a/lib/javascript/model.js b/lib/javascript/model.js index 8a3b52c..114f7f7 100644 --- a/lib/javascript/model.js +++ b/lib/javascript/model.js @@ -3,14 +3,18 @@ import SnakeCase from 'snake-case' import CamelCase from 'camelcase' import Pluralize from 'pluralize' +import Actioncable from "actioncable" + import Util from './util' export default class Model { // static records = {} static recordsLoaded = false - static url_path_base = 'active_sync/' + static urlPathBase = 'active_sync/' + static consumer = Actioncable.createConsumer() + constructor(args){ if(!args.id) throw 'Can not create record without an id' if(this.constructor.records[args.id]){ @@ -23,7 +27,7 @@ export default class Model { setProperties(args){ Object.keys(args).forEach((property)=>{ - this[CamelCase(property)] = args[property] + this[property] = args[property] }) } @@ -33,29 +37,21 @@ export default class Model { } static get all(){ - return new Promise((resolve,reject)=>{ - if(this.recordsLoaded){ - resolve(Object.keys(this.records).map((id) => this.records[id])) - } else { - return this.loadRecords().then(()=>{ - resolve(Object.keys(this.records).map((id) => this.records[id])) - }) - } - }) + return this.loadOrSearch() } - static model_url_path(singular = false){ + static modelUrlPath(singular = false){ if(singular) { - return this.url_path_base + SnakeCase(this.className) + return this.urlPathBase + SnakeCase(this.className) } else { - return this.url_path_base + SnakeCase(Pluralize(this.className)) + return this.urlPathBase + SnakeCase(Pluralize(this.className)) } } static find(id){ return new Promise((resolve,reject)=>{ if(!this.records[id]){ - resolve(this.loadRecords(id).then(()=> this.records[id])) + resolve(this.loadRecords({ id: id }).then(()=> this.records[id])) } else { resolve(this.records[id]) } @@ -63,13 +59,7 @@ export default class Model { } static where(args){ - return new Promise((resolve,reject)=>{ - if(this.recordsLoaded){ - resolve(this.searchRecords(args)) - } else { - this.loadRecords(args).then(() => resolve(this.searchRecords(args))) - } - }) + return this.loadOrSearch(Util.snakeCaseKeys(args)) } static through(model, args){ @@ -80,45 +70,44 @@ export default class Model { } static create(data){ - return Axios.post(this.model_url_path(), Util.snakeCaseKeys(data)) - .then((response) => { - new this(response.data) - return response - }) + return Axios.post(this.modelUrlPath(), Util.snakeCaseKeys(data)) + .then((response) => { + new this(response.data) + return response + }) } static update(data){ - return Axios.put( `${this.model_url_path(true)}/${data.id}`, Util.snakeCaseKeys(data)) + return Axios.put( `${this.modelUrlPath(true)}/${data.id}`, Util.snakeCaseKeys(data)) .then((response) => { - new this(response.data) + // new this(response.data) return response }) } //Intended as private below here - static loadRecords(args = {}){ - console.log(args) - //No args is interpretted as load all. - if(typeof args === 'number') { - return Axios.get(this.model_url_path(true) + '/' + args) - .then((response) => { - new this(response.data) - }) - .catch((error) => { - console.log(error) - }) + static loadOrSearch(args={}){ + let subscriptionParams = { channel: 'ModelsChannel', model: this.className, filter: args } + if(this.consumer.subscriptions.findAll(JSON.stringify(subscriptionParams)).length === 0){ + return this.loadRecords(subscriptionParams) } else { - return Axios.get( this.model_url_path(), { params: Util.snakeCaseKeys(args) } ) - .then((response) => { - response.data.forEach((record)=> { - new this(record) - }) - }) - .catch((error) => { - console.log(error) - }) + return new Promise((resolve, reject) => { resolve(this.searchRecords(args)) } ) } } + + static loadRecords(args){ + return new Promise((resolve, reject) => { + this.consumer.subscriptions.create(args, { + received: (data) => { + let records = [] + data.forEach((datum) => { + records.push(new this(datum)) + }) + resolve(records) + } + }) + }) + } static searchRecords(args){ var results = [] @@ -130,7 +119,9 @@ export default class Model { (Array.isArray(args[arg]) && args[arg].some((a)=> typeof a == 'object' && a.id == this.records[id][arg] || a == this.records[id][arg])) ) }) - if(match) results.push(this.records[id]) + if(match) { + results.push(this.records[id]) + } }) return results } diff --git a/lib/javascript/package.json b/lib/javascript/package.json index 436f76c..fdb8eb6 100644 --- a/lib/javascript/package.json +++ b/lib/javascript/package.json @@ -1,6 +1,6 @@ { "name": "rails-active-sync", - "version": "0.1.3", + "version": "0.2.0", "description": "Javascript for interacting with active-sync ruby gem.", "main": "active-sync.js", "scripts": { @@ -20,7 +20,8 @@ "dependencies": { "actioncable": "^5.2.2-1", "camelcase": "^5.2.0", - "snake-case": "^2.1.0" + "snake-case": "^2.1.0", + "axios": "^0.21.1" }, "author": "Crammaman", "license": "MIT" diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb index 1c07694..4355099 100644 --- a/test/dummy/app/controllers/application_controller.rb +++ b/test/dummy/app/controllers/application_controller.rb @@ -1,3 +1,3 @@ class ApplicationController < ActionController::Base - protect_from_forgery with: :exception + # protect_from_forgery with: :exception end diff --git a/test/dummy/app/javascript/customer.vue b/test/dummy/app/javascript/customer.vue index 811cfa5..c6c7a22 100644 --- a/test/dummy/app/javascript/customer.vue +++ b/test/dummy/app/javascript/customer.vue @@ -1,6 +1,7 @@