Skip to content

caseyleehanley/swift-vapor-api-starter

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

swift-vapor-api-starter

A starter template for a Swift Vapor application that serves a REST API and allows CRUD operations on its entities. An example project that uses this template is available as a separate repository.

Table of Contents

I. Introduction
II. Command Reference
III. Tutorial
IV. Key Concepts
V. Testing
VI. Troubleshooting
VII. FAQs

Dependencies

[Swift] [Vapor] [Fluent] [Postgres] [Xcode] [RapidAPI]



I. Introduction

Key Features

  • Uses code generation via shell scripts to add new entities, reducing the amount of time spent writing boiler-plate code
  • Provides a shared, flexible query interface backed by Fluent for filtering and sorting all entities, as well as including associated entities (e.g., ?name_containsPrefix=Swi)
  • Relies heavily on the concept of DTOs for publicly exposed types, like those used when reading, creating, or updating entities from a client
  • Uses Swift Testing for unit test coverage, with simple tests being generated automatically

This starter project provides an opinionated application structure so that developers can focus on writing the core logic for their service. While it may not be the ideal project structure for every developer, it can be adapted to any scenario by modifying the code-generation templates. By using template files in the Templates/ directory, developers can use code generation to quickly add new entities with a single command: (e.g., make resource schema=programming_languages). If you're familiar with Ruby on Rails, it is similar to the rails generate scaffold command.

Screenshot of Terminal.app Code Generation Output

While this project depends heavily on Fluent for its flexible query interface, it should be straightforward to swap out Vapor for another Swift server framework that's compatible with Fluent, if desired. For example, it should be possible to use Hummingbird and its HummingbirdFluent integration with relatively minimal code changes.

DISCLAIMER: The project was developed using Swift 6 and the Xcode 16 Beta.



II. Command Reference

Test

$ make test

or run tests in Xcode.

Run

$ make run

or run in Xcode.

Migrate

$ make migrate

Revert Last Migration Batch

$ make revert

Add a New Resource (e.g., ProgrammingLanguage)

$ make resource schema=programming_languages


III. Tutorial

This tutorial assumes you have made no changes to the starter template.

First, make sure the server is running by using the make run command or using Xcode.

Next, open the swift-vapor-api-starter.paw file in RapidAPI to get started.

Try the Health Check query to make sure it's up and running:

Screenshot of RapidAPI.app "Health Check" HTTP Response

If you haven't already done so, try adding a new Resource backed by a database table named programming_languages via the command line:

make resource schema=programming_languages

Screenshot of Terminal.app Code Generation Output

Follow the prompts to finish the code generation, then restart the server and hop back over to RapidAPI. Try the Create Programming Language query:

Screenshot of RapidAPI.app "Create Programming Language" HTTP Response

After creating the programming language, try the Get Programming Language query:

Screenshot of RapidAPI.app "Get Programming Language" HTTP Response

Now try creating a few more programming languages using the Create Programming Language query, and then try the Get Programming Languages query:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response

If all is working well, you should see a similar list. But it would be nice to sort the results by name. Try adding a sortBy=name URL query parameter:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response using "sortBy" Parameter

Now try reversing the sort order by adding a sortDirection=descending URL query parameter:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response using "sortDirection" Parameter

Now let's try adding a few more filters... First, let's fetch where name=C:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response using "name" Parameter

Next, how about where name_contains=C:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response using "name_contains" Parameter

And where name_notContains=C:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response using "name_notContains" Parameter

Next, how about where name_containsPrefix=C:

Screenshot of RapidAPI.app "Get Programming Languages" HTTP Response using "name_containsPrefix" Parameter

Hopefully the power of a shared, flexible query interface is apparent. Of course, there are performance considerations to keep in mind at the database level. Opening up such a flexible query interface to clients can potentially lead to suboptimal queries that can impact database performance. However, as long as this limitation is known, this pattern can be very productive.



IV. Key Concepts

Resource

A Resource is the primary conceptual representation of an entity in an application's data schema.

Sources / App / Resources / ProgrammingLanguage.swift:

struct ProgrammingLanguage: CRUDResource {
	static let path = "programming_languages"
	typealias Model = ProgrammingLanguageModel
	typealias Read = ProgrammingLanguageReadDTO
	typealias Create = ProgrammingLanguageCreateDTO
	typealias Update = ProgrammingLanguageUpdateDTO
}

Resources typically hold no logic of their own, but rather serve as a container to define a group of types that are related to a conceptual entity.

Model

A Model represents the data structure that is stored as a table or collection in the database. Models are implemented using the Fluent ORM.

Sources / App / Models / ProgrammingLanguageModel.swift:

