diff --git a/examples/amm/src/service.rs b/examples/amm/src/service.rs index 1c32254cd983..8fabdcefe6ca 100644 --- a/examples/amm/src/service.rs +++ b/examples/amm/src/service.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use amm::{Operation, Parameters}; use async_graphql::{EmptySubscription, Request, Response, Schema}; use linera_sdk::{ - graphql::GraphQLMutationRoot as _, linera_base_types::WithServiceAbi, views::View, Service, + graphql::GraphQLMutationRoot, linera_base_types::WithServiceAbi, views::View, Service, ServiceRuntime, }; @@ -41,12 +41,46 @@ impl Service for AmmService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl AmmService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = AmmState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = AmmService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); } } diff --git a/examples/amm/src/snapshots/amm_service__tests__schema_sdl.snap b/examples/amm/src/snapshots/amm_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..75dd5154a773 --- /dev/null +++ b/examples/amm/src/snapshots/amm_service__tests__schema_sdl.snap @@ -0,0 +1,95 @@ +--- +source: amm/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +input Account { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +An account. +""" +type AccountOutput { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +type AmmState { + shares: MapView_AccountOutput_Amount_d6d2d63e! + totalSharesSupply: Amount! +} + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_AccountOutput_Amount_eed88548 { + key: AccountOutput! + value: Amount +} + +input MapFilters_Account_b5463aa1 { + keys: [Account!] +} + +input MapInput_Account_b5463aa1 { + filters: MapFilters_Account_b5463aa1 +} + +type MapView_AccountOutput_Amount_d6d2d63e { + keys(count: Int): [AccountOutput!]! + count: Int! + entry(key: Account!): Entry_AccountOutput_Amount_eed88548! + entries(input: MapInput_Account_b5463aa1): [Entry_AccountOutput_Amount_eed88548!]! +} + +type OperationMutationRoot { + swap(owner: AccountOwner!, inputTokenIdx: Int!, inputAmount: Amount!): [Int!]! + addLiquidity(owner: AccountOwner!, maxToken0Amount: Amount!, maxToken1Amount: Amount!): [Int!]! + removeLiquidity(owner: AccountOwner!, tokenToRemoveIdx: Int!, tokenToRemoveAmount: Amount!): [Int!]! + removeAllAddedLiquidity(owner: AccountOwner!): [Int!]! + closeChain: [Int!]! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: AmmState + mutation: OperationMutationRoot +} diff --git a/examples/controller/src/service.rs b/examples/controller/src/service.rs index 1805b2292680..47ec6833a993 100644 --- a/examples/controller/src/service.rs +++ b/examples/controller/src/service.rs @@ -43,6 +43,19 @@ impl Service for ControllerService { } async fn handle_query(&self, query: Self::Query) -> Self::QueryResponse { + self.schema().execute(query).await + } +} + +impl ControllerService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), @@ -50,8 +63,6 @@ impl Service for ControllerService { ) .data(self.runtime.clone()) .finish() - .execute(query) - .await } } @@ -163,6 +174,14 @@ mod tests { } } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn schema_sdl() { + let service = create_service(); + + insta::assert_snapshot!(service.schema().sdl()); + } + #[test] fn query_local_worker_state_empty() { let service = create_service(); diff --git a/examples/controller/src/snapshots/controller_service__tests__schema_sdl.snap b/examples/controller/src/snapshots/controller_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..19d6403bb623 --- /dev/null +++ b/examples/controller/src/snapshots/controller_service__tests__schema_sdl.snap @@ -0,0 +1,308 @@ +--- +source: controller/src/service.rs +expression: service.schema().sdl() +--- +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A blanket policy to apply to all messages by default. +""" +enum BlanketMessagePolicy { + """ + Automatically accept all incoming messages. Reject them only if execution fails. + """ + ACCEPT + """ + Automatically reject tracked messages, ignore or skip untracked messages, but accept + protected ones. + """ + REJECT + """ + Don't include any messages in blocks, and don't make any decision whether to accept or + reject. + """ + IGNORE +} + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +scalar ControllerCommand + +""" +The state of the service controller application. +""" +type ControllerState { + """ + The description of this worker as we registered it. + """ + localWorker: Worker + """ + The services currently running locally. + """ + localServices: SetView_DataBlobHash_922740db! + """ + The services we were told to run locally, but we're still waiting to for the + block heights at which we're supposed to start running them. + """ + localPendingServices: MapView_DataBlobHash_PendingService_f85f22f6! + """ + The chains currently followed locally (besides ours and the active service + chains). + """ + localChains: SetView_ChainId_37f83aa9! + """ + The local message policy. + """ + localMessagePolicy: MapView_ChainId_MessagePolicy_b348340f! + """ + The admin account owners (user or application) allowed to update services. + """ + admins: [AccountOwner!] + """ + All the workers declared in the network. + """ + workers: MapView_ChainId_Worker_b98a7459! + """ + All the services currently defined and where they run. If services run on several + workers, the chain is configured so that workers can collaborate to produce blocks + (e.g. only one worker is actively producing blocks while the other waits as a + backup). + """ + services: MapView_DataBlobHash_ChainId_Array_0db9f691! + """ + Services that have been reassigned to a worker, but are awaiting confirmation that + the previous worker has added the new one as an owner. + """ + pendingServices: MapView_DataBlobHash_ServicePendingHandoff_fec9d6cb! + """ + All the chains currently being followed and by which workers. + """ + chains: MapView_ChainId_ChainId_Array_54eacc14! + """ + Retrieve information on a given service. + """ + readService(serviceId: DataBlobHash!): ManagedService + """ + Retrieve the local worker state. + """ + localWorkerState: LocalWorkerState! +} + +""" +Hash of a Data Blob +""" +scalar DataBlobHash + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_ChainId_ChainId_Array_1b667611 { + key: ChainId! + value: [ChainId!] +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_ChainId_MessagePolicy_7c54cb51 { + key: ChainId! + value: MessagePolicy +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_ChainId_Worker_e8bb0e24 { + key: ChainId! + value: Worker +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_DataBlobHash_ChainId_Array_8f84eafa { + key: DataBlobHash! + value: [ChainId!] +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_DataBlobHash_PendingService_9cb7eaf0 { + key: DataBlobHash! + value: PendingService +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_DataBlobHash_ServicePendingHandoff_97c325a6 { + key: DataBlobHash! + value: ServicePendingHandoff +} + +""" +A unique identifier for a user application or for the system application +""" +scalar GenericApplicationId + +scalar LocalWorkerState + +scalar ManagedService + +input MapFilters_ChainId_37f83aa9 { + keys: [ChainId!] +} + +input MapFilters_DataBlobHash_922740db { + keys: [DataBlobHash!] +} + +input MapInput_ChainId_37f83aa9 { + filters: MapFilters_ChainId_37f83aa9 +} + +input MapInput_DataBlobHash_922740db { + filters: MapFilters_DataBlobHash_922740db +} + +type MapView_ChainId_ChainId_Array_54eacc14 { + keys(count: Int): [ChainId!]! + count: Int! + entry(key: ChainId!): Entry_ChainId_ChainId_Array_1b667611! + entries(input: MapInput_ChainId_37f83aa9): [Entry_ChainId_ChainId_Array_1b667611!]! +} + +type MapView_ChainId_MessagePolicy_b348340f { + keys(count: Int): [ChainId!]! + count: Int! + entry(key: ChainId!): Entry_ChainId_MessagePolicy_7c54cb51! + entries(input: MapInput_ChainId_37f83aa9): [Entry_ChainId_MessagePolicy_7c54cb51!]! +} + +type MapView_ChainId_Worker_b98a7459 { + keys(count: Int): [ChainId!]! + count: Int! + entry(key: ChainId!): Entry_ChainId_Worker_e8bb0e24! + entries(input: MapInput_ChainId_37f83aa9): [Entry_ChainId_Worker_e8bb0e24!]! +} + +type MapView_DataBlobHash_ChainId_Array_0db9f691 { + keys(count: Int): [DataBlobHash!]! + count: Int! + entry(key: DataBlobHash!): Entry_DataBlobHash_ChainId_Array_8f84eafa! + entries(input: MapInput_DataBlobHash_922740db): [Entry_DataBlobHash_ChainId_Array_8f84eafa!]! +} + +type MapView_DataBlobHash_PendingService_f85f22f6 { + keys(count: Int): [DataBlobHash!]! + count: Int! + entry(key: DataBlobHash!): Entry_DataBlobHash_PendingService_9cb7eaf0! + entries(input: MapInput_DataBlobHash_922740db): [Entry_DataBlobHash_PendingService_9cb7eaf0!]! +} + +type MapView_DataBlobHash_ServicePendingHandoff_fec9d6cb { + keys(count: Int): [DataBlobHash!]! + count: Int! + entry(key: DataBlobHash!): Entry_DataBlobHash_ServicePendingHandoff_97c325a6! + entries(input: MapInput_DataBlobHash_922740db): [Entry_DataBlobHash_ServicePendingHandoff_97c325a6!]! +} + +""" +Policies for automatically handling incoming messages. +""" +type MessagePolicy { + """ + The blanket policy applied to all messages. + """ + blanket: BlanketMessagePolicy! + """ + A collection of chains which restrict the origin of messages to be + accepted. `Option::None` means that messages from all chains are accepted. An empty + `HashSet` denotes that messages from no chains are accepted. + """ + restrictChainIdsTo: [ChainId!] + """ + A collection of chains whose incoming messages should be ignored. + """ + ignoreChainIds: [ChainId!]! + """ + A collection of applications: If `Some`, only bundles with at least one message by any + of these applications will be accepted. + """ + rejectMessageBundlesWithoutApplicationIds: [GenericApplicationId!] + """ + A collection of applications: If `Some`, only bundles all of whose messages are by these + applications will be accepted. + """ + rejectMessageBundlesWithOtherApplicationIds: [GenericApplicationId!] + """ + A collection of applications: If `Some`, only event streams from those + applications will be processed. + """ + processEventsFromApplicationIds: [GenericApplicationId!] + """ + A collection of applications whose messages must never be rejected. Bundles whose + messages are all from one of these applications bypass the other rejection rules + (except `restrict_chain_ids_to`), and on execution failure they are discarded for + later retry instead of being rejected. A bundle that contains any message from an + application not on this list can be rejected. An empty set disables this feature. + """ + neverRejectApplicationIds: [GenericApplicationId!]! +} + +type OperationMutationRoot { + executeWorkerCommand(owner: AccountOwner!, command: WorkerCommand!): [Int!]! + executeControllerCommand(admin: AccountOwner!, command: ControllerCommand!): [Int!]! + startLocalService(serviceId: DataBlobHash!): [Int!]! +} + +scalar PendingService + +scalar ServicePendingHandoff + +type SetView_ChainId_37f83aa9 { + elements(count: Int): [ChainId!]! + count: Int! +} + +type SetView_DataBlobHash_922740db { + elements(count: Int): [DataBlobHash!]! + count: Int! +} + +""" +The description of a service worker. +""" +type Worker { + """ + The address used by the worker. + """ + owner: AccountOwner! + """ + Some tags denoting the capabilities of this worker. Each capability has a value + that the worker will read from its local environment and pass to the applications. + """ + capabilities: [String!]! +} + +scalar WorkerCommand + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: ControllerState + mutation: OperationMutationRoot +} diff --git a/examples/counter/src/service.rs b/examples/counter/src/service.rs index a3fca481fb9e..37e374177104 100644 --- a/examples/counter/src/service.rs +++ b/examples/counter/src/service.rs @@ -38,15 +38,21 @@ impl Service for CounterService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl CounterService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema(&self) -> Schema, MutationRoot, EmptySubscription> { + Schema::build( self.state.clone(), MutationRoot { runtime: self.runtime.clone(), }, EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -98,4 +104,20 @@ mod tests { assert_eq!(response, expected) } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn schema_sdl() { + let runtime = Arc::new(ServiceRuntime::::new()); + let state = CounterState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = CounterService { + state: Arc::new(state), + runtime, + }; + + insta::assert_snapshot!(service.schema().sdl()); + } } diff --git a/examples/counter/src/snapshots/counter_service__tests__schema_sdl.snap b/examples/counter/src/snapshots/counter_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..6937b88aa108 --- /dev/null +++ b/examples/counter/src/snapshots/counter_service__tests__schema_sdl.snap @@ -0,0 +1,27 @@ +--- +source: counter/src/service.rs +expression: service.schema().sdl() +--- +""" +The application state. +""" +type CounterState { + value: Int! +} + +type MutationRoot { + increment(value: Int!): [Int!]! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: CounterState + mutation: MutationRoot +} diff --git a/examples/crowd-funding/src/service.rs b/examples/crowd-funding/src/service.rs index e2dcdd59216f..fc7ea489fe6b 100644 --- a/examples/crowd-funding/src/service.rs +++ b/examples/crowd-funding/src/service.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use async_graphql::{EmptySubscription, Request, Response, Schema}; use crowd_funding::Operation; use linera_sdk::{ - graphql::GraphQLMutationRoot as _, + graphql::GraphQLMutationRoot, linera_base_types::{ApplicationId, WithServiceAbi}, views::View, Service, ServiceRuntime, @@ -42,12 +42,46 @@ impl Service for CrowdFundingService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl CrowdFundingService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = CrowdFundingState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = CrowdFundingService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); } } diff --git a/examples/crowd-funding/src/snapshots/crowd_funding_service__tests__schema_sdl.snap b/examples/crowd-funding/src/snapshots/crowd_funding_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..4f0ba1cc6a38 --- /dev/null +++ b/examples/crowd-funding/src/snapshots/crowd_funding_service__tests__schema_sdl.snap @@ -0,0 +1,98 @@ +--- +source: crowd-funding/src/service.rs +expression: service.schema().sdl() +--- +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +The crowd-funding campaign's state. +""" +type CrowdFundingState { + """ + The status of the campaign. + """ + status: Status! + """ + The map of pledges that will be collected if the campaign succeeds. + """ + pledges: MapView_AccountOwner_Amount_11ef1379! + """ + The instantiation data that determines the details of the campaign. + """ + instantiationArgument: InstantiationArgument +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_AccountOwner_Amount_aaf96548 { + key: AccountOwner! + value: Amount +} + +""" +The instantiation data required to create a crowd-funding campaign. +""" +type InstantiationArgument { + """ + The receiver of the pledges of a successful campaign. + """ + owner: AccountOwner! + """ + The deadline of the campaign, after which it can be cancelled if it hasn't met its target. + """ + deadline: Timestamp! + """ + The funding target of the campaign. + """ + target: Amount! +} + +input MapFilters_AccountOwner_d6668c53 { + keys: [AccountOwner!] +} + +input MapInput_AccountOwner_d6668c53 { + filters: MapFilters_AccountOwner_d6668c53 +} + +type MapView_AccountOwner_Amount_11ef1379 { + keys(count: Int): [AccountOwner!]! + count: Int! + entry(key: AccountOwner!): Entry_AccountOwner_Amount_aaf96548! + entries(input: MapInput_AccountOwner_d6668c53): [Entry_AccountOwner_Amount_aaf96548!]! +} + +type OperationMutationRoot { + pledge(owner: AccountOwner!, amount: Amount!): [Int!]! + collect: [Int!]! + cancel: [Int!]! +} + +scalar Status + +""" +A timestamp, in microseconds since the Unix epoch +""" +scalar Timestamp + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: CrowdFundingState + mutation: OperationMutationRoot +} diff --git a/examples/ethereum-tracker/src/service.rs b/examples/ethereum-tracker/src/service.rs index 65b1086293a6..1d22be9b521c 100644 --- a/examples/ethereum-tracker/src/service.rs +++ b/examples/ethereum-tracker/src/service.rs @@ -12,7 +12,7 @@ use async_graphql::{EmptySubscription, Request, Response, Schema}; use ethereum_tracker::Operation; use linera_sdk::{ ethereum::{EthereumDataType, EthereumEvent, EthereumQueries, ServiceEthereumClient}, - graphql::GraphQLMutationRoot as _, + graphql::GraphQLMutationRoot, linera_base_types::WithServiceAbi, views::View, Service, ServiceRuntime, @@ -49,15 +49,27 @@ impl Service for EthereumTrackerService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl EthereumTrackerService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Query, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( Query { service: self.clone(), }, Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -179,3 +191,25 @@ pub struct TransferEvent { destination: String, } async_graphql::scalar!(TransferEvent); + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = EthereumTrackerState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = EthereumTrackerService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/ethereum-tracker/src/snapshots/ethereum_tracker_service__tests__schema_sdl.snap b/examples/ethereum-tracker/src/snapshots/ethereum_tracker_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..e3b5a2fa5220 --- /dev/null +++ b/examples/ethereum-tracker/src/snapshots/ethereum_tracker_service__tests__schema_sdl.snap @@ -0,0 +1,67 @@ +--- +source: ethereum-tracker/src/service.rs +expression: service.schema().sdl() +--- +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_String_U256Cont_546704a7 { + key: String! + value: U256Cont +} + +scalar InitialEvent + +input MapFilters_String_72fa253a { + keys: [String!] +} + +input MapInput_String_72fa253a { + filters: MapFilters_String_72fa253a +} + +type MapView_String_U256Cont_8634ebb6 { + keys(count: Int): [String!]! + count: Int! + entry(key: String!): Entry_String_U256Cont_546704a7! + entries(input: MapInput_String_72fa253a): [Entry_String_U256Cont_546704a7!]! +} + +type OperationMutationRoot { + update(toBlock: Int!): [Int!]! +} + +""" +The service handler for GraphQL queries. +""" +type Query { + ethereumEndpoint: String! + contractAddress: String! + startBlock: Int! + accounts: MapView_String_U256Cont_8634ebb6! + """ + Reads the initial Ethereum event emitted by the monitored contract. + """ + readInitialEvent: InitialEvent! + """ + Reads the transfer events emitted by the monitored Ethereum contract. + """ + readTransferEvents(endBlock: Int!): [TransferEvent!]! +} + +scalar TransferEvent + +scalar U256Cont + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: Query + mutation: OperationMutationRoot +} diff --git a/examples/fungible/src/service.rs b/examples/fungible/src/service.rs index ccdea5b7a2b2..1b8ebac4f460 100644 --- a/examples/fungible/src/service.rs +++ b/examples/fungible/src/service.rs @@ -9,7 +9,7 @@ use async_graphql::{EmptySubscription, Object, Request, Response, Schema}; use fungible::{state::FungibleTokenState, Parameters}; use linera_sdk::{ abis::fungible::FungibleOperation, - graphql::GraphQLMutationRoot as _, + graphql::GraphQLMutationRoot, linera_base_types::{AccountOwner, Amount, OwnerSpender, WithServiceAbi}, views::{MapView, View}, Service, ServiceRuntime, @@ -41,13 +41,25 @@ impl Service for FungibleTokenService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl FungibleTokenService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Self, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.clone(), FungibleOperation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -65,3 +77,25 @@ impl FungibleTokenService { Ok(self.runtime.application_parameters().ticker_symbol) } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = FungibleTokenState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = FungibleTokenService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/fungible/src/snapshots/fungible_service__tests__schema_sdl.snap b/examples/fungible/src/snapshots/fungible_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..707d9fd39f19 --- /dev/null +++ b/examples/fungible/src/snapshots/fungible_service__tests__schema_sdl.snap @@ -0,0 +1,111 @@ +--- +source: fungible/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +input Account { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_AccountOwner_Amount_aaf96548 { + key: AccountOwner! + value: Amount +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_OwnerSpender_Amount_9f866202 { + key: OwnerSpender! + value: Amount +} + +type FungibleOperationMutationRoot { + balance(owner: AccountOwner!): [Int!]! + tickerSymbol: [Int!]! + approve(owner: AccountOwner!, spender: AccountOwner!, allowance: Amount!): [Int!]! + transfer(owner: AccountOwner!, amount: Amount!, targetAccount: Account!): [Int!]! + transferFrom(owner: AccountOwner!, spender: AccountOwner!, amount: Amount!, targetAccount: Account!): [Int!]! + claim(sourceAccount: Account!, amount: Amount!, targetAccount: Account!): [Int!]! +} + +type FungibleTokenService { + accounts: MapView_AccountOwner_Amount_11ef1379! + allowances: MapView_OwnerSpender_Amount_4c9e3936! + tickerSymbol: String! +} + +input MapFilters_AccountOwner_d6668c53 { + keys: [AccountOwner!] +} + +input MapFilters_OwnerSpender_6e975ca8 { + keys: [OwnerSpender!] +} + +input MapInput_AccountOwner_d6668c53 { + filters: MapFilters_AccountOwner_d6668c53 +} + +input MapInput_OwnerSpender_6e975ca8 { + filters: MapFilters_OwnerSpender_6e975ca8 +} + +type MapView_AccountOwner_Amount_11ef1379 { + keys(count: Int): [AccountOwner!]! + count: Int! + entry(key: AccountOwner!): Entry_AccountOwner_Amount_aaf96548! + entries(input: MapInput_AccountOwner_d6668c53): [Entry_AccountOwner_Amount_aaf96548!]! +} + +type MapView_OwnerSpender_Amount_4c9e3936 { + keys(count: Int): [OwnerSpender!]! + count: Int! + entry(key: OwnerSpender!): Entry_OwnerSpender_Amount_9f866202! + entries(input: MapInput_OwnerSpender_6e975ca8): [Entry_OwnerSpender_Amount_9f866202!]! +} + +""" +A pair of owner and spender accounts for managing allowances +""" +scalar OwnerSpender + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: FungibleTokenService + mutation: FungibleOperationMutationRoot +} diff --git a/examples/gen-nft/src/service.rs b/examples/gen-nft/src/service.rs index fed134b2dfce..34afee71f8c3 100644 --- a/examples/gen-nft/src/service.rs +++ b/examples/gen-nft/src/service.rs @@ -52,8 +52,14 @@ impl Service for GenNftService { } async fn handle_query(&self, request: Request) -> Response { - let runtime = self.runtime.clone(); - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl GenNftService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema(&self) -> Schema { + Schema::build( QueryRoot { non_fungible_token: self.state.clone(), }, @@ -62,9 +68,8 @@ impl Service for GenNftService { }, EmptySubscription, ) - .data(runtime) - .finish(); - schema.execute(request).await + .data(self.runtime.clone()) + .finish() } } @@ -226,3 +231,25 @@ impl MutationRoot { [] } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = GenNftState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = GenNftService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/gen-nft/src/snapshots/gen_nft_service__tests__schema_sdl.snap b/examples/gen-nft/src/snapshots/gen_nft_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..57472e670eba --- /dev/null +++ b/examples/gen-nft/src/snapshots/gen_nft_service__tests__schema_sdl.snap @@ -0,0 +1,67 @@ +--- +source: gen-nft/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +input Account { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A scalar that can represent any JSON Object value. +""" +scalar JSONObject + +type MutationRoot { + mint(minter: AccountOwner!, prompt: String!): [Int!]! + transfer(sourceOwner: AccountOwner!, tokenId: String!, targetAccount: Account!): [Int!]! + claim(sourceAccount: Account!, tokenId: String!, targetAccount: Account!): [Int!]! +} + +type NftOutput { + tokenId: String! + owner: AccountOwner! + prompt: String! + minter: AccountOwner! +} + +type QueryRoot { + nft(tokenId: String!): NftOutput + nfts: JSONObject! + ownedTokenIdsByOwner(owner: AccountOwner!): [String!]! + ownedTokenIds: JSONObject! + ownedNfts(owner: AccountOwner!): JSONObject! + prompt(prompt: String!): String! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: QueryRoot + mutation: MutationRoot +} diff --git a/examples/hex-game/src/service.rs b/examples/hex-game/src/service.rs index c3345a203711..526ca3570149 100644 --- a/examples/hex-game/src/service.rs +++ b/examples/hex-game/src/service.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use async_graphql::{ComplexObject, Context, EmptySubscription, Request, Response, Schema}; use hex_game::{Operation, Player}; use linera_sdk::{ - graphql::GraphQLMutationRoot as _, linera_base_types::WithServiceAbi, views::View, Service, + graphql::GraphQLMutationRoot, linera_base_types::WithServiceAbi, views::View, Service, ServiceRuntime, }; @@ -42,14 +42,26 @@ impl Service for HexService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl HexService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) .data(self.runtime.clone()) - .finish(); - schema.execute(request).await + .finish() } } @@ -99,4 +111,20 @@ mod tests { assert_eq!(response, json!({"clock" : {"increment": 0}})) } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = HexState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = HexService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } } diff --git a/examples/hex-game/src/snapshots/hex_game_service__tests__schema_sdl.snap b/examples/hex-game/src/snapshots/hex_game_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..555d1d85ebe8 --- /dev/null +++ b/examples/hex-game/src/snapshots/hex_game_service__tests__schema_sdl.snap @@ -0,0 +1,213 @@ +--- +source: hex-game/src/service.rs +expression: service.schema().sdl() +--- +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +The state of a Hex game. +""" +type Board { + """ + The cells, row-by-row. + + Cell `(x, y)` has index `(x + y * size)`. + """ + cells: [Cell!]! + """ + The width and height of the board, in cells. + """ + size: Int! + """ + The player whose turn it is. If the game has ended, this player loses. + """ + active: Player! +} + +""" +The state of a cell on the board. +""" +type Cell { + """ + `None` if the cell is empty; otherwise the player who placed a stone here. + """ + stone: Player + """ + This is `true` if the cell belongs to player `One` and is connected to the left edge + of the board via other cells containing a stone placed by player `One`, or if it + belongs to player `Two` and is connected to the top edge of the board via other cells + containing stones placed by player `Two`. + + So the game ends if this is `true` for any cell at the right or bottom edge. + """ + connected: Boolean! +} + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A clock to track both players' time. +""" +type Clock { + timeLeft: [TimeDelta!]! + increment: TimeDelta! + currentTurnStart: Timestamp! + blockDelay: TimeDelta! +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_AccountOwner_GameChain_Array_5bcd324f { + key: AccountOwner! + value: [GameChain!] +} + +""" +The IDs of a temporary chain for a single game of Hex. +""" +type GameChain { + """ + The ID of the temporary game chain itself. + """ + chainId: ChainId! +} + +""" +The application state. +""" +type HexState { + """ + The `AccountOwner`s controlling players `One` and `Two`. + """ + owners: [AccountOwner!] + """ + The current game state. + """ + board: Board! + """ + The game clock. + """ + clock: Clock! + """ + The timeouts. + """ + timeouts: Timeouts! + """ + Temporary chains for individual games, by player. + """ + gameChains: MapView_AccountOwner_GameChain_Array_9536f240! + winner: Player +} + +input MapFilters_AccountOwner_d6668c53 { + keys: [AccountOwner!] +} + +input MapInput_AccountOwner_d6668c53 { + filters: MapFilters_AccountOwner_d6668c53 +} + +type MapView_AccountOwner_GameChain_Array_9536f240 { + keys(count: Int): [AccountOwner!]! + count: Int! + entry(key: AccountOwner!): Entry_AccountOwner_GameChain_Array_5bcd324f! + entries(input: MapInput_AccountOwner_d6668c53): [Entry_AccountOwner_GameChain_Array_5bcd324f!]! +} + +type OperationMutationRoot { + makeMove(x: Int!, y: Int!): [Int!]! + claimVictory: [Int!]! + start(players: [AccountOwner!]!, boardSize: Int!, feeBudget: Amount!, timeouts: TimeoutsInput): [Int!]! +} + +""" +A player: `One` or `Two` + +It's player `One`'s turn whenever the number of stones on the board is even, so they make +the first move. Otherwise it's `Two`'s turn. +""" +enum Player { + """ + Player one + """ + ONE + """ + Player two + """ + TWO +} + +""" +A duration in microseconds +""" +scalar TimeDelta + +""" +Settings that determine how much time the players have to think about their turns. +""" +type Timeouts { + """ + The initial time each player has to think about their turns. + """ + startTime: TimeDelta! + """ + The duration that is added to the clock after each turn. + """ + increment: TimeDelta! + """ + The maximum time that is allowed to pass between a block proposal and validation. + This should be long enough to confirm a block, but short enough for the block timestamp + to accurately reflect the current time. + """ + blockDelay: TimeDelta! +} + +""" +Settings that determine how much time the players have to think about their turns. +""" +input TimeoutsInput { + """ + The initial time each player has to think about their turns. + """ + startTime: TimeDelta! + """ + The duration that is added to the clock after each turn. + """ + increment: TimeDelta! + """ + The maximum time that is allowed to pass between a block proposal and validation. + This should be long enough to confirm a block, but short enough for the block timestamp + to accurately reflect the current time. + """ + blockDelay: TimeDelta! +} + +""" +A timestamp, in microseconds since the Unix epoch +""" +scalar Timestamp + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: HexState + mutation: OperationMutationRoot +} diff --git a/examples/llm/src/service.rs b/examples/llm/src/service.rs index 274b8bb9d66e..53c27df34bf1 100644 --- a/examples/llm/src/service.rs +++ b/examples/llm/src/service.rs @@ -243,3 +243,20 @@ impl ModelContext { Ok(output) } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use async_graphql::{EmptyMutation, EmptySubscription, Schema}; + + use super::*; + + #[test] + fn schema_sdl() { + // The schema is built without a service instance: constructing `LlmService` + // requires a `model_context` loaded from downloaded model weights, which is + // infeasible in a unit test. The `.data(model_context)` from `handle_query` + // does not affect the generated SDL, so it is omitted here. + let schema = Schema::build(QueryRoot {}, EmptyMutation, EmptySubscription).finish(); + insta::assert_snapshot!(schema.sdl()); + } +} diff --git a/examples/llm/src/snapshots/llm_service__tests__schema_sdl.snap b/examples/llm/src/snapshots/llm_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..3f686e2107b5 --- /dev/null +++ b/examples/llm/src/snapshots/llm_service__tests__schema_sdl.snap @@ -0,0 +1,19 @@ +--- +source: llm/src/service.rs +expression: schema.sdl() +--- +type QueryRoot { + prompt(prompt: String!): String! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: QueryRoot +} diff --git a/examples/matching-engine/src/service.rs b/examples/matching-engine/src/service.rs index 9250f893ccf7..6b020b9da743 100644 --- a/examples/matching-engine/src/service.rs +++ b/examples/matching-engine/src/service.rs @@ -49,12 +49,59 @@ impl Service for MatchingEngineService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl MatchingEngineService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{ + linera_base_types::ApplicationId, util::BlockingWait, views::View, ServiceRuntime, + }; + use matching_engine::Parameters; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let token = ApplicationId::default().with_abi(); + let parameters = Parameters { + tokens: [token; 2], + price_decimals: 0, + }; + let context = linera_views::context::ViewContext::new_unchecked( + runtime.key_value_store(), + Vec::new(), + parameters, + ); + let state = MatchingEngineState::load(context) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = MatchingEngineService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); } } diff --git a/examples/matching-engine/src/snapshots/matching_engine_service__tests__schema_sdl.snap b/examples/matching-engine/src/snapshots/matching_engine_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..25014a99b97e --- /dev/null +++ b/examples/matching-engine/src/snapshots/matching_engine_service__tests__schema_sdl.snap @@ -0,0 +1,296 @@ +--- +source: matching-engine/src/service.rs +expression: service.schema().sdl() +--- +""" +The AccountInfo used for storing which order_id are owned by +each owner. +""" +type AccountInfo { + """ + The list of orders + """ + orders: [Int!]! +} + +""" +An account. +""" +type AccountOutput { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +type CustomCollectionView_PriceAskInput_LevelView_d5795fd8 { + keys: [PriceAsk!]! + count: Int! + entry(key: PriceAskInput!): Entry_PriceAsk_LevelView_09694864! + entries(input: MapInput_PriceAskInput_e9b05f5a): [Entry_PriceAsk_LevelView_09694864!]! +} + +type CustomCollectionView_PriceBidInput_LevelView_f08dcf6c { + keys: [PriceBid!]! + count: Int! + entry(key: PriceBidInput!): Entry_PriceBid_LevelView_0b1ad553! + entries(input: MapInput_PriceBidInput_3851a0e0): [Entry_PriceBid_LevelView_0b1ad553!]! +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_AccountOwner_AccountInfo_9d668534 { + key: AccountOwner! + value: AccountInfo +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_Int_KeyBook_db35ce20 { + key: Int! + value: KeyBook +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_PriceAsk_LevelView_09694864 { + key: PriceAsk! + value: LevelView! +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_PriceBid_LevelView_0b1ad553 { + key: PriceBid! + value: LevelView! +} + +""" +This is the entry present in the state so that we can access +information from the order_id. +""" +type KeyBook { + """ + The corresponding price + """ + price: Price! + """ + The nature of the order + """ + nature: OrderNature! + """ + The owner used for checks + """ + account: AccountOutput! +} + +""" +The price level is contained in a QueueView +The queue starts with the oldest order to the newest. +When an order is cancelled it is zero. But if that +cancelled order is not the oldest, then it remains +though with a size zero. +""" +type LevelView { + queue: QueueView_OrderEntry_37aee5f5! +} + +input MapFilters_AccountOwner_d6668c53 { + keys: [AccountOwner!] +} + +input MapFilters_Int_5242398a { + keys: [Int!] +} + +input MapFilters_PriceAskInput_e9b05f5a { + keys: [PriceAskInput!] +} + +input MapFilters_PriceBidInput_3851a0e0 { + keys: [PriceBidInput!] +} + +input MapInput_AccountOwner_d6668c53 { + filters: MapFilters_AccountOwner_d6668c53 +} + +input MapInput_Int_5242398a { + filters: MapFilters_Int_5242398a +} + +input MapInput_PriceAskInput_e9b05f5a { + filters: MapFilters_PriceAskInput_e9b05f5a +} + +input MapInput_PriceBidInput_3851a0e0 { + filters: MapFilters_PriceBidInput_3851a0e0 +} + +type MapView_AccountOwner_AccountInfo_6b1a2835 { + keys(count: Int): [AccountOwner!]! + count: Int! + entry(key: AccountOwner!): Entry_AccountOwner_AccountInfo_9d668534! + entries(input: MapInput_AccountOwner_d6668c53): [Entry_AccountOwner_AccountInfo_9d668534!]! +} + +type MapView_Int_KeyBook_42dd8508 { + keys(count: Int): [Int!]! + count: Int! + entry(key: Int!): Entry_Int_KeyBook_db35ce20! + entries(input: MapInput_Int_5242398a): [Entry_Int_KeyBook_db35ce20!]! +} + +""" +The matching engine containing the information. +""" +type MatchingEngineState { + """ + The next order_id to be used. + """ + nextOrderId: Int! + """ + The map of the outstanding bids, by the bitwise complement of + the revert of the price. The order is from the best price + level (highest proposed by buyer) to the worst + """ + bids: CustomCollectionView_PriceBidInput_LevelView_f08dcf6c! + """ + The map of the outstanding asks, by the bitwise complement of + the price. The order is from the best one (smallest asked price + by seller) to the worst. + """ + asks: CustomCollectionView_PriceAskInput_LevelView_d5795fd8! + """ + The map with the list of orders giving for each order_id the + fundamental information on the order (price, nature, account) + """ + orders: MapView_Int_KeyBook_42dd8508! + """ + The map giving for each account owner the set of order_id + owned by that owner. + """ + accountInfo: MapView_AccountOwner_AccountInfo_6b1a2835! +} + +type OperationMutationRoot { + executeOrder(order: Order!): [Int!]! + closeChain: [Int!]! +} + +scalar Order + +""" +The order entry in the order book +""" +type OrderEntry { + """ + The number of token1 being bought or sold + """ + quantity: Amount! + """ + The one who has created the order + """ + account: AccountOutput! + """ + The order_id (needed for possible cancel or modification) + """ + orderId: Int! +} + +scalar OrderNature + +""" +The asking or bidding price of token 1 in units of token 0. + +Forgetting about types and units, if `account` is buying `quantity` for a `price` value: +```ignore +account[0] -= price * quantity * 10^-price_decimals; +account[1] += quantity; +``` +where `price_decimals` is a parameter set when the market is created. + +The `quantity` (also called _count_) is of type `Amount` as well as the balance of the +accounts. Therefore, the number of decimals used by quantities in a valid order must +not exceed `Amount::DECIMAL_PLACES - price_decimals`. + +When we have ask > bid then the winner for the residual cash is the liquidity provider. +We choose to force the price to be an integer u64. This is because the tokens are undivisible. +In practice, this means that the value of token1 has to be much higher than the price of token0 +just as in a normal market where the price is in multiple of cents. +""" +type Price { + """ + A price expressed as a multiple of 10^-price_decimals increments. + """ + price: Int! +} + +type PriceAsk { + """ + A price expressed as a multiple of 10^-price_decimals increments. + """ + price: Int! +} + +input PriceAskInput { + """ + A price expressed as a multiple of 10^-price_decimals increments. + """ + price: Int! +} + +type PriceBid { + """ + A price expressed as a multiple of 10^-price_decimals increments. + """ + price: Int! +} + +input PriceBidInput { + """ + A price expressed as a multiple of 10^-price_decimals increments. + """ + price: Int! +} + +type QueueView_OrderEntry_37aee5f5 { + count: Int! + entries(count: Int): [OrderEntry!]! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: MatchingEngineState + mutation: OperationMutationRoot +} diff --git a/examples/native-fungible/src/service.rs b/examples/native-fungible/src/service.rs index a7cbd3800d30..070f29e62901 100644 --- a/examples/native-fungible/src/service.rs +++ b/examples/native-fungible/src/service.rs @@ -9,7 +9,7 @@ use async_graphql::{EmptySubscription, Object, Request, Response, Schema}; use fungible::Parameters; use linera_sdk::{ abis::fungible::{FungibleOperation, FungibleTokenAbi}, - graphql::GraphQLMutationRoot as _, + graphql::GraphQLMutationRoot, linera_base_types::{AccountOwner, OwnerSpender, WithServiceAbi}, Service, ServiceRuntime, }; @@ -36,13 +36,25 @@ impl Service for NativeFungibleTokenService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl NativeFungibleTokenService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Self, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.clone(), FungibleOperation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -117,3 +129,21 @@ impl NativeFungibleTokenService { }) } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::ServiceRuntime; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + + let service = NativeFungibleTokenService { + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/native-fungible/src/snapshots/native_fungible_service__tests__schema_sdl.snap b/examples/native-fungible/src/snapshots/native_fungible_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..a0aad836552b --- /dev/null +++ b/examples/native-fungible/src/snapshots/native_fungible_service__tests__schema_sdl.snap @@ -0,0 +1,86 @@ +--- +source: native-fungible/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +input Account { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +type AccountEntry { + key: AccountOwner! + value: Amount! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +type Accounts { + entry(key: AccountOwner!): AccountEntry! + entries: [AccountEntry!]! + keys: [AccountOwner!]! +} + +type AllowanceEntry { + key: OwnerSpender! + value: Amount! +} + +type Allowances { + entry(key: OwnerSpender!): AllowanceEntry! + entries: [AllowanceEntry!]! +} + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +type FungibleOperationMutationRoot { + balance(owner: AccountOwner!): [Int!]! + tickerSymbol: [Int!]! + approve(owner: AccountOwner!, spender: AccountOwner!, allowance: Amount!): [Int!]! + transfer(owner: AccountOwner!, amount: Amount!, targetAccount: Account!): [Int!]! + transferFrom(owner: AccountOwner!, spender: AccountOwner!, amount: Amount!, targetAccount: Account!): [Int!]! + claim(sourceAccount: Account!, amount: Amount!, targetAccount: Account!): [Int!]! +} + +type NativeFungibleTokenService { + tickerSymbol: String! + accounts: Accounts! + allowances: Allowances! +} + +""" +A pair of owner and spender accounts for managing allowances +""" +scalar OwnerSpender + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: NativeFungibleTokenService + mutation: FungibleOperationMutationRoot +} diff --git a/examples/non-fungible/src/service.rs b/examples/non-fungible/src/service.rs index c2ad296594c7..5d01412049e3 100644 --- a/examples/non-fungible/src/service.rs +++ b/examples/non-fungible/src/service.rs @@ -46,7 +46,14 @@ impl Service for NonFungibleTokenService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl NonFungibleTokenService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema(&self) -> Schema { + Schema::build( QueryRoot { non_fungible_token: self.state.clone(), runtime: self.runtime.clone(), @@ -56,8 +63,7 @@ impl Service for NonFungibleTokenService { }, EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -211,3 +217,25 @@ impl MutationRoot { [] } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = NonFungibleTokenState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = NonFungibleTokenService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/non-fungible/src/snapshots/non_fungible_service__tests__schema_sdl.snap b/examples/non-fungible/src/snapshots/non_fungible_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..ac07329a3f51 --- /dev/null +++ b/examples/non-fungible/src/snapshots/non_fungible_service__tests__schema_sdl.snap @@ -0,0 +1,72 @@ +--- +source: non-fungible/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +input Account { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +Hash of a Data Blob +""" +scalar DataBlobHash + +""" +A scalar that can represent any JSON Object value. +""" +scalar JSONObject + +type MutationRoot { + mint(minter: AccountOwner!, name: String!, blobHash: DataBlobHash!): [Int!]! + transfer(sourceOwner: AccountOwner!, tokenId: String!, targetAccount: Account!): [Int!]! + claim(sourceAccount: Account!, tokenId: String!, targetAccount: Account!): [Int!]! +} + +type NftOutput { + tokenId: String! + owner: AccountOwner! + name: String! + minter: AccountOwner! + payload: [Int!]! +} + +type QueryRoot { + nft(tokenId: String!): NftOutput + nfts: JSONObject! + ownedTokenIdsByOwner(owner: AccountOwner!): [String!]! + ownedTokenIds: JSONObject! + ownedNfts(owner: AccountOwner!): JSONObject! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: QueryRoot + mutation: MutationRoot +} diff --git a/examples/rfq/src/service.rs b/examples/rfq/src/service.rs index 7c0d7b67eea1..11fb4b902617 100644 --- a/examples/rfq/src/service.rs +++ b/examples/rfq/src/service.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use async_graphql::{EmptySubscription, Request, Response, Schema}; use linera_sdk::{ - graphql::GraphQLMutationRoot as _, linera_base_types::WithServiceAbi, views::View, Service, + graphql::GraphQLMutationRoot, linera_base_types::WithServiceAbi, views::View, Service, ServiceRuntime, }; use rfq::Operation; @@ -42,12 +42,46 @@ impl Service for RfqService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl RfqService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = RfqState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = RfqService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); } } diff --git a/examples/rfq/src/snapshots/rfq_service__tests__schema_sdl.snap b/examples/rfq/src/snapshots/rfq_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..3417fd705ed1 --- /dev/null +++ b/examples/rfq/src/snapshots/rfq_service__tests__schema_sdl.snap @@ -0,0 +1,151 @@ +--- +source: rfq/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +type AccountOutput { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A non-negative amount of tokens. +""" +scalar Amount + +""" +A unique identifier for a user application +""" +scalar ApplicationId + +type AwaitingTokens { + tokenPair: TokenPair! + amountOffered: Amount! + quoterAccount: AccountOwner! + tempChainId: ChainId! +} + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_RequestId_RequestData_6ae3f850 { + key: RequestId! + value: RequestData +} + +type ExchangeInProgress { + tempChainId: ChainId! +} + +input MapFilters_RequestIdInput_7d592c26 { + keys: [RequestIdInput!] +} + +input MapInput_RequestIdInput_7d592c26 { + filters: MapFilters_RequestIdInput_7d592c26 +} + +type MapView_RequestId_RequestData_da3d7826 { + keys(count: Int): [RequestId!]! + count: Int! + entry(key: RequestIdInput!): Entry_RequestId_RequestData_6ae3f850! + entries(input: MapInput_RequestIdInput_7d592c26): [Entry_RequestId_RequestData_6ae3f850!]! +} + +type OperationMutationRoot { + requestQuote(target: ChainId!, tokenPair: TokenPairInput!, amount: Amount!): [Int!]! + provideQuote(requestId: RequestIdInput!, quote: Amount!, quoterOwner: AccountOwner!): [Int!]! + acceptQuote(requestId: RequestIdInput!, owner: AccountOwner!, feeBudget: Amount!): [Int!]! + finalizeDeal(requestId: RequestIdInput!): [Int!]! + cancelRequest(requestId: RequestIdInput!): [Int!]! +} + +type QuoteProvided { + tokenPair: TokenPair! + amount: Amount! + amountOffered: Amount! + quoterOwner: AccountOwner! +} + +type QuoteRequested { + tokenPair: TokenPair! + amount: Amount! +} + +type RequestData { + state: RequestState! +} + +type RequestId { + otherChainId: ChainId! + seqNum: Int! + weRequested: Boolean! +} + +input RequestIdInput { + otherChainId: ChainId! + seqNum: Int! + weRequested: Boolean! +} + +union RequestState = QuoteRequested | QuoteProvided | AwaitingTokens | ExchangeInProgress + +type RfqState { + nextSeqNumber: Int! + requests: MapView_RequestId_RequestData_da3d7826! + tempChainState: TempChainState +} + +type TempChainState { + requestId: RequestId! + initiator: ChainId! + tokenPair: TokenPair! + tokensInHold: Tokens +} + +type TokenPair { + tokenOffered: ApplicationId! + tokenAsked: ApplicationId! +} + +input TokenPairInput { + tokenOffered: ApplicationId! + tokenAsked: ApplicationId! +} + +type Tokens { + tokenId: ApplicationId! + owner: AccountOutput! + amount: Amount! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: RfqState + mutation: OperationMutationRoot +} diff --git a/examples/social/src/service.rs b/examples/social/src/service.rs index a0d0b752de40..f6ac9ce2ea99 100644 --- a/examples/social/src/service.rs +++ b/examples/social/src/service.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use async_graphql::{EmptySubscription, Request, Response, Schema}; use linera_sdk::{ - graphql::GraphQLMutationRoot as _, linera_base_types::WithServiceAbi, views::View, Service, + graphql::GraphQLMutationRoot, linera_base_types::WithServiceAbi, views::View, Service, ServiceRuntime, }; use social::Operation; @@ -40,12 +40,46 @@ impl Service for SocialService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl SocialService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Arc, + >::MutationRoot, + EmptySubscription, + > { + Schema::build( self.state.clone(), Operation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = SocialState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = SocialService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); } } diff --git a/examples/social/src/snapshots/social_service__tests__schema_sdl.snap b/examples/social/src/snapshots/social_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..fb0880c12bf8 --- /dev/null +++ b/examples/social/src/snapshots/social_service__tests__schema_sdl.snap @@ -0,0 +1,169 @@ +--- +source: social/src/service.rs +expression: service.schema().sdl() +--- +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A comment on a post +""" +type Comment { + """ + The comment text + """ + text: String! + """ + The ChainId of the commenter + """ + chainId: ChainId! +} + +type CustomMapView_Key_Post_057e51d5 { + keys(count: Int): [Key!]! + entry(key: KeyInput!): Entry_Key_Post_6913b126! + entries(input: MapInput_KeyInput_b8ccb225): [Entry_Key_Post_6913b126!]! +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_Key_Post_6913b126 { + key: Key! + value: Post +} + +""" +A key by which a post is indexed. +""" +type Key { + """ + The timestamp of the block in which the post was included on the author's chain. + """ + timestamp: Timestamp! + """ + The owner of the chain on which the `Post` operation was included. + """ + author: ChainId! + """ + The number of posts by that author before this one. + """ + index: Int! +} + +""" +A key by which a post is indexed. +""" +input KeyInput { + """ + The timestamp of the block in which the post was included on the author's chain. + """ + timestamp: Timestamp! + """ + The owner of the chain on which the `Post` operation was included. + """ + author: ChainId! + """ + The number of posts by that author before this one. + """ + index: Int! +} + +type LogView_OwnPost_22dbfca0 { + count: Int! + entries(start: Int, end: Int): [OwnPost!]! +} + +input MapFilters_KeyInput_b8ccb225 { + keys: [KeyInput!] +} + +input MapInput_KeyInput_b8ccb225 { + filters: MapFilters_KeyInput_b8ccb225 +} + +type OperationMutationRoot { + subscribe(chainId: ChainId!): [Int!]! + unsubscribe(chainId: ChainId!): [Int!]! + post(text: String!, imageUrl: String): [Int!]! + like(key: KeyInput!): [Int!]! + comment(key: KeyInput!, comment: String!): [Int!]! +} + +""" +A post's text and timestamp, to use in contexts where author and index are known. +""" +type OwnPost { + """ + The timestamp of the block in which the post operation was included. + """ + timestamp: Timestamp! + """ + The posted text. + """ + text: String! + """ + The posted Image_url(optional). + """ + imageUrl: String +} + +""" +A post on the social app. +""" +type Post { + """ + The key identifying the post, including the timestamp, author and index. + """ + key: Key! + """ + The post's text content. + """ + text: String! + """ + The post's image_url(optional). + """ + imageUrl: String + """ + The total number of likes + """ + likes: Int! + """ + Comments with their ChainId + """ + comments: [Comment!]! +} + +""" +The application state. +""" +type SocialState { + """ + Our posts. + """ + ownPosts: LogView_OwnPost_22dbfca0! + """ + Posts we received from authors we subscribed to. + """ + receivedPosts: CustomMapView_Key_Post_057e51d5! +} + +""" +A timestamp, in microseconds since the Unix epoch +""" +scalar Timestamp + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: SocialState + mutation: OperationMutationRoot +} diff --git a/examples/task-processor/Cargo.toml b/examples/task-processor/Cargo.toml index 2fa91652b110..3150eb45ffa2 100644 --- a/examples/task-processor/Cargo.toml +++ b/examples/task-processor/Cargo.toml @@ -17,6 +17,7 @@ linera-sdk = { workspace = true, features = ["test"] } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] insta.workspace = true +linera-sdk = { workspace = true, features = ["test", "wasmer"] } serde-reflection.workspace = true [[bin]] diff --git a/examples/task-processor/src/service.rs b/examples/task-processor/src/service.rs index a5eb6395abb1..3c570575de51 100644 --- a/examples/task-processor/src/service.rs +++ b/examples/task-processor/src/service.rs @@ -43,7 +43,14 @@ impl Service for TaskProcessorService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl TaskProcessorService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema(&self) -> Schema { + Schema::build( QueryRoot { state: self.state.clone(), runtime: self.runtime.clone(), @@ -53,8 +60,7 @@ impl Service for TaskProcessorService { }, EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -132,3 +138,25 @@ impl MutationRoot { [] } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = TaskProcessorState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = TaskProcessorService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/task-processor/src/snapshots/task_processor_service__tests__schema_sdl.snap b/examples/task-processor/src/snapshots/task_processor_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..58420b3afaa4 --- /dev/null +++ b/examples/task-processor/src/snapshots/task_processor_service__tests__schema_sdl.snap @@ -0,0 +1,57 @@ +--- +source: task-processor/src/service.rs +expression: service.schema().sdl() +--- +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +type MutationRoot { + """ + Requests a task to be processed by an off-chain operator. + """ + requestTask(operator: String!, input: String!): [Int!]! + requestTaskOn(chainId: ChainId!, operator: String!, input: String!): [Int!]! +} + +scalar ProcessorActions + +type QueryRoot { + """ + Returns the current task count. + """ + taskCount: Int! + """ + Returns the stored results in order. + """ + results: [String!]! + """ + Returns the pending tasks and callback requests for the task processor. + """ + nextActions(cursor: String, now: Timestamp!): ProcessorActions! + """ + Processes the outcome of a completed task and schedules operations. + """ + processTaskOutcome(outcome: TaskOutcome!): Boolean! +} + +scalar TaskOutcome + +""" +A timestamp, in microseconds since the Unix epoch +""" +scalar Timestamp + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: QueryRoot + mutation: MutationRoot +} diff --git a/examples/wrapped-fungible/src/service.rs b/examples/wrapped-fungible/src/service.rs index 7e681af3ac9b..83ec69c1388c 100644 --- a/examples/wrapped-fungible/src/service.rs +++ b/examples/wrapped-fungible/src/service.rs @@ -44,13 +44,25 @@ impl Service for WrappedFungibleTokenService { } async fn handle_query(&self, request: Request) -> Response { - let schema = Schema::build( + self.schema().execute(request).await + } +} + +impl WrappedFungibleTokenService { + /// Builds the GraphQL schema served by [`Self::handle_query`]. + fn schema( + &self, + ) -> Schema< + Self, + >::MutationRoot, + EmptySubscription, + >{ + Schema::build( self.clone(), WrappedFungibleOperation::mutation_root(self.runtime.clone()), EmptySubscription, ) - .finish(); - schema.execute(request).await + .finish() } } @@ -87,3 +99,25 @@ impl WrappedFungibleTokenService { params.evm_source_chain_id } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use linera_sdk::{util::BlockingWait, views::View, ServiceRuntime}; + + use super::*; + + #[test] + fn schema_sdl() { + let runtime = ServiceRuntime::::new(); + let state = WrappedFungibleTokenState::load(runtime.root_view_storage_context()) + .blocking_wait() + .expect("Failed to read from mock key value store"); + + let service = WrappedFungibleTokenService { + state: Arc::new(state), + runtime: Arc::new(runtime), + }; + + insta::assert_snapshot!(service.schema().sdl()); + } +} diff --git a/examples/wrapped-fungible/src/snapshots/wrapped_fungible_service__tests__schema_sdl.snap b/examples/wrapped-fungible/src/snapshots/wrapped_fungible_service__tests__schema_sdl.snap new file mode 100644 index 000000000000..67fc6a66ef5e --- /dev/null +++ b/examples/wrapped-fungible/src/snapshots/wrapped_fungible_service__tests__schema_sdl.snap @@ -0,0 +1,131 @@ +--- +source: wrapped-fungible/src/service.rs +expression: service.schema().sdl() +--- +""" +An account. +""" +input Account { + """ + The chain of the account. + """ + chainId: ChainId! + """ + The owner of the account. + """ + owner: AccountOwner! +} + +""" +A unique identifier for a user or an application. +""" +scalar AccountOwner + +""" +A unique identifier for a user application +""" +scalar ApplicationId + +""" +The unique identifier (UID) of a chain. This is currently computed as the hash value of a ChainDescription. +""" +scalar ChainId + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_AccountOwner_U128_71141611 { + key: AccountOwner! + value: U128 +} + +""" +A GraphQL-visible map item, complete with key. +""" +type Entry_OwnerSpender_U128_5b2bd1dc { + key: OwnerSpender! + value: U128 +} + +input MapFilters_AccountOwner_d6668c53 { + keys: [AccountOwner!] +} + +input MapFilters_OwnerSpender_6e975ca8 { + keys: [OwnerSpender!] +} + +input MapInput_AccountOwner_d6668c53 { + filters: MapFilters_AccountOwner_d6668c53 +} + +input MapInput_OwnerSpender_6e975ca8 { + filters: MapFilters_OwnerSpender_6e975ca8 +} + +type MapView_AccountOwner_U128_16c54081 { + keys(count: Int): [AccountOwner!]! + count: Int! + entry(key: AccountOwner!): Entry_AccountOwner_U128_71141611! + entries(input: MapInput_AccountOwner_d6668c53): [Entry_AccountOwner_U128_71141611!]! +} + +type MapView_OwnerSpender_U128_eaab1541 { + keys(count: Int): [OwnerSpender!]! + count: Int! + entry(key: OwnerSpender!): Entry_OwnerSpender_U128_5b2bd1dc! + entries(input: MapInput_OwnerSpender_6e975ca8): [Entry_OwnerSpender_U128_5b2bd1dc!]! +} + +""" +A pair of owner and spender accounts for managing allowances +""" +scalar OwnerSpender + +""" +A 128-bit unsigned integer. +""" +scalar U128 + +type WrappedFungibleOperationMutationRoot { + balance(owner: AccountOwner!): [Int!]! + tickerSymbol: [Int!]! + approve(owner: AccountOwner!, spender: AccountOwner!, allowance: U128!): [Int!]! + transfer(owner: AccountOwner!, amount: U128!, targetAccount: Account!): [Int!]! + transferFrom(owner: AccountOwner!, spender: AccountOwner!, amount: U128!, targetAccount: Account!): [Int!]! + claim(sourceAccount: Account!, amount: U128!, targetAccount: Account!): [Int!]! + mintAndTransfer(targetAccount: Account!, amount: U128!): [Int!]! + burn(owner: AccountOwner!, amount: U128!): [Int!]! + registerAuthorizedCaller(appId: ApplicationId!): [Int!]! +} + +type WrappedFungibleTokenService { + accounts: MapView_AccountOwner_U128_16c54081! + allowances: MapView_OwnerSpender_U128_eaab1541! + tickerSymbol: String! + """ + The number of decimal places used by the source ERC-20. + """ + decimals: Int! + """ + The ERC-20 token address on the source EVM chain (hex-encoded). + """ + evmTokenAddress: String! + """ + The EVM chain ID of the source chain. + """ + evmSourceChainId: Int! +} + +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +schema { + query: WrappedFungibleTokenService + mutation: WrappedFungibleOperationMutationRoot +}