Skip to content

Commit

Permalink
Macros (#91)
Browse files Browse the repository at this point in the history
## Now with Macros!

Adds a few macros to streamline building with Alchemy.

### Routing

Use `@{METHOD}` to generate a route handler that automatically pulls function parameters from incoming requests and converts the return type to a response. Annotating your Application or Controller will automatically register the handlers, or you can access them via `$nameOfTheFunction`.

```swift
@Application
struct App {
    
    @post("/user")
    func createUser(email: String, password: String) async throws -> User {
        // create user with parsed `email & `password`
    }
}
```

Automatically validate parameters with `@Validate` and a library (Coming Soon™️) of built in validators.

```swift
@controller
struct UserController {
    
    @patch("/email")
    func updateEmail(@Validate(.email) email: String) async throws {
        // email is valid!
    }
}
```

### Jobs

Quickly create a background job with just a function (global or static). Use the `$` prefix to refer to the job.

```swift
@job
func sendInvoice(email: String) async throws {
    ...
}

// run in foreground
try await sendInvoice(email: "[email protected]") 

// run as a background job
try await $sendInvoice(email: "[email protected]").dispatch()
```

### Rune

Define models with the `@Model` macro.

```swift
@model
struct User {
    var id: UUID
    var email: String
    var name: String
}
```

This unlocks some major quality of life improvements including type safe, `KeyPath`-based query builders which are Coming Soon™️

#### Relationships

Relationships look similar but get some powerful quality of life improvements including better join column inference, CRUD functions, and the ability to be defined in extensions.

```swift
struct Todo {
    var id: Int
    var name: String
    var isDone = false
   
    @BelongsToMany var tags: [Tag]
}

extension User {
    @hasmany var tags: [Tag]
}

// eager load with `$relationship`
User.query().with(\.$todos)

// chain relationships for nested loads (this loads todos & tags)
User.query().with(\.$todos.$tags)

