Skip to content

Architecture

Roope Salmi edited this page May 31, 2021 · 2 revisions

Layered 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.)

Resource layer

  • CSES API: trait CsesApi, implementation CsesHttpApi
    • Data is defined as structs, which can be serialized/deserialized with serde
    • Will use the minreq crate for HTTP requests
  • Local storage: trait Storage, implementation FileStorage
    • Stores API tokens and other persistent local data.
  • Filesystem: trait Filesystem, implementation ConcreteFilesystem
    • 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();
    // ...
}

Service layer

  • 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).

UI layer

  • 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 a Command on startup.

OpenAPI

OpenAPI is used to describe the REST-ish API. It is being designed in tandem with the command line application.

Error handling

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 tests

End-to-end/integration tests work by running the binary itself, connected to a testing server.