typealias ProgrammingLanguageModel = ProgrammingLanguageModelV1

final class ProgrammingLanguageModelV1: ResourceModel, @unchecked Sendable {
	static let schema = "programming_languages"
	
	@ID(key: .id)
	var id: UUID?

	@Field(key: "name")
	var name: String
	
	init() {}

	init(
		id: UUID? = nil,
		name: String
	) {
		self.id = id
		self.name = name
	}
	
	func toRead() throws -> ProgrammingLanguage.Read {
		return .init(
			id: try self.requireID(),
			name: self.name
		)
	}
}

In this project, typealiases are used to version model types (e.g., typealias ProgrammingLanguageModel = ProgrammingLanguageModelV1), as this allows the database schema to change over time in a way that doesn't break older migrations. The typealias (ProgrammingLanguageModel) is used in application code by default, whereas the versioned model type (ProgrammingLanguageModelV1) is used in migration-related code.

Models should be able to be converted into their Resource's ReadDTO-conforming type. This conversion is used whenever the service sends records back to clients.

DTOs

DTOs, or Data Transfer Objects, are separate types representing the data structure to be encoded/decoded by clients during create, read, and update operations. Maintaining separate types for Models and DTOs decouples the API from the database schema, allowing the schema to change without breaking the public interface.

Sources / App / DTOs / ProgrammingLanguageDTOs.swift:

struct ProgrammingLanguageReadDTO: ReadDTO {
	let id: UUID
	let name: String
}

struct ProgrammingLanguageCreateDTO: CreateDTO {
	let name: String
	
	func toModel() throws -> ProgrammingLanguage.Model {
		return .init(
			name: self.name
		)
	}
}

struct ProgrammingLanguageUpdateDTO: UpdateDTO {
	let name: String?
	
	func apply(to model: ProgrammingLanguage.Model) {
		if let name {
			model.name = name
		}
	}
}

CreateDTO-conforming types should be able to be converted into their Resource's Model type. This conversion is used when clients send a POST request to create a new record.

UpdateDTO-conforming types should be able to apply their updates to an instance of their Resource's Model type. This method is used when clients send a PUT request to update a record.

Controller

A Controller is a collection of routes related to a resource. Generally, a resource will have the following five routes to support all CRUD-like operations, but more routes can be added when necessary or convenient.

  • GET /programming_languages (Get a list of all programming languages)
  • GET /programming_languages/:id (Get a single programming language)
  • POST /programming_languages (Create a new programming language)
  • PUT /programming_languages/:id (Update a single programming language)
  • DELETE /programming_languages/:id (Delete a single programming language)

Sources / App / Controllers / ProgrammingLanguageController.swift:

struct ProgrammingLanguageController: Controller {
	typealias Resource = ProgrammingLanguage
	
	func boot(routes: RoutesBuilder) throws {
		let group = routes.grouped(.init(stringLiteral: Resource.path))
		group.get(use: self.index)
		group.post(use: self.create)
		group.group(":id") { item in
			item.get(use: self.find)
			item.put(use: self.update)
			item.delete(use: self.delete)
		}
	}

	@Sendable
	func index(request: Request) async throws -> [Resource.Read] {
		let query = Resource.Model.query(on: request.db)
		if let relationKeys = try request.getRelationKeys(definedBy: Resource.Model.self) {
			query.includeRelations(keyedBy: relationKeys)
		}
		if let filterKeys = try request.getFilterKeys(definedBy: Resource.Model.self) {
			try query.filter(by: filterKeys)
		}
		if let sortKeys = try request.getSortKeys(definedBy: Resource.Model.self) {
			try query.sort(by: sortKeys)
		}
		return try await query.all().map { try $0.toRead() }
	}
	
	@Sendable
	func find(request: Request) async throws -> Resource.Read {
		// ...
	}

	@Sendable
	func create(request: Request) async throws -> Resource.Read {
		// ...
	}
	
	@Sendable
	func update(request: Request) async throws -> Resource.Read {
		// ...
	}

	@Sendable
	func delete(request: Request) async throws -> HTTPStatus {
		// ...
	}
}

The index method makes use of the shared, flexible query interface for filtering, sorting, and including associations by default. Queryable properties are specified by conforming to the Queryable protocol:

Sources / App / Models / ProgrammingLanguageModel+Queryable.swift:

extension ProgrammingLanguageModel: Queryable {
	enum FilterKeys: String, CaseIterable, QueryKey, TypedQueryKey {
		case name
		// ...
	}
	
	enum SortKeys: String, CaseIterable, QueryKey {
		case name
		// ...
	}
	
