Payload Services¶
Payload services are essentially hardware services which have been custom designed for mission payload hardware. They share the same architecture as the hardware services, exposing low-level device APIs through a GraphQL interface.
The examples folder of the Kubos repo includes two example payload services: one written in Python, and one written in Rust.
Python Service¶
Inside of the Python service example you will find several files and a folder:
config.yml
- This YAML file holds configuration options for the GraphQL/HTTP endpoint.README.rst
- Overview of the service project.requirements.txt
- Python module requirements file with list of module/version dependencies.service.py
- Boilerplate service code which reads the config file and starts up the GraphQL/HTTP endpoint.service/
- This folder holds the guts of the service’s source. The service folder contains the main files which will need modifying when building a custom payload service:__init__.py
- This empty file belongs in the service/ folder to give service.py access to the modules within.app.py
- Another boilerplate service file. This one should not require any customization.models.py
- Describes the hardware model exposed to GraphQL and contains calls down into lower level APIs.schema.py
- Contains the actual GraphQL models which are used to generate the GraphQL endpoint.
We will now take a closer look at models.py and schema.py to see what exactly it takes to expose a hardware API through the service.
models.py¶
Inside of the example models.py file there are Subsystem and Status classes. Both of these classes must be subclasses of graphql.ObjectType from the Graphene module.
The Subsystem class models the hardware that this service will be interacting with.
class Subsystem(graphene.ObjectType):
"""
Model encapsulating subsystem functionality.
"""
power_on = graphene.Boolean()
def refresh(self):
"""
Will hold code for refreshing the status of the subsystem
model based on queries to the actual hardware.
"""
print "Querying for subsystem status"
self.power_on = not self.power_on
def set_power_on(self, power_on):
"""
Controls the power state of the subsystem
"""
print "Sending new power state to subsystem"
print "Previous State: %s" % self.power_on
print "New State: %s" % power_on
self.power_on = power_on
return Status(status=True, subsystem=self)
Member variables can be added if any persistent data needs to be stored. Member functions are called by the GraphQL schema and are used to call into low-level device API functions.
The Status class is used to model any important information gathered from calling device API functions.
class Status(graphene.ObjectType):
"""
Model representing execution status. This allows us to return
the status of the mutation function alongside the state of
the model affected.
"""
status = graphene.Boolean()
subsystem = graphene.Field(Subsystem)
Right now it just contains a status member which represents the status of the function call and a subsystem member which represents the current state of the Subsystem.
schema.py¶
Now lets look inside of schema.py. This file contains the models used by Graphene to create our GraphQL endpoint.
Queries¶
Queries allow us to fetch data from the subsystem. There is only one Query class needed in the schema.py file.
class Query(graphene.ObjectType):
"""
Creates query endpoints exposed by graphene.
"""
subsystem = graphene.Field(Subsystem)
def resolve_subsystem(self, info):
"""
Handles request for subsystem query.
"""
_subsystem.refresh()
return _subsystem
Any member variables of the type graphene.Field become top-level fields accessible by queries. Because we are using the Subsystem class, which is also a graphene.ObjectType, members of that class become accessible by queries. Each Graphene field requires a resolver function named resolve_fieldname which returns back an object of the field’s class type. In this case, we call _subsystem.refresh() to load the latest data into the global _subsystem object and return it.
The above class would enable the following query for subsystem power status:
{
subsystem {
powerOn
}
}
Mutations¶
Mutations allow us to call functions on the subsystem which cause change or perform some action. Like the Query class we will only need one top level Mutation class.
class Mutation(graphene.ObjectType):
"""
Creates mutation endpoints exposed by Graphene.
"""
power_on = PowerOn.Field()
Like with the Query, each Field member becomes a top-level mutation. However for mutations we will create a new class for each mutation field.
class PowerOn(graphene.Mutation):
"""
Creates mutation for Subsystem.PowerOn
"""
class Arguments:
power = graphene.Boolean()
Output = Status
def mutate(self, info, power):
"""
Handles request for subsystem powerOn mutation
"""
status = Status(status=True, subsystem=_subsystem)
if power != None:
status = _subsystem.set_power_on(power)
return status
The Arguments class describe any argument fields needed for this mutation.
The line Output = Status
describes the class type this mutation should return.
The mutate
function performs the actual work of the mutation and must return back an object of the type specified in the Output
line.
The above classes enable the following mutation:
mutation {
powerOn(power:false) {
status
}
}
Running the example¶
Getting the example service up and running is fairly simple.
First, you must make sure you have the necessary Python dependencies installed.
If you are using the Kubos SDK Vagrant box then these will already be installed.
Otherwise, you will need to run pip install -r requirements.txt
.
Once the dependencies are in place, you can run python service.py config.yml
and the example service should begin.
You will know that it is running if the command line output says * Running on http://0.0.0.1:5000/ (Press CTRL+C to quit)
.
You can now point a web browser to http://127.0.0.1:5000/graphiql to access a graphical GraphQL interface.
Here you can run queries and mutations against the GraphQL endpoints and see the results.
Note
If you are running the example from within the Vagrant box then you may need some additional configuration.
By default the Vagrant box does not forward any ports. In order to access the HTTP
interface of the service running inside of the Vagrant box we need to forward
the port it is using. To do so you will need to add the following line to
your `Vagrantfile`
(after Vagrant.configure("2") do |config|
):
config.vm.network "forwarded_port", guest: 5000, host: 5000
Now restart the Vagrant box with vagrant reload
. You should now have the ability
to run the python service inside the Vagrant box and access it from the outside
at http://127.0.0.1:5000.
Rust Service¶
This is a quick overview of the payload service written in Rust.
The current guide for working with Rust within the Kubos SDK can be found here.
Libraries¶
This payload service and future rust-based services will be written using the following external crate:
- Juniper - GraphQL server library
And one internal helper crate:
- Kubos Service - UDP service interface
The Cargo.toml
in the example payload service gives a good list of crate
dependencies to start with.
Example Source¶
Cargo.lock
- Cargo lock file
Cargo.toml
- Cargo manifest file
src
- Contains the actual Rust source.
main.rs
- Contains setup code using thekubos-service
crate. May need minor customization but not much.model.rs
- Describes the hardware model exposed to GraphQL and contains calls down to lowel-level APIs.schema.rs
- Contains the actual GraphQL schema models used to generate the GraphQL endpoint.
We will now take a closer look at model.rs
and schema.rs
and break down
the pieces required to expose hardware APIs through the service.
model.rs¶
The model.rs
file contains structures and functions used to wrap low-level device APIs
and provide abstractions for the GraphQL schema to call into. Looking inside of the model.rs
file you will see several struct
declarations. We’ll start with the Subsystem
:
pub struct Subsystem;
Here we have a struct which is used to model a subsystem. In this example the struct is given no member variables for persistence. All data is obtained through function calls for real-time results.
Here is an abbreviated set of functions implemented for the Subsystem
struct:
impl Subsystem {
/// Creates new Subsystem structure instance
/// Code initializing subsystems communications
/// would likely be placed here
pub fn new() -> Subsystem {
println!("getting new subsystem data");
// Here we would call into a hardware API
Subsystem {}
}
/// Power status getter
/// Code querying for new power value
/// could be placed here
pub fn power(&self) -> Result<bool, Error> {
println!("Getting power");
// Low level query here
Ok(true)
}
/// Power state setter
/// Here we would call into the low level
/// device function
pub fn set_power(&self, _power: bool) -> Result<SetPower, Error> {
println!("Setting power state");
// Send command to device here
if _power {
Ok(SetPower { power: true })
} else {
Err(Error::new(
ErrorKind::PermissionDenied,
"I'm sorry Dave, I afraid I can't do that",
))
}
}
}
/// Overriding the destructor
impl Drop for Subsystem {
/// Here is where we would clean up
/// any subsystem communications stuff
fn drop(&mut self) {
println!("Destructing subsystem");
}
}
The new
function is the Subsystem
constructor. It can be used to establish
a connection with the hardware if necessary. This function is called once per
query or mutation and produces the struct instance used.
The power
function is an example of a function called during a query. These
functions called by GraphQL functions must return the type Result<T, Error>
in order to properly unpack valid data vs an error message.
The set_power
function is an example of a function called during a mutation.
It is essentially the same as power
but takes a parameter. Functions called
during mutations must also return the type Result<T, Error>
.
The last function is the overridden destructor. This is not required but can be nice if you need to clean up any connections to the subsystem between queries.
In the model.rs
file there are also several other very simple structs which
don’t have any functions implemented for them: SetPower
, ResetUptime
,
and CalibrateThermometer
. These are used as wrappers around scalar values
returned by various mutations in schema.rs
.
schema.rs¶
Now we will take a look inside of schema.rs
. This file contains the query
and mutation models used by Juniper to create
our GraphQL endpoints.
Queries¶
Queries allow us to fetch data from the subsystem. There is only one base Query
struct needed in the schema.rs
file.
pub struct QueryRoot;
/// Base GraphQL query model
graphql_object!(QueryRoot : Context as "Query" |&self| {
field subsystem(&executor) -> FieldResult<&Subsystem>
as "Subsystem query"
{
Ok(executor.context().get_subsystem())
}
});
Inside of the graphql_object macro
we define each top-level query field. In this case there is just the one subsystem
field.
In order to allow GraphQL access to the member functions (or variables) of the Subsystem
struct we also apply the graphql_object
macro to it:
/// GraphQL model for Subsystem
graphql_object!(Subsystem: Context as "Subsystem" |&self| {
description: "Handler subsystem"
field power() -> FieldResult<bool> as "Power state of subsystem" {
Ok(self.power()?)
}
field uptime() -> FieldResult<i32> as "Uptime of subsystem" {
Ok(self.uptime()?)
}
field temperature() -> FieldResult<i32> as "Temperature of subsystem" {
Ok(self.temperature()?)
}
});
Here we create GraphQL field wrappers around each member of the Subsystem
struct that we want exposed. The syntax Ok(self.func()?)
allows the
translation of return type Result<T, Error>
into FieldResult<T>
.
Mutations¶
Mutations allow us to call functions on the subsystem which cause change or
perform some action. Like the QueryRoot
struct, we will only need one
top-level MutationRoot
struct:
pub struct MutationRoot;
/// Base GraphQL mutation model
graphql_object!(MutationRoot : Context as "Mutation" |&self| {
// Each field represents functionality available
// through the GraphQL mutations
field set_power(&executor, power : bool) -> FieldResult<SetPower>
as "Set subsystem power state"
{
Ok(executor.context().get_subsystem().set_power(power)?)
}
});
Each top-level mutation is exposed as an individual field. For each mutation field there is a custom struct wrapping up the return values for that function. Each of these structs must also have the graphql_object macro applied to them.
/// GraphQL model for SetPower return
graphql_object!(SetPower: Context as "SetPower" |&self| {
description: "Enable Power Return"
field power() -> FieldResult<bool> as "Power state of subsystem" {
Ok(self.power)
}
});
These structs define fields which can then be used in the mutation to specify which return data is desired.
Building and Running¶
From inside of a Kubos SDK Vagrant box, navigate to the service
folder of your
copy of the Rust service example.
Issue cargo build
in order to build the service.
Note
The cargo build
command can be used to build any Rust service
or crate from within the Vagrant box.
In order to run the service locally:
- Verify that port 8000 is being forwarded out of your Vagrant box
- Issue
cargo run
Once it is up and running you can use the udp-service-client to issue queries or mutations against the service.