Ruby Client for the MyJohnDeere API
This client allows you to connect the MyJohnDeere API without having to code your own oAuth process, API requests, and pagination.
- Works with Rails, but does not require it
- Supports both sandbox and live mode
- Simplifies the oAuth negotiation process
- Provides an ActiveRecord-style interface to many endpoints
- Provides
get
,create
,put
, anddelete
methods to make easy, authenticated, direct API calls - Uses ruby enumerables to handle pagination behind the scenes. Calls like
each
,map
, etc will fetch new pages of data as needed.
Table of Contents
- How to Read This Documentation
- Installation
- Authorizing with John Deere via oAuth 1.0
- Interacting with the User's John Deere Account
- Using the Client to Do Stuff
- Contribution Products
- Contribution Definitions
- Organizations
- Assets
- Asset Locations
- Fields
- Direct API Requests
- GET
- POST
- PUT
- DELETE
- Errors
- How Can I Help?
- Give Us a Star!
- Contribute to This Gem
How To Read This Documentation
We provide RDoc documentation, but here is a helpful guide for getting started. Because the gem name is long, all examples are going to assume this shortcut:
JD = MyJohnDeereApi
So that when you see:
JD::Authorize
It really means:
MyJohnDeereApi::Authorize
Installation
This library is available as a gem. To use it, just install the gem:
gem install my_john_deere_api
If you're using Bundler (and why wouldn't you?) then add the gem to your gemfile:
gem 'my_john_deere_api'
and run:
bundle install
Authorizing with John Deere via oAuth 1.0
This is the simplest path to authorization, though your user has to jump through an extra hoop of giving you the verification code:
# Create an authorize object, using your app's API key and secret. You can
# pass an environment (`:live` or `:sandbox`), which default to `:live`.
authorize = JD::Authorize.new(API_KEY, API_SECRET, environment: :sandbox)
# Retrieve a valid authorization url from John Deere, where you can send
# your user for authorizing your app to the JD platform.
url = authorize.authorize_url
# Verify the code given to the user during the authorization process, and
# turn this into access credentials for your user.
authorize.verify(code)
In reality, you will likely need to re-instantiate the authorize object when the user returns, and that works without issue:
# Create an authorize object, using your app's API key and secret.
authorize = JD::Authorize.new(API_KEY, API_SECRET, environment: :sandbox)
# Retrieve a valid authorization url from John Deere.
url = authorize.authorize_url
# Queue elevator music while your app serves other users...
# Re-create the authorize instance in a different process
authorize = JD::Authorize.new(API_KEY, API_SECRET, environment: :sandbox)
# Proceed as normal
authorize.verify(code)
In a web app, you're prefer that your user doesn't have to copy/paste verification codes. So you can pass in an :oauth_callback url. When the user authorizes your app with John Deere, they are redirected to the url you provide, with the paraameter 'oauth_verifier' that contains the verification code so the user doesn't have to provide it.
# Create an authorize object, using your app's API key and secret.
authorize = JD::Authorize.new(
API_KEY,
API_SECRET,
environment: :sandbox,
oauth_callback: 'https://example.com'
)
# Retrieve a valid authorization url from John Deere.
# This will contain the callback url encoded into the
# query string for you.
url = authorize.authorize_url
# Queue elevator music while your app serves other users...
# Re-create the authorize instance in a different process.
# It's not necessary to re-initialize with the callback url.
authorize = JD::Authorize.new(API_KEY, API_SECRET, environment: :sandbox)
# Inside a Rails controller, you might do this:
authorize.verify(params[:oauth_verifier])
Interacting with the User's John Deere Account
After authorization is complete, the Client
object will provide most of the interface for this library. A client can
be used with or without user credentials, because some API calls are specific to your application's relationship
with John Deere, not your user's. But most interactions will involve user data. Here's how to instantiate a client:
client = JD::Client.new(
# the application's API key
API_KEY,
# the application's API secret
API_SECRET,
# the chosen environment (:sandbox or :live)
environment: :sandbox,
# optional contribution_definition_id. This is needed for some requests,
# but the client can be created without it, in order to find it.
contribution_definition_id: CONTRIBUTION_DEFINITION_ID,
# the user's access credentials
access: [ACCESS_TOKEN, ACCESS_SECRET]
)
Using the Client to Do Stuff
Once you're connected, the client works like a simplified version of ActiveRecord. JSON hashes from the API are
converted into objects to be easier to work with. Collections of things, like organizations, handle pagination
for you. Just iterate using each
, map
, etc, and new pages are fetched as needed.
This client is a work in progress. You can currently do the following things without resorting to API calls:
client
├── contribution_products
| ├── count
| ├── all
| ├── first
| └── find(contribution_product_id)
| └── contribution_definitions
| ├── count
| ├── all
| ├── first
| └── find(contribution_definition_id)
└── organizations
├── count
├── all
├── first
└── find(organization_id)
├── assets(attributes)
| ├── create(attributes)
| ├── count
| ├── all
| ├── first
| └── find(asset_id)
| ├── save
| ├── update(attributes)
| └── locations
| ├── create(attributes)
| ├── count
| ├── all
| └── first
└── fields
├── count
├── all
├── first
└── find(field_id)
└── flags
├── count
├── all
└── first
Contribution Product collections act like a list. In addition to all the methods included via Ruby's Enumerable Module, contribution product collections support the following methods:
- all
- count
- first
- find(contribution_product_id)
An individual contribution product supports the following methods and associations:
- id
- market_place_name
- market_place_description
- default_locale
- current_status
- activation_callback
- preview_images
- supported_regions
- supported_operation_centers
- links
- contribution_definitions (collection of this contribution product's contribution definitions)
client.contribution_products
# => collection of contribution products under this client
client.contribution_products.count
# => 1
client.contribution_products.first
# => an individual contribution product
contribution_product = client.contribution_products.find(1234)
# => an individual contribution product, fetched by ID
contribution_product.market_place_name
# => 'Market Place Name'
contribution_product.contribution_definitions
# => collection of contribution definitions belonging to this contribution product
Handles a contribution product's contribution definitions. Contribution definition collections support the following methods:
- all
- count
- first
- find(contribution_definition_id)
An individual contribution definition supports the following methods and associations:
- id
- name
- links
contribution_product.contribution_definitions
# => collection of contribution definitions under this contribution product
client.contribution_definitions.count
# => 1
client.contribution_definitions.first
# => an individual contribution definition
contribution_definition = contribution_product.contribution_definitions.find(1234)
# => an individual contribution definition, fetched by ID
contribution_definition.name
# => 'Contribution Definition Name'
Handles an account's organizations. Organization collections support the following methods:
- all
- count
- first
- find(organization_id)
An individual organization supports the following methods and associations:
- id
- name
- type
- member?
- links
- assets (collection of this organization's assets)
- fields (collection of this organization's fields)
The count
method only requires loading the first page of results, so it's a relatively cheap call. On the other hand,
all
forces the entire collection to be loaded from John Deere's API, so use with caution. Organizations cannot be
created via the API, so there is no create
method on this collection.
client.organizations
# => collection of organizations under this client
client.organizations.count
# => 15
client.organizations.first
# => an individual organization object
organization = client.organizations.find(1234)
# => an individual organization object, fetched by ID
organization.name
# => 'Smith Farms'
organization.type
# => 'customer'
organization.member?
# => true
organization.links
# => {
# 'self' => 'https://sandboxapi.deere.com/platform/organizations/1234',
# 'machines' => 'https://sandboxapi.deere.com/platform/organizations/1234/machines',
# 'wdtCapableMachines' => 'https://sandboxapi.deere.com/platform/organizations/1234/machines?capability=wdt'
# }
organization.assets
# => collection of assets belonging to this organization
organization.fields
# => collection of fields belonging to this organization
Handles an organization's assets. Asset collections support the following methods:
- create(attributes)
- all
- count
- first
- find(asset_id)
An individual asset supports the following methods and associations:
- id
- title
- category
- type
- sub_type
- links
- update(attributes)
- locations (collection of this asset's locations)
organization = client.organizations.first
# => the first organization returned by the client
organization.assets
# => collection of assets belonging to this organization
asset = organization.assets.find(123)
# => an individual asset object, fetched by ID
asset.title
# => 'AgThing Water Device'
asset.category
# => 'DEVICE'
asset.type
# => 'SENSOR'
asset.sub_type
# => 'OTHER'
asset.links
# => a hash of API urls related to this asset
The create
method creates the asset in the John Deere platform, and returns the newly created record.
asset = organization.assets.create(
title: 'Asset Title',
asset_category: 'DEVICE',
asset_type: 'SENSOR',
asset_sub_type: 'ENVIRONMENTAL'
)
asset.title
# => 'Asset Title'
The update
method updates the local object, and also the asset on the John Deere platform.
Only the title of an asset can be updated.
asset.update(title: 'New Title')
asset.title
# => 'New Title', also John Deere record is updated
The save
method updates John Deere with any local changes that have been made.
asset.title = 'New Title'
asset.save
# => Successful Net::HTTPNoContent object
Handles an asset's locations. Asset Location collections support the following methods:
- create(attributes)
- all
- count
- first
An individual location supports the following methods:
- timestamp
- geometry
- measurement_data
asset = organizations.assets.first
# => the first asset returned by the organization
asset.locations
# => collection of locations belonging to this asset
location = asset.locations.first
# => the first location returned by the asset. Note that locations do not have their own id's
# in the JD platform, and therefore cannot be requested individually via a "find" method.
location.timestamp
# => "2019-11-11T23:00:00.000Z"
# John Deere includes 3 decimal places in the format, but does not actually
# store fractions of a second, so it will always end in ".000". This is
# important, because timestamps must be unique.
location.geometry
# => a GeoJSON formatted hash, for example:
# {
# "type"=>"Feature",
# "geometry"=>{
# "geometries"=>[
# {
# "coordinates"=>[-95.123456, 40.123456],
# "type"=>"Point"
# }
# ],
# "type"=>"GeometryCollection"
# }
# }
location.measurement_data
# => the status details of this location, for example:
# [
# {
# "@type"=>"BasicMeasurement",
# "name"=>"[Soil Temperature](http://example.com/current_temperature)",
# "value"=>"21.0",
# "unit"=>"°C"
# }
# ]
The create
method creates the location in the John Deere platform, and returns the newly created
object from John Deere. However, there will be no new information since there is no unique ID
generated. The timestamp submitted (which defaults to "now") will be rounded
to the nearest second.
locaton = asset.locatons.create(
# You can pass fractional seconds, but they will be truncated by JD.
timestamp: "2019-11-11T23:00:00.123Z",
# JD requires more complicated JSON geometry, but this client will convert a simple
# set of lat/long coordinates into the larger format automatically.
geometry: [-95.123456, 40.123456],
# This is a list of "measurements"
measurement_data: [
{
name: 'Temperature',
value: '68.0',
unit: 'F'
}
]
)
location.timestamp
# => "2019-11-11T23:00:00.000Z"
# Note that the timestamp's fractional second is truncated by John Deere, though they
# still return the record with three digits of precision.
location.geometry
# => a GeoJSON formatted hash in its larger format
# {
# "type"=>"Feature",
# "geometry"=>{
# "geometries"=>[
# {
# "coordinates"=>[-95.123456, 40.123456],
# "type"=>"Point"
# }
# ],
# "type"=>"GeometryCollection"
# }
# }
location.measurement_data
# [
# {
# "@type"=>"BasicMeasurement",
# "name"=>"Temperature",
# "value"=>"68.0",
# "unit"=>"F"
# }
# ]
There is no updating or deleting of a location. The newest location record always acts as the status for the given asset, and is what appears on the map view.
Note that locations are called "Asset Locations" in John Deere, but we call the association "locations", as in
asset.locations
, for brevity.
Handles an organization's fields. Field collections support the following methods:
- all
- count
- first
- find(field_id)
An individual field supports the following methods and associations:
- id
- name
- archived?
- links
- flags (collection of this field's flags)
The count
method only requires loading the first page of results, so it's a relatively cheap call. On the other hand,
all
forces the entire collection to be loaded from John Deere's API, so use with caution. Fields can be
created via the API, but there is no create
method on this collection yet.
organization.fields
# => collection of fields under this organization
organization.fields.count
# => 15
organization.fields.first
# => an individual field object
field = organization.fields.find(1234)
# => an individual field object, fetched by ID
field.name
# => 'Smith Field'
field.archived?
# => false
field.links
# => a hash of API urls related to this field
field.flags
# => collection of flags belonging to this field
Handles a field's flags. Flag collections support the following methods. Note, John Deere does not provide an endpoint to retrieve a specific flag by id:
- all
- count
- first
An individual flag supports the following methods and associations:
- id
- notes
- geometry
- archived?
- proximity_alert_enabled?
- links
The count
method only requires loading the first page of results, so it's a relatively cheap call. On the other hand,
all
forces the entire collection to be loaded from John Deere's API, so use with caution. Flags can be
created via the API, but there is no create
method on this collection yet.
field.flags
# => collection of flags under this field
field.flags.count
# => 15
flag = field.flags.first
# => an individual flag object
flag.notes
# => 'A big rock on the left after entering the field'
flag.geometry
# => a GeoJSON formatted hash, for example:
# {
# "type"=>"Feature",
# "geometry"=>{
# "geometries"=>[
# {
# "coordinates"=>[-95.123456, 40.123456],
# "type"=>"Point"
# }
# ],
# "type"=>"GeometryCollection"
# }
# }
field.archived?
# => false
field.proximity_alert_enabled?
# => true
field.links
# => a hash of API urls related to this flag
Direct API Requests
While the goal of the client is to eliminate the need to make/interpret calls to the John Deere API, it's important to be able to make calls that are not yet fully supported by the client. Or sometimes, you need to troubleshoot.
GET
GET requests require only a resource path.
client.get('/organizations')
Abbreviated sample response:
{
"links": ["..."],
"total": 1,
"values": [
{
"@type": "Organization",
"name": "ABC Farms",
"type": "customer",
"member": true,
"id": "123123",
"links": ["..."]
}
]
}
This won't provide any client goodies like pagination or validation, but it does parse the returned JSON.
POST
POST requests require a resource path, and a hash for the request body. The client will camelize the keys, and convert to JSON.
client.post(
'/organizations/123123/assets',
{
"title"=>"i like turtles",
"assetCategory"=>"DEVICE",
"assetType"=>"SENSOR",
"assetSubType"=>"ENVIRONMENTAL",
"links"=>[
{
"@type"=>"Link",
"rel"=>"contributionDefinition",
"uri"=>"https://sandboxapi.deere.com/platform/contributionDefinitions/CONTRIBUTION_DEFINITION_ID"
}
]
}
)
John Deere's standard response is a 201 HTTP status code, with the message "Created". This method returns the full Net::HTTP response.
PUT
PUT requests require a resource path, and a hash for the request body. The client will camelize the keys, and convert to JSON.
client.put(
'/assets/123123',
{
"title"=>"i REALLY like turtles",
"assetCategory"=>"DEVICE",
"assetType"=>"SENSOR",
"assetSubType"=>"ENVIRONMENTAL",
"links"=>[
{
"@type"=>"Link",
"rel"=>"contributionDefinition",
"uri"=>"https://sandboxapi.deere.com/platform/contributionDefinitions/CONTRIBUTION_DEFINITION_ID"
}
]
}
)
John Deere's standard response is a 204 HTTP status code, with the message "No Content". This method returns the full Net::HTTP response.
DELETE
DELETE requests require only a resource path.
client.delete('/assets/123123')
John Deere's standard response is a 204 HTTP status code, with the message "No Content". This method returns the full Net::HTTP response.
Errors
Custom errors help clearly identify problems when using the client:
-
UnsupportedEnvironmentError is raised when you attempt to instantiate a client with an
unrecognized environment. Valid environments are
:sandbox
or:production
. - InvalidRecordError is raised when bad input has been given, in an attempt to create or update a record on the John Deere platform.
- MissingContributionDefinitionIdError is raised when the optional contribution_definition_id has not been set in the client, but an operation has been attempted that requires it - like creating an asset in the John Deere platform.
- TypeMismatchError is raised when a model is instantiated, typically when a record is received from John Deere and is being converted into a Ruby object. Model instantiation is normally handled by request objects, but this error is helpful if you're instantiating your own models for advanced usage.
- NotYetImplementedError is raised when you attempt to use a feature that is earmarked for future development, but hasn't been implemented in this client yet. These are a great chance to contribute to this gem!
How Can I Help?
Give Us a Star!
Star this gem on GitHub. It helps developers find and choose this gem over others that may be out there. To our knowledge, there are no other John Deere gems that are being actively maintained.
Contribute to This Gem
The easiest way to contribute is:
- Clone the repo
- Create a feature branch
- Grep for "raise NotYetImplementedError" in the lib directory
- Replace one of these exceptions with working code, following the conventions used in the rest of the app
- TEST EVERYTHING!
- Run tests.
- You may need to regenerate all VCR cassettes from scratch.
- All VCR cassettes should be pre-recorded in
vcr_setup
- Anything that is created in the JD sandbox as a result of running the tests should be removed, also in
vcr_setup
.
- When tests are passing, submit a Pull Request.