Get started with Pioneer
This tutorial will help you get started with building a GraphQL API using Pioneer.
This tutorial assumes that you are familiar with the command line, Swift, and Swift Package Manager and have installed a recent Swift version.
1: New Swift project
Go to a directory where you want to create the project on.
Setup a skeleton of the executable using Swift package manager by running:
swift package init --type executable
The project directory should now contains a Package.swift
file.
2: Dependencies
For this tutorial, we will be using Vapor (opens in a new tab) as the web framework and Graphiti (opens in a new tab) to built our GraphQL schema.
Compabilitiy
Pioneer comes with first-party support for Vapor (opens in a new tab) and Graphiti (opens in a new tab), but they are not restricted to both packages.
Adding dependencies
Add these dependencies and Pioneer to the Package.swift
let package = Package(
dependencies: [
.package(url: "https://github.com/GraphQLSwift/Graphiti.git", from: "1.2.0"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.1"),
.package(url: "https://github.com/d-exclaimation/pioneer", from: "1.3.0")
],
targets: [
.target(
name: "<project-name>",
dependencies: [
.product(name: "Pioneer", package: "pioneer"),
.product(name: "Graphiti", package: "Graphiti"),
.product(name: "Vapor", package: "vapor")
]
)
]
)
Using Swift 6 or higher
We will also restrict the platform of the project to macOS v10.15 or higher, to allow the use of Swift Concurrency.
let package = Package(
platforms: [
.macOS(.v10_15),
],
// ...
)
3: Define entities and context
Continuing from the setup, now we will be declaring entities for the GraphQL API.
Book entity
We'll create a simple Book
entity.
import struct Pioneer.ID
struct Book: Identifiable {
var id: ID
var title: String
}
ID
Pioneer provide a struct to define ID (opens in a new tab) from a String
or UUID
which will be differentiable from a regular String
by Graphiti (opens in a new tab).
ID (opens in a new tab) are commonly used scalar in GraphQL use to identify types.
Context
Context is a useful type that can be generated for each request and can be used for many purpose such as:
- Reading request-specific header value,
- Setting response headers and cookies, or
- Performing dependency injection to each resolver functions
For this tutorial, we will create simple context type to hold both the Vapor's Request
and Response
object.
import class Vapor.Request
import class Vapor.Response
struct Context {
var req: Request
var res: Response
}
4: Define data source
Pioneer doesn't dictate where the resolvers get their data from and thus, it can be use with any data source (any databases, any ORMs, etc.).
For simplicity, we will simple hardcode the value and use actor to store it.
actor Books {
private var books: [Book.ID: Book] = [:]
func create(book: Book) -> Book? {
guard case .none = books[book.id] else {
return nil
}
books[book.id] = book
return book
}
func all() -> [Book] {
return books.values.map { $0 }
}
enum Errors: Error {
case duplicate(id: Book.ID)
}
static let shared: Books = .init()
}
5: Define resolver
Resolvers tell GraphQL schema how to fetch the data associated with a particular type.
Resolver
In Graphiti (opens in a new tab), this is done with a seperate resolver struct.
struct Resolver {}
Query resolver
Let's now add a resolver to query all the books
import struct Graphiti.NoArguments
struct Resolver {
func books(ctx: Context, args: NoArguments) async -> [Book] {
await Books.shared.all()
}
}
Mutation resolver and arguments
For a mutation, arguments may be necessary to provide information to create a new instance of a type.
In Graphiti (opens in a new tab), this is done with a seperate argument struct which must be Decodable
.
import struct Graphiti.NoArguments
import struct Vapor.Abort
struct Resolver {
func books(ctx: Context, args: NoArguments) async -> [Book] {
await Books.shared.all()
}
struct NewArgs: Decodable {
var title: String
}
func newBook(ctx: Context, args: NewArgs) async throws -> Book {
let book = await Books.shared.create(
book: Book(id: .uuid(), title: args.title)
)
guard let book else {
throw Abort(.internalServerError)
}
return book
}
}
6: Define Schema
Every GraphQL server uses a schema to define the structure of data that clients can query.
In Graphiti (opens in a new tab), schema can be declared using Swift code which allow type safety.
import Graphiti
import struct Pioneer.ID
func schema() throws -> Schema<Resolver, Context> {
.init {
// Adding ID as usable scalar for Graphiti
Scalar(ID.self)
// The Book as a GraphQL type with all its properties as fields
Type(Book.self) {
Field("id", at: \.id)
Field("title", at: \.title)
}
Query {
// The root query field to fetch all books
Field("books", at: Resolver.books)
}
Mutation {
// The root mutation field to create a new book
Field("newBook", at: Resolver.book) {
// Argument for this field
Argument("title", at: \.title)
}
}
}
}
GraphQL SDL
This is the equivalent schema in GraphQL SDL for one built with Graphiti. This is not required to be created.
type Book {
id: ID!
title: String!
}
type Query {
books: [Book!]!
}
type Mutation {
newBook(title: String!): Book!
}
schema {
query: Query
mutation: Mutation
}
For further reading, the team at Apollo GraphQL (opens in a new tab) has provided a guide on good schema design (opens in a new tab).
7: Pioneer instance
Now, it's time to integrate Pioneer into the existing Vapor application using the resolver and schema declared before.
Pioneer configuration
First, create an instance of Pioneer with the desired configuration.
import Pioneer
let server = try Pioneer(
schema: schema(),
resolver: Resolver(),
httpStrategy: .csrfPrevention,
introspection: true,
playground: .sandbox
)
Basic Vapor application
Next, let's setup a basic Vapor application.
import Pioneer
import Vapor
let app = try Application(.detect())
let server = try Pioneer(
schema: schema(),
resolver: Resolver(),
httpStrategy: .csrfPrevention,
introspection: true,
playground: .sandbox
)
defer {
app.shutdown()
}
try app.run()
Pioneer can also run as a standalone server without needing to setup a Vapor application.
import Pioneer
let server = try Pioneer(
schema: schema(),
resolver: Resolver(),
httpStrategy: .csrfPrevention,
introspection: true,
playground: .sandbox
)
try server.standaloneServer(
at: "graphql",
context: { req, res in
Context(req, res)
}
)
Pioneer as Vapor middleware
Finally, apply Pioneer to Vapor as a middleware (opens in a new tab).
import Pioneer
import Vapor
let app = try Application(.detect())
let server = try Pioneer(
schema: schema(),
resolver: Resolver(),
httpStrategy: .csrfPrevention,
introspection: true,
playground: .sandbox
)
defer {
app.shutdown()
}
app.middleware.use(
server.vaporMiddleware(
at: "graphql",
context: { req, res in
Context(req, res)
}
)
)
try app.run()
Skip to the end (If you don't need subscriptions.)
8: Adding subscriptions
Subscriptions is a feature of GraphQL which allow real-time stream of data. This is usually done through WebSocket using an additional protocol.
Enabling GraphQL over WebSocket
Pioneer is already built with these feature, and all that you have to do is enable it.
import Pioneer
import Vapor
let app = try Application(.detect())
let server = try Pioneer(
schema: schema(),
resolver: Resolver(),
httpStrategy: .csrfPrevention,
websocketProtocol: .graphqlWs,
introspection: true,
playground: .sandbox
)
defer {
app.shutdown()
}
app.middleware.use(
server.vaporMiddleware(
at: "graphql",
context: { req, res in
Context(req, res)
}
)
)
try app.run()
Subscription resolver
Now, let's add the subscription resolver. Pioneer can resolve subscription as long as the return value is either:
- AsyncEventStream (opens in a new tab), or
ConcurrentEventStream
In this tutorial, we will be using Pioneer's built in PubSub (opens in a new tab) system and its in-memory implementation, AsyncPubSub (opens in a new tab)
import Pioneer
import class GraphQL.EventStream
import struct Graphiti.NoArguments
import struct Vapor.Abort
struct Resolver {
private let pubsub: PubSub = AsyncPubSub()
private let trigger = "*:book-added"
func books(ctx: Context, args: NoArguments) async -> [Book] {
await Books.shared.all()
}
struct NewArgs: Decodable {
var title: String
}
func newBook(ctx: Context, args: NewArgs) async throws -> Book {
let book = await Books.shared.create(
book: Book(id: .uuid(), title: args.title)
)
guard let book else {
throw Abort(.internalServerError)
}
return book
}
func bookAdded(ctx: Context, args: NoArguments) -> EventStream<Book> {
pubsub.asyncStream(Book.self, for: trigger).toEventStream()
}
}
Triggering a subscription
With PubSub (opens in a new tab), subscription value can be pushed manually from a mutation. All we have to do is to publish under the same trigger.
import Pioneer
import class GraphQL.EventStream
import struct Graphiti.NoArguments
import struct Vapor.Abort
struct Resolver {
private let pubsub: PubSub = AsyncPubSub()
private let trigger = "*:book-added"
func books(ctx: Context, args: NoArguments) async -> [Book] {
await Books.shared.all()
}
struct NewArgs: Decodable {
var title: String
}
func newBook(ctx: Context, args: NewArgs) async throws -> Book {
let book = await Books.shared.create(
book: Book(id: .uuid(), title: args.title)
)
guard let book else {
throw Abort(.internalServerError)
}
try await pubsub.publish(for: trigger, payload: book)
return book
}
func bookAdded(ctx: Context, args: NoArguments) -> EventStream<Book> {
pubsub.asyncStream(Book.self, for: trigger).toEventStream()
}
}
Updating the schema
We can now add the subscription in the schema.
import Graphiti
import struct Pioneer.ID
func schema() throws -> Schema<Resolver, Context> {
.init {
// Adding ID as usable scalar for Graphiti
Scalar(ID.self)
// The Book as a GraphQL type with all its properties as fields
Type(Book.self) {
Field("id", at: \.id)
Field("title", at: \.title)
}
Query {
// The root query field to fetch all books
Field("books", at: Resolver.books)
}
Mutation {
// The root mutation field to create a new book
Field("newBook", at: Resolver.book) {
// Argument for this field
Argument("title", at: \.title)
}
}
Subscription {
SubsciptionField("bookAdded", as: Book.self, atSub: Resolver.bookAdded)
}
}
}
WebSocket context
Due to the nature of subscription which goes through WebSocket instead of HTTP, the context is built with different types of information i.e. there is no Response
object for WebSocket operation.
Pioneer's Vapor (opens in a new tab) integration allow a different WebSocket context builder which gives a different set of arguments catered towards what will be available on a WebSocket operation.
Pioneer will try to use the same context builder if not explicit given a different one for WebSocket. It will try to maintain all relevant information and inject that values into the Request
object.
import Pioneer
import Vapor
let app = try Application(.detect())
let server = try Pioneer(
schema: schema(),
resolver: Resolver(),
httpStrategy: .csrfPrevention,
websocketProtocol: .graphqlWs,
introspection: true,
playground: .sandbox
)
defer {
app.shutdown()
}
app.middleware.use(
server.vaporMiddleware(
at: "graphql",
context: { req, res in
Context(req, res)
},
websocketContext: { req, payload, gql in
Context(req, .init())
}
)
)
try app.run()
9: Start the server
The server is now ready!
Run the Swift project using:
swift run
Now, just open http://localhost:8080/graphql to go the Apollo Sandbox and play with the queries, and mutations.
Congrats, you have just built a GraphQL server with Swift and Pioneer!