// associates a todo with a user
let todo = Todo(name: "do laundry")
try await user.$todos.connect(todo)
```

If loaded, relationships are automatically encoded from route handlers.

```swift
@get("/todos")
func getTodos() -> [Todo] {
    try await Todo.with(\.$tags).all()
}
```
```json
[
    {
        "id": 1,
        "name": "Build great backends with Alchemy",
        "tags": [
            {
                "id": 1,
                "name": "Coding"
            },
            {
                "id": 2,
                "name": "Extremely Fun"
            }
        ]
    },
    {
        "id": 2,
        "name": "Make cookies",
        "tags": [
            {
                "id": 3,
                "name": "Cooking"
            }
        ]
    }
]
```
  • Loading branch information
joshuawright11 authored Jun 23, 2024
1 parent f6607a6 commit eaa8e94
Show file tree
Hide file tree
Showing 132 changed files with 3,313 additions and 981 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@ on:

jobs:
test-macos:
runs-on: macos-13
runs-on: macos-14
env:
DEVELOPER_DIR: /Applications/Xcode_14.3.app/Contents/Developer
DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer
steps:
- uses: actions/checkout@v3
- name: Build
run: swift build -v
- name: Run tests
run: swift test -v
test-linux:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
strategy:
matrix:
swift: [5.8]
swift: ["5.10"]
container: swift:${{ matrix.swift }}
steps:
- uses: actions/checkout@v3
Expand Down
128 changes: 128 additions & 0 deletions Alchemy/AlchemyMacros.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
@attached(extension, conformances: Application, Controller, names: named(route))
public macro Application() = #externalMacro(module: "AlchemyPlugin", type: "ApplicationMacro")

@attached(extension, conformances: Controller, names: named(route))
public macro Controller() = #externalMacro(module: "AlchemyPlugin", type: "ControllerMacro")

@attached(peer, names: prefixed(`$`))
public macro Job() = #externalMacro(module: "AlchemyPlugin", type: "JobMacro")

// MARK: Rune - Model

@attached(memberAttribute)
@attached(member, names: named(storage), named(fieldLookup))
@attached(extension, conformances: Model, Codable, names: named(init), named(fields), named(encode))
public macro Model() = #externalMacro(module: "AlchemyPlugin", type: "ModelMacro")

@attached(accessor)
public macro ID() = #externalMacro(module: "AlchemyPlugin", type: "IDMacro")

// MARK: Rune - Relationships

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro HasMany(from: String? = nil, to: String? = nil) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro HasManyThrough(
_ through: String,
from: String? = nil,
to: String? = nil,
throughFrom: String? = nil,
throughTo: String? = nil
) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro HasOne(from: String? = nil, to: String? = nil) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro HasOneThrough(
_ through: String,
from: String? = nil,
to: String? = nil,
throughFrom: String? = nil,
throughTo: String? = nil
) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro BelongsTo(from: String? = nil, to: String? = nil) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro BelongsToThrough(
_ through: String,
from: String? = nil,
to: String? = nil,
throughFrom: String? = nil,
throughTo: String? = nil
) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

@attached(accessor)
@attached(peer, names: prefixed(`$`))
public macro BelongsToMany(
_ pivot: String? = nil,
from: String? = nil,
to: String? = nil,
pivotFrom: String? = nil,
pivotTo: String? = nil
) = #externalMacro(module: "AlchemyPlugin", type: "RelationshipMacro")

// MARK: Route Methods

@attached(peer, names: prefixed(`$`)) public macro HTTP(_ method: String, _ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro DELETE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro GET(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro PATCH(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro POST(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro PUT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro OPTIONS(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro HEAD(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro TRACE(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")
@attached(peer, names: prefixed(`$`)) public macro CONNECT(_ path: String, options: RouteOptions = []) = #externalMacro(module: "AlchemyPlugin", type: "HTTPMethodMacro")

// MARK: Route Parameters

@propertyWrapper public struct Path<L: LosslessStringConvertible> {
public var wrappedValue: L

public init(wrappedValue: L) {
self.wrappedValue = wrappedValue
}
}

@propertyWrapper public struct Header<L: LosslessStringConvertible> {
public var wrappedValue: L

public init(wrappedValue: L) {
self.wrappedValue = wrappedValue
}
}

@propertyWrapper public struct URLQuery<L: LosslessStringConvertible> {
public var wrappedValue: L

public init(wrappedValue: L) {
self.wrappedValue = wrappedValue
}
}

@propertyWrapper public struct Field<C: Codable> {
public var wrappedValue: C

public init(wrappedValue: C) {
self.wrappedValue = wrappedValue
}
}

@propertyWrapper public struct Body<C: Codable> {
public var wrappedValue: C

public init(wrappedValue: C) {
self.wrappedValue = wrappedValue
}
}

119 changes: 119 additions & 0 deletions Alchemy/AlchemyX/Application+AlchemyX.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import AlchemyX

extension Application {
@discardableResult
public func useAlchemyX(db: Database = DB) -> Self {
// 1. users table

db.migrations.append(UserMigration())
db.migrations.append(TokenMigration())

// 2. users endpoint

return use(AuthController())
}
}

private struct AuthController: Controller, AuthAPI {
func route(_ router: Router) {
registerHandlers(on: router)
}

func signUp(email: String, password: String) async throws -> AuthResponse {
let password = try await Hash.make(password)
let user = try await User(email: email, password: password).insertReturn()
let token = try await Token(userId: user.id).insertReturn()
return .init(token: token.value, user: user.dto)
}

func signIn(email: String, password: String) async throws -> AuthResponse {
guard let user = try await User.firstWhere("email" == email) else {
throw HTTPError(.notFound)
}

guard try await Hash.verify(password, hash: user.password) else {
throw HTTPError(.unauthorized)
}

let token = try await Token(userId: user.id).insertReturn()
return .init(token: token.value, user: user.dto)
}

func signOut() async throws {
try await token.delete()
}

func getUser() async throws -> AlchemyX.User {
try user.dto
}

func updateUser(email: String?, phone: String?, password: String?) async throws -> AlchemyX.User {
var user = try user
if let email { user.email = email }
if let phone { user.phone = phone }
if let password { user.password = try await Hash.make(password) }
return try await user.save().dto
}
}

extension Controller {
var req: Request { .current }
fileprivate var user: User { get throws { try req.get() } }
fileprivate var token: Token { get throws { try req.get() } }
}

@Model
struct Token: TokenAuthable {
var id: UUID
var value: String = UUID().uuidString
let userId: UUID

@BelongsTo var user: User
}

@Model
struct User {
var id: UUID
var email: String
var password: String
var phone: String?

@HasMany var tokens: [Token]

var dto: AlchemyX.User {
AlchemyX.User(
id: id,
email: email,
phone: phone
)
}
}

struct TokenMigration: Migration {
func up(db: Database) async throws {
try await db.createTable("tokens") {
$0.uuid("id").primary()
$0.string("value").notNull()
$0.uuid("user_id").references("id", on: "users").notNull()
}
}

func down(db: Database) async throws {
try await db.dropTable("tokens")
}
}

struct UserMigration: Migration {
func up(db: Database) async throws {
try await db.createTable("users") {
$0.uuid("id").primary()
$0.string("email").notNull()
$0.string("password").notNull()
$0.string("phone")
}
}

func down(db: Database) async throws {
try await db.dropTable("users")
}
}
Loading

0 comments on commit eaa8e94

Please sign in to comment.