Alula-Ruby
This is the official Alula ruby API client.
Refer to last two sections if you're making changes in alula-ruby to be used as gem file in AC.
Installation
This gem is public, and can be installed via bundler from Rubygems.
gem 'alula-ruby', '~> 1.1'
If you use Sidekiq
and plan on using the Alula-Ruby gem in your Sidekiq workers, you should also bundle the RequestStore-Sidekiq
extension. This extension is used to store client authorization info in Thread.current
for multithreading support.
gem 'request_store-sidekiq', '~> 0.1'
And then execute:
$ bundle
Authorization
Alula-Ruby requires an OAuth access token to use. You can obtain a token like so:
# Authorize the OAuth client
Alula::Oauth.configure(client_id: "your client id", client_secret: "your client secret", api_url: "the API URL")
# Obtain OAuth tokens with username & password
creds = Alula::Oauth.authenticate(username: "a username", password: "a password")
#> creds.token_type = bearer
#> creds.access_token = some string
#> creds.refresh_token = some string
#> creds.expires_in = integer
#> creds.scope = string
Configuring
There are two types of settings in the Alula::Client
configuration:
The first one are the ones that will have a fixed value in the application lifecicle so its better to put them in an initializer.
Alula::Client.configure do |c|
c.api_key = # your API key
c.debug = # if set to true will log requests to the API
c.user_agent = # A short name for your script/application
end
The second one are the ones that are stored in the request Rack env, so those need to set on each request (to the application).
Alula::Client.configure do |d|
c.api_key = # your API key
c.role = # User/Customer role
c.customer_id = # The user_id, optional, only for video API requests
end
Usage
Once you have obtained an access token, configure the client to use that token. You should perform this initialization at the start of every request, and at the start of any thread that performs work. The Alula-Ruby client uses the RequestStore
gem to stash its configuration data in Thread.current
, so keep this limitation in mind.
Alula::Client.config.api_key = "your access token"
If you need to save any records, you will also need to tell the Alula::Client
about your users' authorized role. Depending on the role certain fields may be writeable or protected. You can set your role one of 3 ways:
myself = Alula::Self.retrieve
# The user role is inferred from the Alula::Self object
Alula::Client.config.role = myself
# Explicitly set the role via a role symbol
Alula::Client.config.role = :dealer
Any method will do, you just need to perform one of these prior to attempting to save a resource. Role symbols map to uType resources. The Alula Gem supports the following roles & their corrisponding uType:
| Name | uType | Role | Description |
|---------------|-------|----------------|----------------------------------------|
| System | 2 | :system | Highly-privileged system user |
| Station | 4 | :station | Central station user |
| Dealer | 8 | :dealer | Dealer user; child of station users |
| Technician | 16 | :technician | Technician user; child of dealer users |
| User | 32 | :user | Normal user; child of dealer users |
| Sub-User | 64 | :sub_user | Child user; child of normal users |
| User/Sub-User | 96 | :user_sub_user | Child user; child of normal users |
See the official Alula API docs for a detailed breakdown of which fields on which resources are changable by which roles.
A Hash object is provided on the Alula::User
model mapping uType
to Role
:
pp Alula::User::UTYPE_ROLE_MAP
{
2 => :system,
4 => :station,
8 => :dealer,
16 => :technician,
32 => :user,
64 => :sub_user,
96 => :user_sub_user
}
Retrieving Records
Records can be fetched as a single record:
# Fetch a single device
device = Alula::Device.retrieve(device_id)
device.friendly_name
#> 'Test Device'
# Fetch singleton resources, like self, without an ID
me = Alula::Self.retrieve
Collections of records can also be fetched:
# Fetch devices
devices = Alula::Device.list
# Fetch user data
users = Alula::User.list
Paging collections
Collections can be paginated
# Use .offset to request different pages
devices = Alula::Device.offset(2).list
# Use .size to change the default page size
devices = Alula::Device.size(100).list
# Use together
devices = Alula::Device.offset(20).size(25).list
# A raw .page method is offered:
devices = Alula::Device.page(size: 20, number: 2).list
Sorting collections
Collections can be sorted. See the Alula API documentation for details of which fields can be sorted.
devices = Alula::DeviceEventLog.sort(date_entered: :desc).where_like(mac: '%54%').list
Including related models
Many models relate to other models. When loading a model you can specify related models to 'include' in the fetch, and if they exist they will be available on the model.
An Alula::Device
has a single Dealer
that relates to it, as defined in the Device metadata:
relationship :dealer, type: 'dealers', cardinality: 'To-one'
When you load the Device with and include the Dealer, any available Dealer
data will become available as an Alula::Dealer
object like so:
device = Alula::Device.includes(:dealer).retrieve(some_id)
puts device.dealer
# A Dealer model will be output here
puts device.dealer.company_name
# A company name here...
TODO: This section is in progress! We are missing many models, so not all relationships are ready for inclusion.
Filter collections
Collections of records can be filtered against using a fluent query API. Check the API documentation to see which fields can be filtered for each model. Errors will be raised when invalid field filter options are selected.
Filters directly map to their Sequelize operator, when composing complex queries it is helpful to know how the Sequelize operators work.
Field Names
All filters take field names as hash keys, and filter values as hash values. You can use the camelCase representation of each field, or a snake_case represenation. Internally all keys are cast to snake_case for validation, and to camelCase for actual querying.
$where filter
The $where
filter is the most basic filter. It creates an exact match for any fields passed in:
devices = Alula::Device.where(friendly_name: 'TitusTestDevice', program_id: 33).list
devices = Alula::Device.where(friendly_name: 'TitusTestDevice').where(program_id: 33).list
$and filter
The $and
filter is very similar to the $where
filter. Unlike $where
, each field passed into the $and
filter will be wrapped in the explicit $and
clause.
devices = Alula::Device.and(friendly_name: 'TitusTestDevice', program_id: 33).list
$like and $notLike filters
The $like
and $notLike
filters allow for wildcard searches across multiple fields.
# Find all devices where the friendly_name starts with 'Titus'
devices = Alula::Device.like(friendly_name: 'Titus%').list
# Find all devices where the term 'Titus' is not present in the friendly_name
devices = Alula::Device.not_like(friendly_name: '%Titus%').list
$in and $notIn filters
The $in
and $notIn
filters accept an array of matches to be queried. The values should be simple values, numbers, strings, and ISO8601 string representations of dates.
# Find records with a value containing one of an array of values
devices = Alula::Device.in(program_id: [1, 22, 39]).list
# Exclude records with an array of values
devices = Alula::Device.not_in(program_id: [8, 33, 1]).list
$between and $notBetween filters
The $between
and $notBetween
filters are similar to an $in
query, but they take an array of 2 values and request records between those values. Pass in numbers, ISO8601-formatted date strings, or strings where MariaDB can figure out what it means to be "between" each string.
# Find records created between dates
customers = Alula::User.between(date_entered: [1.year.ago.iso8601, Time.now.iso8601]).list
# Find records outside of a range of values
devices = Alula::Device.not_between(online_status_timestamp: [1.week.ago.to_i, Time.now.to_i]).list
$not and $ne filters
The $not
and $ne
filters ($ne for Not Equivilant) operators are roughly analagous, though they use different matchers when constructing their SQL queries.
# Where a field is not strictly a value
devices = Alula::Device.not(friendly_name: 'TitusTestDevice').list
# Where a field is not equivilant to a value
devices = Alula::Device.ne(program_id: '2').list
$lt, $lte, $gt, $gte filters
The family of less-than, less-than-or-equal, greater-than, and greater-than-or-equal filters all works the same way. They accept a scalar value, a number or ISO8601 date string, and return records that match their constraints.
devices = Alula::Device.gt(:online_status_timestamp: 1.year.ago.to_i).list
devices = Alula::Device.gte(:online_status_timestamp: 1.year.ago.to_i).list
devices = Alula::Device.lt(:online_status_timestamp: 1.week.ago.to_i).list
devices = Alula::Device.lte(:online_status_timestamp: 1.week.ago.to_i).list
$or filters
The $or
filter is unique in how it is called, and how it constructs a query. $or
can take any nested set of other filter operators, so you can combine $like
and $in
filters into a single $or
query to retrieve a broad set of records.
The $or
filter provides a strict match key->value interface, and a fluent block interface.
# Build an $or query for strict field values
# Will return any device with the friendly_name 'TitusTestDevice' OR a program_id of 33
devices = Alula::Device.or(friendly_name: 'TitusTestDevice', program_id: 33).list
# Build an expressive $or query
# The provided `query_builder` param allows full access to the filter API
# This will search for devices with a program_id of 2, 33, 85, or 12,
# where the friendly_name contains 'Helix', and no device is of program_id 2
#
devices = Alula::Device.or do |query_builder|
query_builder.in(program_id: [2, 33, 85, 12])
.like(friendly_name: '%Helix%')
end
devices = devices.not(program_id: 5).page(20).list
$or $like filters
The OR LIKE pattern is commonly used to do a wildcard search for a term across a bunch of fields at the same time. A shorthand query method is provided to make this easier. As this is a LIKE query, you can use wildcards (%
) in your term.
devices = Alula::Device.or_like(friendy_name: '%Titus%', email: '%@alula.net').list
Constructing your own filters
You can build your own JSON API-compliant filters with the .filter
method if any of the built in operators do not work for you. Be aware that you must use the camelCase
field name format, and no validation is performed against your query.
# All devices with a strict friendly_name match and an $in query on the program_id
devices = Alula::Device.filter({
friendlyName: 'TitusTestDevice',
$or: {
$in: {
programId: [2, 10]
}
}
}).list
Debugging Filters
Before calling .list
to execute your query, you can call .as_json
, and be given a JSON representation of your built query. Be aware multiple filters against the same field may overwrite one another, so get a JSON dump to ensure that your assembled query is in the form you intend.
Supported API filter methods
The Alula API supports the following filter methods. Access to these filter methods are provided with Ruby methods on collection objects.
API Operator | Ruby Method | Value | Comment |
---|---|---|---|
$gt | .gt | Scalar; number, date | Greater than (numeric) |
$gte | .gte | ^ Ditto | Greater than or equal |
$lt | .lt | ^ Ditto | Less than |
$lte | .lte | ^ Ditto | Less than or equal |
$not | .not | Scalar; any | Not |
$ne | .ne | Scalar; any | Not equal |
$between | .between | Array; numbers, dates | Between range |
$notBetween | .not_between | ^ Ditto | Not between range |
$in | .in | Array; any | Array of matches |
$notIn | .notIn | Array; any | Array of negative matches |
$like | .like | Scalar; string | Match string where % can be wildcard |
$notLike | .not_like | Scalar; string | Negative match string where % can be wildcard |
$or | .or | Complex | See example |
$and | .and | Array of associative arrays | Assoc. array where keys are field names, values like at top-level |
Saving Records
And you can save records
device.friendly_name = 'Waaaluigi'
device.save
#> true
Errors saving are reflected with a false
return to the save call, and errors on the model:
device.meid = 'Test Test'
device.save
#> false
device.errors.first
Remote Procedure Calls (RPC)
Alula-Ruby partially supports the Alula API's RPC namespace. All RPC methods use the same style method signature, differing only in what params are passed. See the API documentation for a list of params each method supports.
Each remote procedure call supports a single method, named call
. This method takes a param list equal to the remote procedures params (underscored, not camelcased).
Responses respond to the method .ok?
for inferring if an error took place.
Success responses are custom per RPC method. Some provide response data, some do not. Response data is raw JSON and is available via response.result
, and it will be a Hash or Array.
Development
After checking out the repo, run bin/setup
to install dependencies. You can also run bundle exec bin/console
for an interactive prompt that will allow you to experiment.
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
, commit and push your changes. GitHub Actions takes care of the gem release.
Using Docker
Alula Ruby runs its unit & integratin tests against a copy of the API running in Docker. The remote API is cleaned up (DB truncated & re-seeded fresh) between every describe
or context
block.
Authenticate with AWS
Set the registry account number to refer to
export AWS_ECR_REGISTRY_ID=613707345027 #409473619697
Then execute AWS login where the
$env
is whatever profile name your Shared Services account is setup under in AWS CLI SSO.aws ecr get-login-password --profile $env | docker login \ --username AWS --password-stdin $AWS_ECR_REGISTRY_ID.dkr.ecr.$AWS_REGION.amazonaws.com
Set up local Core API cluster/swarm using
make
:make up
or use the
alula-docker-compose
approach:alula-docker-compose -I @test-helper --registry '6z1wlx5zf1.execute-api.us-east-1.amazonaws.com/' -- up -d
Note: You do need to be authorized against our private ECR registry. This is manual, talk with an Alula Lead to get pointed in the right direction. TODO: Write out what we need to do for this.
Configure your local .env file, you can copy-paste
.env.example
over to.env
Run the complete test suite:
bundle exec rspec
Run a specific test file:
`bundle exec rspec ./spec/alula/oauth_spec.rb`
Run Guard to have tests run on file change
`bundle exec guard`
If you're using the Ruby Test Explorer extension to run the tests within VS Code, SimpleCov has a dry run error preventing auto detection. You need to turn off minimum coverage to get the dry run command it uses to find tests to work.
SimpleCov.start do add_filter '/spec/' minimum_coverage 0 end
Update all Docker images to the latest images:
docker compose -f alula-docker-compose.yml pull
Occasionally under heavy use the dockerized API may lose or drop its databases, resulting in the test suite erroring completly and very quickly. To fix this simply restart the API with docker-compose -f alula-docker-compose.yml down && docker-compose -f alula-docker-compose.yml up -d
Adding a new device
Optional: When a new device is added, methods for it can be exposed inside lib/alula/helpers/device_helpers/program_id_helper.rb. The device ID can be added to existing consts or new methods can be created for the specific device.
Importing GEM in AC Docker
Most of the times, the work we do in alula ruby includes just updating the model/procedures and push the changes, without having to worry about the configuration mentioned above. When we make changes to alula ruby locally we need to test if the gem file works. For that we need to point to the local alula ruby gem we're working on from AC.
This is straight forward if you're not using docker: provide the path of gem file in Gemfile in AC. But if you're using docker then you'll have to load the volume in the docker first and then update the gemfile to that specific volume.
- In docker-compose.yml of your AC update the alulaconnect/volumes as:
- ~/Documents/alula-project/alula-ruby:/app/alula-ruby
In this step, you are basically pointing to the file location of your alula-ruby and loading it in the docker volume as 'app/alula-ruby'. Make sure that you got the path of your alula ruby correct.
- Second step is to import the gem file from the loaded volume. Update your Gemfile in AC as:
gem 'alula-ruby', path: "/app/alula-ruby"
Make sure that you comment out the existing import where we're fetching gem from the remote server.
Once this is done, you're ready to test your local alula-ruby gem on the alula connect side.
Releasing
In your PR:
- Update VERSION.md with a new version number and a change list
- Update
lib/alula/version.rb
with the new version number - Commit these changes & include them in your PR.
After merging your PR:
- Check out
main
and pull to get the latest.
Update the following in AC side, the commits are to be made from AC for the files mentioned:
- Once merge is done and build is successful update the gem 'alula-ruby' in alula connect Gemfile to the latest version you updated. Also run
bundle install
to update Gemfile.lock file. Commit both files. Failing to do so will break the build process of alula connect. <!-- - Run
bundle exec rake release
. A tag will be pushed to Github and then the UI will ask you input to push to a nonexistant URL. Just spam the enter key and let it error out. It's the tag on github that we care about. [[ github action takes care of this ]] -->