Declarative rules for relations between inputs?

What

I would like to be able to describe declaratively the rules that describe what constitutes a valid input into my API. So that I could express things such as “when X is provided, Y also needs to be provided” or “A and B are mutually exclusive”. I want this to be declarative, to be data, so that it could more easily become a part of the documentation and the users of the API could use it themselves to verify what they want to send.

Why

Currently the REST API has Swagger / OpenAPI docs. These specify the data types of the inputs but only a (small) subset of all the possible values that satisfy the types are actually valid. There are important business rules that further limit this domain but they are not communicated. At best there are some comments that mention some of the rules. Thus the process of developing with the API consists of trial and error, sending requests the developer things are right, trying to interpret the error messages, frequently reaching out to the developers of the API for clarification, correcting the requests, and so on. Not very efficient. What it we could make those rules explicit and include them in the documentation? And preferably make them available to the clients so that they could use them in a programatic way?

NOTE: Some of the rules could be encoded with a smart use of data types such as union data types (e.g. “the product user is either Person or Role”).

Examples

To make it concrete, here are a few examples of rules from my domain, ordering of mobile subscriptions and phones:

  1. If the user already has a subscription and should pay breakage fee then her consent to that may be provided. In all other cases it must not be present.

  2. If the order includes a SIM card or any hardware then delivery information must be provided.

  3. If the order only includes a SIM card then the delivery method may be “letter”.

  4. If the product user is a person then we also need her address. (Note: this could be encoded in the data types but currently is not.)

  5. If the order is a transfer of a phone number from a different operator then a consent by the donor legal owner must be provided. However the order can be created without it, it is only required when submitting it.

Questions

Does this reasonable? And doable? How do other people solve this problem, through a better documentation?

Thank you!

2 Likes

Clojure Spec? Or Malli if you prefer?

Edit: can probably be modeled even without dependent types.
Things like multi-schemas can likely cover your use cases:

https://clojuredocs.org/clojure.spec.alpha/multi-spec
The term you need to search for in your favorite engine is “dependent types”

Can probably be done with spec and malli

1 Like

There has been some discussion about declarative rules in #malli clojurians slack: either as data or as custom (sci) functions.

OpenAPI (and Swagger) both limit what can be presented, but one can always generate separate api-docs from Schema/Spec/Malli directly.

Example of rule3 with malli & sci:

[:schema
 {:registry
  {"Order" [:map
            [:items [:vector [:enum "SIM" "SAM"]]]
            [:delivery [:enum "letter" "email"]]]
   "SimDeliveryRule" [:fn {:error/message "If the order only includes a SIM card then the delivery method may be “letter”."
                           :error/path [:delivery]}
                      '(fn [{:keys [items delivery]}]
                         (or (not= items ["SIM"])
                             (= delivery "letter")))]}}
 [:and "Order" "SimDeliveryRule"]]

Same in malli playground.

2 Likes

Some further thoughts
I think most if not all of the cases you described can be reduced to simple schemas.
Let’s look at 3, we can translate it to a syllogism:
The delivery method is a letter if and only if the order includes only a SIM card
i.e.
method = letter → order = [SIM]
Well then
Let

(def Item [:enum "SIM" "SAM"])
(def DeliveryMethods [:enum "SHIPMENT"])

(def RegularOrder
  [:map
   [:items [:vector Item]]
   [:delivery DeliveryMethods]])

Let’s define (worth adding it as a shorthand, Tommi?)

(defn only
  [v]
  [:sequential {:min 1 :max 1} [:= v]])

We can write case 3 as:

(def SimOrder
  (m/schema
   [:map
    [:items (only "SIM")]
    [:delivery [:or DeliveryMethods [:= "letter"]]]]))

Then any order is just:

[:or SimOrder RegularOrder]

Now, that was quite manual. What’s interesting is, can we take it a step further?
Previously, I formalized the requirement as a logical relation, i.e.

;;; generally
[:rel schema1 schema2]
;;; Specifically
[:rel
 [:map [:delivery [:= "letter"]]]
 [:map [:items (only "SIM")]]]

I believe the other constraints you have specified can be laid out this way as well. Do they all reduce to logical conjunction? All the clauses you specified have an or between them
Now here’s the kicker - we already have a language and model for relations - logical programming and datalog!
Could it perhaps be possible to write a datalog → schema compiler, where you specify the desired relations between entities and a schema is derived?

As an initial step, you can just formalize each of these requirements about fields as particular schemas, start from a base schema S0, define Si where you just mu/assoc all the constraints regarding specific fields and close(?) the schema, then S* = [:or S1 ... Si ... Sn]
Thoughts?

2 Likes

where you specify the desired relations between entities and a schema is derived

Oh that’d be awesome I think… Someone please combine EQL and spec and call it speql :wink:

Started taking a crack at it, see how far it goes

3 Likes

This looks awesome, @bsless !

Wonderful! Thank you for digging into this! Some kind of rules engine / datalog was something I thought about but I have not thought about using that as a base for schema generation. Smart!

I will keep watching my code for cases where I would like to express such rules and checking whether this solution would apply.