First draft of farmware package name fetcher
parent
2b418db5cf
commit
8f22688890
|
@ -13,9 +13,15 @@ module Api
|
|||
render json: ""
|
||||
end
|
||||
|
||||
# Perform HTTP post to this endpoint when you
|
||||
# need to force an update.
|
||||
def refresh
|
||||
farmware_installation.force_package_refresh!
|
||||
render json: farmware_installation
|
||||
end
|
||||
|
||||
def create
|
||||
mutate FarmwareInstallations::Create.run(raw_json,
|
||||
device: current_device)
|
||||
mutate FarmwareInstallations::Create.run(raw_json, device: current_device)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -5,4 +5,53 @@ class FarmwareInstallation < ApplicationRecord
|
|||
validates :url, url: true
|
||||
validates_uniqueness_of :url, { scope: :device }
|
||||
validates_presence_of :device
|
||||
# Prevent malice when fetching farmware.json
|
||||
MAX_JSON_SIZE = 5000
|
||||
OTHER_PROBLEM = "Unknown error: %s"
|
||||
# Keep a dictionary of known errors if fetching
|
||||
# the `package` attr raises a runtime error.
|
||||
KNOWN_PROBLEMS = {
|
||||
KeyError =>
|
||||
"`farmware.json` must have a `package` field that is a string.",
|
||||
OpenURI::HTTPError =>
|
||||
"The server is online, but the URL could not be opened.",
|
||||
SocketError =>
|
||||
"The server appears to be offline.",
|
||||
JSON::ParserError =>
|
||||
"Expected `farmware.json` to be valid JSON, "\
|
||||
"but it is not. Consider using a JSON validator.",
|
||||
ActiveRecord::ValueTooLong =>
|
||||
"The name of the package is too long."
|
||||
}
|
||||
|
||||
# Downloads the `farmware.json` file in a background
|
||||
# worker, updating the `package` column if possible.
|
||||
def force_package_refresh!
|
||||
self.delay.infer_package_name_from_url
|
||||
end
|
||||
|
||||
# A lot of things can go wrong when fetching
|
||||
# a package name in a background worker.
|
||||
def maybe_recover_from_fetch_error(error)
|
||||
known_error = KNOWN_PROBLEMS[error.class]
|
||||
description = \
|
||||
known_error || (OTHER_PROBLEM % error.class)
|
||||
update_attributes!(package_error: description,
|
||||
package: nil)
|
||||
unless known_error.present?
|
||||
raise error
|
||||
end
|
||||
end
|
||||
|
||||
# SLOW I/O BOUND STUFF! Don't run this on the
|
||||
# main thread!
|
||||
def infer_package_name_from_url
|
||||
string_io = open(url)
|
||||
string = string_io.read(MAX_JSON_SIZE)
|
||||
json = JSON.parse(string)
|
||||
pkg_name = json.fetch("package")
|
||||
update_attributes!(package: pkg_name, package_error: nil)
|
||||
rescue => error
|
||||
maybe_recover_from_fetch_error(error)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
module FarmwareInstallations
|
||||
class Create < Mutations::Command
|
||||
|
||||
required do
|
||||
string :url
|
||||
model :device, class: Device
|
||||
end
|
||||
|
||||
def execute
|
||||
FarmwareInstallation.create!(url: url, device: device)
|
||||
fwi = FarmwareInstallation.create!(create_params)
|
||||
fwi.force_package_refresh!
|
||||
fwi
|
||||
end
|
||||
|
||||
private
|
||||
def create_params
|
||||
@create_params ||= { url: url,
|
||||
device: device }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
class FarmwareInstallationSerializer < ApplicationSerializer
|
||||
attributes :url
|
||||
attributes :url, :package
|
||||
end
|
||||
|
|
|
@ -9,7 +9,6 @@ FarmBot::Application.routes.draw do
|
|||
{
|
||||
diagnostic_dumps: [:create, :destroy, :index],
|
||||
farm_events: [:create, :destroy, :index, :show, :update],
|
||||
farmware_installations: [:create, :destroy, :index, :show],
|
||||
images: [:create, :destroy, :index, :show],
|
||||
password_resets: [:create, :update],
|
||||
peripherals: [:create, :destroy, :index, :show, :update],
|
||||
|
@ -38,7 +37,13 @@ FarmBot::Application.routes.draw do
|
|||
|
||||
resources(:points, except: []) { post :search, on: :collection }
|
||||
|
||||
resources :farmware_installations, except: [:update] do
|
||||
post :refresh, on: :member
|
||||
end
|
||||
|
||||
resources :logs, except: [:update, :show] do
|
||||
# When farmware fetching fails and the user
|
||||
# wants to try agian.
|
||||
get :search, on: :collection
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
class AddPackageToFarmwareInstallation < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :farmware_installations,
|
||||
:package,
|
||||
:string,
|
||||
limit: 80
|
||||
add_column :farmware_installations,
|
||||
:package_error,
|
||||
:string
|
||||
end
|
||||
end
|
|
@ -398,7 +398,9 @@ CREATE TABLE public.farmware_installations (
|
|||
device_id bigint,
|
||||
url character varying,
|
||||
created_at timestamp without time zone NOT NULL,
|
||||
updated_at timestamp without time zone NOT NULL
|
||||
updated_at timestamp without time zone NOT NULL,
|
||||
package character varying(80),
|
||||
package_error character varying
|
||||
);
|
||||
|
||||
|
||||
|
@ -2888,6 +2890,7 @@ INSERT INTO "schema_migrations" (version) VALUES
|
|||
('20181204005038'),
|
||||
('20181208035706'),
|
||||
('20190103211708'),
|
||||
('20190103213956');
|
||||
('20190103213956'),
|
||||
('20190108211419');
|
||||
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ describe Api::FarmwareInstallationsController do
|
|||
post :create, body: payload.to_json, params: {format: :json}
|
||||
expect(response.status).to eq(200)
|
||||
expect(FarmwareInstallation.count).to be > old_installation_count
|
||||
expect(json.keys.sort).to eq([:created_at, :id, :updated_at, :url])
|
||||
expect(json.keys.sort)
|
||||
.to eq([:created_at, :id, :package, :updated_at, :url])
|
||||
expect(json[:url]).to eq(url)
|
||||
expect(FarmwareInstallation.find(json[:id]).device).to eq(user.device)
|
||||
end
|
||||
|
@ -50,6 +51,16 @@ describe Api::FarmwareInstallationsController do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#refresh" do
|
||||
it "triggers a re-fetch of farmware" do
|
||||
fi = FactoryBot.create(:farmware_installation, device: user.device)
|
||||
allow_any_instance_of(fi.class).to receive(:force_package_refresh!)
|
||||
sign_in user
|
||||
post :refresh, params: { id: fi.id }
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
it "destroys a record" do
|
||||
sign_in user
|
||||
|
|
|
@ -1,11 +1,70 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe FarmwareInstallation do
|
||||
FAKE_URL = "http://www.sphere.bc.ca/test/circular-man2.html"
|
||||
FAKE_URL = "https://raw.githubusercontent.com/"\
|
||||
"FarmBot-Labs/set-servo-angle/master"\
|
||||
"/manifest.json"
|
||||
let(:device) { FactoryBot.create(:device) }
|
||||
|
||||
it 'Enforces uniqueness of URL' do
|
||||
class Mystery < StandardError; end
|
||||
|
||||
it "handles unknown errors while parsing `farmware.json`" do
|
||||
error = Mystery.new("wow!")
|
||||
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
|
||||
expect(fi).to receive(:open).and_raise(error)
|
||||
expect { fi.infer_package_name_from_url }.to raise_error(error)
|
||||
expect(fi.package_error)
|
||||
.to eq(FarmwareInstallation::OTHER_PROBLEM % Mystery.to_s)
|
||||
expect(fi.package).to eq(nil)
|
||||
end
|
||||
|
||||
it "handles unreasonably large package names" do
|
||||
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
|
||||
stringio = StringIO.new({ package: "*" * 100 }.to_json)
|
||||
expect(fi).to receive(:open).and_return(stringio)
|
||||
fi.infer_package_name_from_url
|
||||
error =
|
||||
FarmwareInstallation::KNOWN_PROBLEMS.fetch(ActiveRecord::ValueTooLong)
|
||||
expect(fi.package_error).to eq(error)
|
||||
expect(fi.package).to eq(nil)
|
||||
end
|
||||
|
||||
it "handles unreasonably large payloads" do
|
||||
old_value = FarmwareInstallation::MAX_JSON_SIZE
|
||||
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
|
||||
stringio = StringIO.new({hello: "world"}.to_json)
|
||||
|
||||
const_reassign(FarmwareInstallation, :MAX_JSON_SIZE, 2)
|
||||
|
||||
expect(fi).to receive(:open).and_return(stringio)
|
||||
fi.infer_package_name_from_url
|
||||
error = FarmwareInstallation::KNOWN_PROBLEMS.fetch(JSON::ParserError)
|
||||
expect(fi.package_error).to eq(error)
|
||||
expect(fi.package).to eq(nil)
|
||||
const_reassign(FarmwareInstallation, :MAX_JSON_SIZE, old_value)
|
||||
end
|
||||
|
||||
it "handles non-JSON strings" do
|
||||
fi = FarmwareInstallation.create(device: device, url: FAKE_URL)
|
||||
expect(fi).to receive(:open).and_return(StringIO.new("{lol"))
|
||||
fi.infer_package_name_from_url
|
||||
error = FarmwareInstallation::KNOWN_PROBLEMS.fetch(JSON::ParserError)
|
||||
expect(fi.package_error).to eq(error)
|
||||
expect(fi.package).to eq(nil)
|
||||
end
|
||||
|
||||
it "handles `package` fetch errors" do
|
||||
malformed_url = "http://#{SecureRandom.base58.downcase}.com"
|
||||
fi = FarmwareInstallation.create!(device: device,
|
||||
url: malformed_url)
|
||||
fi.infer_package_name_from_url
|
||||
error = FarmwareInstallation::KNOWN_PROBLEMS.fetch(SocketError)
|
||||
expect(fi.package_error).to eq(error)
|
||||
expect(fi.package).to eq(nil)
|
||||
end
|
||||
|
||||
it "Enforces uniqueness of URL" do
|
||||
FarmwareInstallation.destroy_all
|
||||
device = FactoryBot.create(:device)
|
||||
first = FarmwareInstallation.create(device: device, url: FAKE_URL)
|
||||
second = FarmwareInstallation.create(device: device, url: FAKE_URL)
|
||||
expect(first.valid?).to be true
|
||||
|
@ -13,7 +72,7 @@ describe FarmwareInstallation do
|
|||
expect(second.errors[:url]).to include("has already been taken")
|
||||
end
|
||||
|
||||
it 'disallows empty URLs' do
|
||||
it "disallows empty URLs" do
|
||||
x = FarmwareInstallation.create(url: "")
|
||||
expect(x.errors[:url]).to include("is an invalid URL")
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue