Architecture
The current architecture is layered into resources (CSES API, local storage, filesystem), service, and UI components.
In general, these components implement a trait interface, so that dependency injection can be used for tests, and potentially caching middleware. Fake implementations can then be written (see src/test/service.rs
), and the mockall
library could be used to write mocks.
Dependencies to trait interfaces are declared with generics. It is also possible to use dynamically dispatched trait objects, which would cut down on boilerplate, but this can prevent certain optimizations, possibly affecting binary size. (Aside: faux
is yet another solution to this, which replaces struct implementations for tests during compile time. Using it would prevent different types of fake implementation other than mocks, however.)
- CSES API: trait
CsesApi
, implementationCsesHttpApi
- Data is defined as structs, which can be serialized/deserialized with serde
- Will use the
minreq
crate for HTTP requests
- Local storage: trait
Storage
, implementationFileStorage
- Stores API tokens and other persistent local data.
- Filesystem: trait
Filesystem
, implementationConcreteFilesystem
- Reads code files for submissions
Resources are combined into one struct for convenience, Resources
. To allow for dependency injection without needing generics for each different resource, the trait ResourcesProvider
is used to group the implementations together.
Unfortunately, type aliases for something like Resources<impl ResourcesProvider>
are not (yet) possible, so a slightly more verbose type signature will need to be written for service functions. To ease this a bit, I defined a type alias type RP = ResourcesProvider
.
fn ping(res: &mut Resources<impl RP>) {
res.api.ping();
// ...
}
- Primarily free standing functions in the
service
namespace.- Service functions are passed
&mut Resources<impl RP>
to access resource layer functionalities. - In principle, the service shouldn't hold state (other than through
Storage
etc). Any state needed during execution should be passed in each time as a parameter.- For simple asynchronous operations, such as when waiting for a submission to be graded, callbacks should be suitable. For more complex cases, separate threads and message queues are the way to go (needs more planning).
- Service functions are passed
- Command line parsing: enum
Command
- Enum variants correspond to different subcommands, e.g. "Help", "Submit". Can nest different types of parameters.
- Command line arguments are parsed into this enum using the
pico-args
crate.
- Terminal UI: implementation
Ui
- Uses the
console
crate for cross-platform terminal features, such as rewriting lines for status updates, password input, and colors. - Initialized with a
Service
, passed aCommand
on startup.
- Uses the
OpenAPI is used to describe the REST-ish API. It is being designed in tandem with the command line application.
For the most part, anyhow
is used for user-facing error handling. If an error occurs somewhere, it is passed up to the UI and to main
, where it will be printed, and execution will stop.
However, for the HTTP API, we might want to handle some errors explicitly, rather than wrapping them in anyhow::Error
and aborting. There I have set up an error enum, ApiError
, using thiserror
.
Panics should never occur in any (reasonable) circumstance. I have disabled panic unwinding (stack traces) in the release build to minimize binary size.
End-to-end/integration tests work by running the binary itself, connected to a testing server.