	enum RelationKeys: CaseIterable {}
}

Migration

A Migration represents a set of changes to the database schema. In the context of a Resource, migrations can be used to create its backing database table, to add or remove columns as the model evolves over time, to seed data, or even to remove the table if it is no longer needed.

Sources / App / Migrations / CreateProgrammingLanguage.swift:

struct CreateProgrammingLanguage: AsyncMigration {
	func prepare(on database: Database) async throws {
		try await database.schema("programming_languages")
			.id()
			.field("name", .string, .required)
			.create()
	}

	func revert(on database: Database) async throws {
		try await database.schema("programming_languages").delete()
	}
}

In this project, versioned model types (e.g., ProgrammingLanguageModelV1) are used when referring to a Model type in a migration. This allows historical migrations to continue to compile as the database schema evolves over time.

Middleware

Model middleware are used to allow hooking into a Model's lifecycle events. This is useful when extra steps need to be taken before a Model instance is saved to, updated in, or deleted from the database. One common scenario is to add logging whenever records are created, updated, or deleted.

Sources / App / Middleware / ProgrammingLanguageModelMiddleware.swift:

struct ProgrammingLanguageModelMiddleware: AsyncModelMiddleware {
	func create(
		model: ProgrammingLanguageModel,
		on db: Database,
		next: AnyAsyncModelResponder
	) async throws {
		// Add code before create...
		try await next.create(model, on: db)
	}
	
	func update(
		model: ProgrammingLanguageModel,
		on db: any Database,
		next: any AnyAsyncModelResponder
	) async throws {
		// Add code before update...
		try await next.update(model, on: db)
	}
	
	func delete(
		model: ProgrammingLanguageModel,
		force: Bool,
		on db: any Database,
		next: any AnyAsyncModelResponder
	) async throws {
		// Add code before delete...
		try await next.delete(model, force: force, on: db)
	}
}


V. Testing

Tests are implemented using Swift Testing. To make it easier to arrange test data within a test case, factories are implemented to allow for quick and flexible instantiation of Models and DTOs.

Tests / ControllerTests / API / V1 / ProgrammingLanguageControllerTests.swift:

@Test("GET /api/v1/programming_languages")
func index() async throws {
	let factory = Resource.ModelFactory(db: app.db)
	for _ in 0..<5 { try await factory.make() }
	
	let response = try await perform(.GET, path)
	#expect(response.status == .ok)
	
	let reads = try response.content.decode([Resource.Read].self)
	#expect(reads.count == 5)
}

Tests / Support / Factories / ProgrammingLanguageFactories.swift:

struct ProgrammingLanguageModelFactory {
	let db: Database

	@discardableResult
	func make(
		name: String = "Swift"
	) async throws -> ProgrammingLanguage.Model {
		let programmingLanguage = ProgrammingLanguage.Model(
			name: name
		)
		try await programmingLanguage.save(on: db)
		return programmingLanguage
	}
}

Controller tests are particularly useful for quickly testing various query scenarios, such as the following:

?name=Swift
?name_contains=wif
?name_containsPrefix=Sw
?name_containsSuffix=ft
?name_notEq=Objective-C
?age=10
?age_gt=9
?age_gte=10
?age_lt=11
?age_lte=10
?sortBy=name
?sortBy=name&sortDirection=descending
etc...

For example:

@Test("?name_contains=C")
func filterByNameContains() async throws {
	let factory = Resource.ModelFactory(db: app.db)
	try await factory.make(name: "Swift")
	try await factory.make(name: "Objective-C")
	try await factory.make(name: "C")
	try await factory.make(name: "C++")
	try await factory.make(name: "Go")
	
	let response = try await perform(.GET, "\(path)?name_contains=C")
	#expect(response.status == .ok)
	
	let reads = try response.content.decode([Resource.Read].self)
	#expect(reads.count == 3)
}


VI. Troubleshooting

Why Doesn't It Work?

  • Check your database configuration in configure.swift. Is there a database running at that location with the specified name?
  • Are you running on macOS 14?
  • For general Vapor/Fluent questions, check out the Vapor Discord


VII. FAQs

Does it work on Linux?
I don't know, I haven't tested it. But probably!

Does it work with Swift < 6?
I don't know, I haven't tested it. But probably not, because of Swift Testing requirements.

Does it work with Xcode < 16?
I don't know, I haven't tested it. But probably not, because of Swift Testing requirements.

Does it work with other Swift server frameworks besides Vapor?
I don't know, I haven't tested it. But as long as that framework integrates with Fluent, it should (with relatively minimal code changes).

About

Swift Vapor API Starter Template

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published