Quickstart
This provides a simple introduction to Leto, you can explore more in the following sections of this README or by looking at the tests, documentation and examples for each package. A fullstack Dart example with Flutter client and Leto/Shelf server can be found in https://github.com/juancastillo0/leto/tree/main/chat_example
The source code for this quickstart can be found in https://github.com/juancastillo0/leto/blob/main/leto_shelf/example/lib/quickstart_server.dart.
Install
Add dependencies to your pubspec.yaml
dependencies:
leto_schema: ^0.0.1-dev.3
leto: ^0.0.1-dev.1
leto_shelf: ^0.0.1-dev.1
shelf: ^1.0.0
shelf_router: ^1.0.0
# Not nessary for the server, just for testing it
http: ^1.0.0
dev_dependencies:
# Only if you use code generation
leto_generator: ^0.0.1-dev.3
build_runner: ^2.0.0
Create a GraphQLSchema
Specify the logic for your server, this could be anything such as accessing a database, reading a file or sending an http request. We will use a controller class with a stream that emits events on mutation to support subscriptions.
// this annotations is only necessary for code generation
()
class Model {
final String state;
final DateTime createdAt;
const Model(this.state, this.createdAt);
}
/// Set up your state.
/// This could be anything such as a database connection.
///
/// Global means that there will only be one instance of [ModelController]
/// for this reference. As opposed to [ScopedRef.local] where there will be
/// one [ModelController] for each request (for saving user information
/// or a [DataLoader], for example).
final stateRef = ScopedRef<ModelController>.global(
(scope) => ModelController(
Model('InitialState', DateTime.now()),
),
);
class ModelController {
Model? _value;
Model? get value => _value;
final _streamController = StreamController<Model>.broadcast();
Stream<Model> get stream => _streamController.stream;
ModelController(this._value);
void setValue(Model newValue) {
if (newValue.state == 'InvalidState') {
// This will appear as an GraphQLError in the response.
// You can pass more information using custom extensions.
throw GraphQLError(
"Can't be in InvalidState.",
extensions: {'errorCodeExtension': 'INVALID_STATE'},
);
}
_value = newValue;
_streamController.add(newValue);
}
}
With the logic that you want to expose, you can create the GraphQLSchema instance and access the controller state using the Ctx
for each resolver and the ScopedRef.get
method. The following is a schema with Query, Mutation and Subscription with a simple model. However, GraphQL is a very expressive language with Unions, Enums, complex Input Objects, collections and more. For more documentation on writing GraphQL Schemas with Leto you can read the following sections, tests and examples for each package.
To expose this logic, we could implement the following GraphQL API:
type Query {
"""Get the current state"""
getState: Model
}
type Model {
state: String!
createdAt: Date!
}
"""An ISO-8601 Date."""
scalar Date
type Mutation {
setState(
"""The new state, can't be 'WrongState'!."""
newState: String!
): Boolean!
}
type Subscription {
onStateChange: Model!
}
This could be exposed by using package:leto_schema
API as shown in the following code sample or more simply by using code generation.
/// Create a [GraphQLSchema].
/// All of this can be generated automatically using `package:leto_generator`
GraphQLSchema makeGraphQLSchema() {
// The [Model] GraphQL Object type. It will be used in the schema
final GraphQLObjectType<Model> modelGraphQLType = objectType<Model>(
'Model',
fields: [
// All the fields that you what to expose
graphQLString.nonNull().field(
'state',
resolve: (Model model, Ctx ctx) => model.state,
),
graphQLDate.nonNull().field(
'createdAt',
resolve: (Model model, Ctx ctx) => model.createdAt,
),
],
);
// The executable schema. The `queryType`, `mutationType`
// and `subscriptionType` are should be GraphQL Object types
final schema = GraphQLSchema(
queryType: objectType('Query', fields: [
// Use the created [modelGraphQLType] as the return type for the
// "getState" root Query field
modelGraphQLType.field(
'getState',
description: 'Get the current state',
resolve: (Object? rootValue, Ctx ctx) => stateRef.get(ctx).value,
),
]),
mutationType: objectType('Mutation', fields: [
graphQLBoolean.nonNull().field(
'setState',
// set up the input field. could also be done with
// `graphQLString.nonNull().inputField('newState')`
inputs: [
GraphQLFieldInput(
'newState',
graphQLString.nonNull(),
description: "The new state, can't be 'WrongState'!.",
),
],
// execute the mutation
resolve: (Object? rootValue, Ctx ctx) {
final newState = ctx.args['newState']! as String;
if (newState == 'WrongState') {
return false;
}
stateRef.get(ctx).setValue(Model(newState, DateTime.now()));
return true;
},
),
]),
subscriptionType: objectType('Subscription', fields: [
// The Subscriptions are the same as Queries and Mutations as above,
// but should use `subscribe` instead of `resolve` and return a `Steam`
modelGraphQLType.nonNull().field(
'onStateChange',
subscribe: (Object? rootValue, Ctx ctx) => stateRef.get(ctx).stream,
)
]),
);
assert(schema.schemaStr == schemaString.trim());
return schema;
}
Using Code Generation
You can use code generation to create a function similar to makeGraphQLSchema
with the following resolver definitions with annotations.
/// Code Generation
/// Using leto_generator, [makeGraphQLSchema] could be generated
/// with the following annotated functions and the [GraphQLObject]
/// annotation over [Model]
/// Get the current state
()
Model? getState(Ctx ctx) {
return stateRef.get(ctx).value;
}
()
bool setState(
Ctx ctx,
// The new state, can't be 'WrongState'!.
String newState,
) {
if (newState == 'WrongState') {
return false;
}
stateRef.get(ctx).setValue(Model(newState, DateTime.now()));
return true;
}
()
Stream<Model> onStateChange(Ctx ctx) {
return stateRef.get(ctx).stream;
}
This generates the same modelGraphQLType
in <file>.g.dart
and graphqlApiSchema
in 'lib/graphql_api.schema.dart' (TODO: 1G configurable). The documentation comments will be used as description in the generated schema. More information on code generation can be found in the following sections, in the package:leto_generator
's README or in the code generation example.
Start the server
With the GraphQLSchema
and the resolver logic implemented, we can set up the shelf handlers for each route. In this case we will use the graphQLHttp
handlers for the "/graphql" endpoint and graphQLWebSocket
for "/graphql-subscription" which supports subscriptions. You could provide custom extensions, document validations or a ScopedMap
to override the state in the GraphQL
executor constructor.
Future<HttpServer> runServer({int? serverPort, ScopedMap? globals}) async {
// you can override state with ScopedMap.setGlobal/setScoped
final ScopedMap scopedMap = globals ?? ScopedMap();
if (globals == null) {
// if it wasn't overridden it should be the default
assert(stateRef.get(scopedMap).value?.state == 'InitialState');
}
// Instantiate the GraphQLSchema
final schema = makeGraphQLSchema();
// Instantiate the GraphQL executor, you can pass extensions and
// decide whether you want to introspect the schema
// and validate the requests
final letoGraphQL = GraphQL(
schema,
extensions: [],
introspect: true,
globalVariables: scopedMap,
);
final port =
serverPort ?? const int.fromEnvironment('PORT', defaultValue: 8080);
const graphqlPath = 'graphql';
const graphqlSubscriptionPath = 'graphql-subscription';
final endpoint = 'http://localhost:$port/$graphqlPath';
final subscriptionEndpoint = 'ws://localhost:$port/$graphqlSubscriptionPath';
// Setup server endpoints
final app = Router();
// GraphQL HTTP handler
app.all(
'/$graphqlPath',
graphQLHttp(letoGraphQL),
);
// GraphQL WebSocket handler
app.all(
'/$graphqlSubscriptionPath',
graphQLWebSocket(
letoGraphQL,
pingInterval: const Duration(seconds: 10),
validateIncomingConnection: (
Map<String, Object?>? initialPayload,
GraphQLWebSocketShelfServer wsServer,
) {
if (initialPayload != null) {
// you can authenticated an user with the initialPayload:
// final token = initialPayload['token']! as String;
// ...
}
return true;
},
),
);
In the shelf router you can specify other handlers such as static files or other utilities. In the following code we set up a GraphQL UI explorer in the "/playground" route using the playgroundHandler
handler and a "/graphql-schema" endpoint that returns the GraphQL schema String in the body of the response.
// GraphQL schema and endpoint explorer web UI.
// Available UI handlers: playgroundHandler, graphiqlHandler and altairHandler
app.get(
'/playground',
playgroundHandler(
config: PlaygroundConfig(
endpoint: endpoint,
subscriptionEndpoint: subscriptionEndpoint,
),
),
);
// Simple endpoint to download the GraphQLSchema as a SDL file.
// $ curl http://localhost:8080/graphql-schema > schema.graphql
const downloadSchemaOnOpen = true;
const schemaFilename = 'schema.graphql';
app.get('/graphql-schema', (Request request) {
return Response.ok(
schema.schemaStr,
headers: {
'content-type': 'text/plain',
'content-disposition': downloadSchemaOnOpen
? 'attachment; filename="$schemaFilename"'
: 'inline',
},
);
});
Once you set up all the handlers, you can start the server adding middlewares if necessary. In this example, we will use the etag
and cors
middlewares from package:leto_shelf
. You can read more about them in the package's README.
// Set up other shelf handlers such as static files
// Start the server
final server = await shelf_io.serve(
const Pipeline()
// Configure middlewares
.addMiddleware(customLog(log: (msg) {
// TODO: 2A detect an introspection query.
// Add more structured logs and headers
if (!msg.contains('IntrospectionQuery')) {
print(msg);
}
}))
.addMiddleware(cors())
.addMiddleware(etag())
.addMiddleware(jsonParse())
// Add Router handler
.addHandler(app),
'0.0.0.0',
port,
);
print(
'GraphQL Endpoint at $endpoint\n'
'GraphQL Subscriptions at $subscriptionEndpoint\n'
'GraphQL Playground UI at http://localhost:$port/playground',
);
return server;
}
With the runServer
function finished, we can now create a main function that executes it and servers the implemented logic in a GraphQL server. This function can also be used for test as shown in the testServer
function from the next section.
Future<void> main() async {
final server = await runServer();
final url = Uri.parse('http://${server.address.host}:${server.port}/graphql');
await testServer(url);
}
Test the server
You can test the server programmatically by sending HTTP requests to the server. You could also test the GraphQL executor directly using the GraphQL.parseAndExecute
function without running the shelf server.
/// For a complete GraphQL client you probably want to use
/// Ferry (https://github.com/gql-dart/ferry)
/// Artemis (https://github.com/comigor/artemis)
/// or raw GQL Links (https://github.com/gql-dart/gql/tree/master/links)
Future<void> testServer(Uri url) async {
final before = DateTime.now();
const newState = 'NewState';
// POST request which sets the state
final response = await http.post(
url,
body: jsonEncode({
'query':
r'mutation setState ($state: String!) { setState(newState: $state) }',
'variables': {'state': newState}
}),
headers: {'content-type': 'application/json'},
);
assert(response.statusCode == 200);
final body = jsonDecode(response.body) as Map<String, Object?>;
final data = body['data']! as Map<String, Object?>;
assert(data['setState'] == true);
// Also works with GET
final responseGet = await http.get(url.replace(
queryParameters: <String, String>{
'query': '{ getState { state createdAt } }'
},
));
assert(responseGet.statusCode == 200);
final bodyGet = jsonDecode(responseGet.body) as Map<String, Object?>;
final dataGet = bodyGet['data']! as Map<String, dynamic>;
assert(dataGet['getState']['state'] == newState);
final createdAt = DateTime.parse(dataGet['getState']['createdAt'] as String);
assert(createdAt.isAfter(before));
assert(createdAt.isBefore(DateTime.now()));
// To test subscriptions you can open the playground web UI at /playground
// or programmatically using https://github.com/gql-dart/gql/tree/master/links/gql_websocket_link,
// an example can be found in test/mutation_and_subscription_test.dart
}
Test and explore the server manually in the explorer interface "http://localhost:8080/playground". It supports subscriptions, subscribe in one tab and send a mutation request in another to test it. There are other UI explorers that you can set up (for example, GraphiQL and Altair), for more information Web UI explorers section.
We also set up a "http://localhost:8080/graphql-schema" endpoint which returns the GraphQL schema String in the schema definition language, this could be useful for other tools such as client side code generators.