Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow clients configure API features #794

Merged
merged 9 commits into from Apr 1, 2023
4 changes: 4 additions & 0 deletions CHANGELOG.md
Expand Up @@ -41,6 +41,10 @@ changes.
bleeding-edge from `master` branch is available at
https://hydra.family/head-protocol/unstable.

- API clients can decide if they want to:
+ Skip observing history of events before they connected
+ View the transactions in the server output encoded as CBOR

## [0.9.0] - 2023-03-02

:dragon_face: Renamed the repository from `hydra-poc` to [`hydra`](https://github.com/input-output-hk/hydra)!
Expand Down
6 changes: 5 additions & 1 deletion docs/core-concepts/behavior.md
Expand Up @@ -18,4 +18,8 @@ A special case is the `RolledBack` output. This means that the chain rolled back

## Replay of past server outputs

When a `hydra-node` restarts, it will load it's history from persistence and replay previous server outputs to enable clients to re-establish their state upon re-connection. If that happens, obviously some of these outputs are not relevant anymore. One example of this is the `PeerConnected` and `PeerDisconnected`. To make it possible to determine the end of replayed history, client applications can use the `Greetings`, which will be emitted on every `hydra-node` start. See the `hydra-tui` example client for how this is handled.
When a `hydra-node` restarts, by default it will load it's history from persistence and replay previous server outputs to enable clients to re-establish their state upon re-connection. If that happens, obviously some of these outputs are not relevant anymore. One example of this is the `PeerConnected` and `PeerDisconnected`. To make it possible to determine the end of replayed history, client applications can use the `Greetings`, which will be emitted on every `hydra-node` start. See the `hydra-tui` example client for how this is handled.

Clients can optionally decide to skip history outputs and receive only the `Greetings` and following ones. In order to do that they can use query param `history=no`.

For example if the client wants to connect to a local `hydra-node` and doesn't want to view the server history but also want to have the transactions encoded as CBOR (base16) they would connect using default port `4001` and the full path `ws://localhost:4001/?history=no&tx-output=cbor`.
1 change: 1 addition & 0 deletions hydra-node/hydra-node.cabal
Expand Up @@ -152,6 +152,7 @@ library
, iohk-monitoring
, iproute
, memory
, modern-uri
, network
, network-mux
, optparse-applicative
Expand Down
33 changes: 29 additions & 4 deletions hydra-node/json-schemas/api.yaml
Expand Up @@ -6,7 +6,10 @@ info:
WebSocket API for administrating & interacting with Hydra Heads: multi-party isomorphic state-channels for Cardano.

Once started, a Hydra node provides an API in the forms of JSON messages over WebSocket. An Hydra node is an event-driven application where users (a.k.a you) are one possible source of inputs. Other sources can be mainly other Hydra nodes in the network, or transactions observed on the layer 1 (e.g. a closing transaction).
Therefore, once connected, clients receive a stream of outputs as they arrive. They can interact with their node by pushing events to it, some are local, and some will have consequences on the rest of the head.
Therefore, once connected, clients receive a stream of outputs as they arrive. Clients get to decide (using query parameters) if they want to observe the history of outputs and the transaction format.
For example if client provides a path that looks like this `/?history=no&tx-output=cbor` the server will not serve prior history of server outputs and the transaction fields in the json will be encoded as CBOR (base16 encoded).

They can interact with their node by pushing events to it, some are local, and some will have consequences on the rest of the head.
v0d1ch marked this conversation as resolved.
Show resolved Hide resolved
See [the documentation](https://hydra.family/head-protocol/core-concepts/behavior/) for more details on the overall API behavior.

> By default, a Hydra node listens for TCP WebSocket connections on port `tcp/4001` . This can be changed using `--port`.
Expand Down Expand Up @@ -35,6 +38,22 @@ channels:
description: Main (and sole) entry point for the Hydra service.
servers:
- localhost
bindings:
ws:
query:
history:
required: false
description: Specify weather the client wants to receive the full node history. Default is yes.
schema:
type: string
enum: ["yes", "no"]
tx-output:
description: Specify weather the client wants see transactions encoded as cbor. Default is json.
schema:
type: string
enum: ["json", "cbor"]


subscribe:
summary: Events emitted by the Hydra node.
operationId: serverOutput
Expand Down Expand Up @@ -493,7 +512,10 @@ components:
headId:
$ref: "#/components/schemas/HeadId"
transaction:
$ref: "#/components/schemas/Transaction"
description: Choose between output formats using the `tx-output` query parameter.
oneOf:
- $ref: "#/components/schemas/Transaction"
- $ref: "#/components/schemas/RawTransaction"
seq:
$ref: "#/components/schemas/SequenceNumber"
timestamp:
Expand Down Expand Up @@ -526,7 +548,10 @@ components:
utxo:
$ref: "#/components/schemas/UTxO"
transaction:
$ref: "#/components/schemas/Transaction"
description: Choose between output formats using the `tx-output` query parameter.
oneOf:
- $ref: "#/components/schemas/Transaction"
- $ref: "#/components/schemas/RawTransaction"
validationError:
type: object
properties:
Expand Down Expand Up @@ -1186,7 +1211,7 @@ components:
RawTransaction:
title: RawTransaction
description: |
A CBOR-serialised signed Alonzo transaction, encoded in base16.
A CBOR-serialised signed Cardano transaction, encoded in base16.
type: string
contentEncoding: base16

Expand Down
60 changes: 53 additions & 7 deletions hydra-node/src/Hydra/API/Server.hs
@@ -1,3 +1,4 @@
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UndecidableInstances #-}

Expand All @@ -18,13 +19,20 @@ import Control.Concurrent.STM.TVar (TVar, modifyTVar', newTVarIO, readTVar)
import Control.Exception (IOException)
import qualified Data.Aeson as Aeson
import Hydra.API.ClientInput (ClientInput)
import Hydra.API.ServerOutput (ServerOutput (Greetings, InvalidInput), TimedServerOutput (..))
import Hydra.API.ServerOutput (
OutputFormat (..),
ServerOutput (Greetings, InvalidInput),
TimedServerOutput (..),
prepareServerOutput,
)
import Hydra.Chain (IsChainState)
import Hydra.Logging (Tracer, traceWith)
import Hydra.Network (IP, PortNumber)
import Hydra.Party (Party)
import Hydra.Persistence (PersistenceIncremental (..))
import Network.WebSockets (
PendingConnection (pendingRequest),
RequestHead (..),
acceptRequest,
receiveData,
runServer,
Expand All @@ -33,6 +41,8 @@ import Network.WebSockets (
withPingThread,
)
import Test.QuickCheck (oneof)
import Text.URI
import Text.URI.QQ (queryKey, queryValue)

data APIServerLog
= APIServerStarted {listeningPort :: PortNumber}
Expand Down Expand Up @@ -86,7 +96,7 @@ withAPIServer host port party PersistenceIncremental{loadAll, append} tracer cal
-- client.
void $ appendToHistory history (Greetings party)
race_
(runAPIServer host port tracer history callback responseChannel)
(runAPIServer host port party tracer history callback responseChannel)
. action
$ Server
{ sendOutput = \output -> do
Expand Down Expand Up @@ -117,22 +127,56 @@ runAPIServer ::
(IsChainState tx) =>
IP ->
PortNumber ->
Party ->
Tracer IO APIServerLog ->
TVar [TimedServerOutput tx] ->
(ClientInput tx -> IO ()) ->
TChan (TimedServerOutput tx) ->
IO ()
runAPIServer host port tracer history callback responseChannel = do
runAPIServer host port party tracer history callback responseChannel = do
traceWith tracer (APIServerStarted port)
handle onIOException $
runServer (show host) (fromIntegral port) $ \pending -> do
let path = requestPath $ pendingRequest pending
queryParams <- uriQuery <$> mkURIBs path
con <- acceptRequest pending
chan <- STM.atomically $ dupTChan responseChannel
traceWith tracer NewAPIConnection
forwardHistory con

-- api client can decide if they want to see the past history of server outputs
if shouldNotServeHistory queryParams
then forwardGreetingOnly con
else forwardHistory con

-- api client can decide if they want tx's to be displayed as CBOR instead of plain json
let txDisplay = decideOnTxDisplay queryParams

withPingThread con 30 (pure ()) $
race_ (receiveInputs con) (sendOutputs chan con)
race_ (receiveInputs con) (sendOutputs chan con txDisplay)
where
forwardGreetingOnly con = do
time <- getCurrentTime
sendTextData con $
Aeson.encode
TimedServerOutput
{ time
, seq = 0
, output = Greetings party :: ServerOutput tx
}
decideOnTxDisplay qp =
let k = [queryKey|tx-output|]
v = [queryValue|cbor|]
queryP = QueryParam k v
in case queryP `elem` qp of
True -> OutputCBOR
False -> OutputJSON

shouldNotServeHistory qp =
flip any qp $ \case
(QueryParam key val)
| key == [queryKey|history|] -> val == [queryValue|no|]
_other -> False

onIOException ioException =
throwIO $
RunServerException
Expand All @@ -141,9 +185,11 @@ runAPIServer host port tracer history callback responseChannel = do
, port
}

sendOutputs chan con = forever $ do
sendOutputs chan con txDisplay = forever $ do
response <- STM.atomically $ readTChan chan
let sentResponse = Aeson.encode response
let sentResponse =
prepareServerOutput txDisplay response
v0d1ch marked this conversation as resolved.
Show resolved Hide resolved

sendTextData con sentResponse
traceWith tracer (APIOutputSent $ toJSON response)

Expand Down
60 changes: 59 additions & 1 deletion hydra-node/src/Hydra/API/ServerOutput.hs
Expand Up @@ -2,8 +2,11 @@

module Hydra.API.ServerOutput where

import Data.Aeson (Value (..), withObject, (.:))
import Cardano.Binary (serialize')
import Data.Aeson (Value (..), encode, withObject, (.:))
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Lazy as LBS
import Hydra.API.ClientInput (ClientInput (..))
import Hydra.Chain (ChainStateType, HeadId, IsChainState, PostChainTx, PostTxError)
import Hydra.Crypto (MultiSignature)
Expand Down Expand Up @@ -115,3 +118,58 @@ instance
Greetings me -> Greetings <$> shrink me
PostTxOnChainFailed p e -> PostTxOnChainFailed <$> shrink p <*> shrink e
RolledBack -> []

-- | Possible transaction formats in the api server output
data OutputFormat
= OutputCBOR
| OutputJSON
deriving (Eq, Show)

-- | Replaces the json encoded tx field with it's cbor representation.
-- NOTE: we deliberately pattern match on all 'ServerOutput' constructors
-- so that we don't forget to update this function if they change.
prepareServerOutput ::
IsChainState tx =>
-- | Decide on tx representation
OutputFormat ->
-- | Server output
TimedServerOutput tx ->
-- | Final output
LBS.ByteString
prepareServerOutput OutputJSON response = encode response
prepareServerOutput OutputCBOR response =
case output response of
PeerConnected{} -> encodedResponse
PeerDisconnected{} -> encodedResponse
HeadIsInitializing{} -> encodedResponse
Committed{} -> encodedResponse
HeadIsOpen{} -> encodedResponse
HeadIsClosed{} -> encodedResponse
HeadIsContested{} -> encodedResponse
ReadyToFanout{} -> encodedResponse
HeadIsAborted{} -> encodedResponse
HeadIsFinalized{} -> encodedResponse
CommandFailed{clientInput} ->
case clientInput of
Init -> encodedResponse
Abort -> encodedResponse
Commit{} -> encodedResponse
NewTx{Hydra.API.ClientInput.transaction = tx} -> replacedResponse tx
GetUTxO -> encodedResponse
Close -> encodedResponse
Contest -> encodedResponse
Fanout -> encodedResponse
TxValid{Hydra.API.ServerOutput.transaction = tx} -> replacedResponse tx
TxInvalid{Hydra.API.ServerOutput.transaction = tx} -> replacedResponse tx
SnapshotConfirmed{} -> encodedResponse
GetUTxOResponse{} -> encodedResponse
InvalidInput{} -> encodedResponse
Greetings{} -> encodedResponse
PostTxOnChainFailed{} -> encodedResponse
RolledBack -> encodedResponse
where
encodedResponse = encode response
replacedResponse tx =
case toJSON response of
Object km -> encode $ Object $ KeyMap.insert "transaction" (String . decodeUtf8 . Base16.encode $ serialize' tx) km
_other -> encodedResponse