skyward-api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +561 -0
- data/lib/skyward/api/version.rb +7 -0
- data/lib/skyward/api.rb +9 -0
- data/sig/generated/skyward/api/version.rbs +7 -0
- data/sig/generated/skyward/api.rbs +2 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9d81b4c057774384182e54484943d5c399000104814ab7ea53bfeb3ec3be5487
|
|
4
|
+
data.tar.gz: f11cad01ffa40ee65447c0c54a39572368e33002d57a0efd20221909d74521fa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d608db62654a3b75b2b078370770e032810455570485e2b94430813b55a6988f1e40af5276016ba521c1df2cabcbba8531491d46ec31c08a624b45c4eb7152a8
|
|
7
|
+
data.tar.gz: 8ebb4c4ba506bca8f1da700ef7f596d3c803c78df7000b35a89d93e21c0c8fd9aada860797ebeeabfde51195de73dd3629687e8b28dbb004e22413fc49f977f4
|
data/README.md
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# Skyward API
|
|
2
|
+
|
|
3
|
+
> [!CAUTION]
|
|
4
|
+
> **Work in progress: Skyward API doesn't do anything yet.** Version 0.1 is a skeleton. The gems install and load, but none of the features described here are built. This README is the spec for what's coming, not documentation of what exists. Don't depend on it yet.
|
|
5
|
+
|
|
6
|
+
A small, fast Ruby web framework where **an endpoint is a method**.
|
|
7
|
+
|
|
8
|
+
Its inputs are keyword arguments. Its response is the return value. Add a type signature and Skyward API uses it to coerce and validate the request, check your code with Steep, and write your OpenAPI docs for you. Leave the signature off and it's still just Ruby.
|
|
9
|
+
|
|
10
|
+
Skyward API is half of Skyward. The other half is [Skyward ORM](../skyward-orm/README.md), but each works without the other.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# config.ru
|
|
14
|
+
require "skyward/api"
|
|
15
|
+
|
|
16
|
+
class Hello < Skyward::API
|
|
17
|
+
get "/hello/:name", to: :greet
|
|
18
|
+
|
|
19
|
+
def greet(name:, shout: "false")
|
|
20
|
+
message = "Hello, #{name}"
|
|
21
|
+
{ message: shout == "true" ? message.upcase : message }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
run Skyward.app { |app| app.mount Hello.new }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
$ skyward serve
|
|
30
|
+
$ curl localhost:9292/hello/ruby?shout=true
|
|
31
|
+
{"message":"HELLO, RUBY"}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's the whole framework. The rest of this README is detail.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Contents
|
|
39
|
+
|
|
40
|
+
- [Install](#install)
|
|
41
|
+
- [Endpoints](#endpoints)
|
|
42
|
+
- [Inputs](#inputs)
|
|
43
|
+
- [Responses](#responses)
|
|
44
|
+
- [Types: the bonus that pays rent](#types-the-bonus-that-pays-rent)
|
|
45
|
+
- [OpenAPI docs](#openapi-docs)
|
|
46
|
+
- [Building an app](#building-an-app)
|
|
47
|
+
- [Providers](#providers)
|
|
48
|
+
- [Errors](#errors)
|
|
49
|
+
- [Testing](#testing)
|
|
50
|
+
- [Production](#production)
|
|
51
|
+
- [CLI](#cli)
|
|
52
|
+
- [Coming from…](#coming-from)
|
|
53
|
+
- [Design principles](#design-principles)
|
|
54
|
+
- [Status](#status)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# Gemfile
|
|
62
|
+
gem "skyward-api"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Skyward API supports every Ruby that isn't end-of-life (3.3 and later, as of November 2026), and runs on any Rack 3 server. It recommends [Falcon](https://github.com/socketry/falcon) or Puma. `skyward serve` runs Falcon, so add `gem "falcon"` to use it.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Endpoints
|
|
70
|
+
|
|
71
|
+
An endpoint is a public method. The class lists its routes at the top, and each route names the method it calls.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class TodosAPI < Skyward::API
|
|
75
|
+
get "/", to: :index, summary: "List todos, newest first."
|
|
76
|
+
get "/:id", to: :show, summary: "Fetch a single todo."
|
|
77
|
+
|
|
78
|
+
def initialize(todos:)
|
|
79
|
+
super()
|
|
80
|
+
@todos = todos
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def index(done: nil, limit: "50")
|
|
84
|
+
@todos.list(done:, limit: Integer(limit))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def show(id:)
|
|
88
|
+
@todos.find(Integer(id)) || not_found
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- `Skyward::API` gives the class `get`, `post`, `put`, `patch` and `delete`, and gives its instances the response helpers below. It has no initializer or state of its own, so the initializer is yours, and so are the instance variables.
|
|
94
|
+
- Routes go at the top of the class, so it opens with a table of everything it serves. Each names its method with `to:`, and two routes can name the same one. Below the routes, an endpoint is an ordinary method, written and typed like any other.
|
|
95
|
+
- A route that names a method the class doesn't have, or a path parameter the method doesn't take, fails at boot.
|
|
96
|
+
- An API class doesn't know where it lives. You choose the prefix when you mount it (`at: "/todos"`).
|
|
97
|
+
- Each API object is created **once**, at boot, and shared by every request. Per-request data arrives as arguments, never as instance state, so your handlers are thread- and fiber-safe by default.
|
|
98
|
+
- A route's `summary:` is its documentation, in the [OpenAPI docs](#openapi-docs). It's optional, and it's a plain Ruby string: Skyward API never reads comments.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Inputs
|
|
103
|
+
|
|
104
|
+
Skyward API fills each keyword argument by its name:
|
|
105
|
+
|
|
106
|
+
| Keyword | Filled from |
|
|
107
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
108
|
+
| a path parameter (`id:` for `/:id`) | the URL path |
|
|
109
|
+
| `body:` | the parsed JSON request body |
|
|
110
|
+
| `request:` | the raw `Skyward::Request` (an escape hatch for headers, cookies and the IP) |
|
|
111
|
+
| a [provided](#providers) name (`current_user:`) | your provider |
|
|
112
|
+
| anything else | the query string |
|
|
113
|
+
|
|
114
|
+
Ruby already has syntax for required and optional, so Skyward API uses it. A keyword with no default is required: if it's missing, the request fails with a 422 before your method runs. A keyword with a default is optional.
|
|
115
|
+
|
|
116
|
+
Without types, path and query values arrive as `String`s and `body:` arrives as a `Hash` with symbol keys. That makes it a good fit for pattern matching:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
post "/", to: :create
|
|
120
|
+
|
|
121
|
+
def create(body:)
|
|
122
|
+
case body
|
|
123
|
+
in { title: String => title } then created @todos.create(title:)
|
|
124
|
+
else unprocessable "title is required"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Responses
|
|
132
|
+
|
|
133
|
+
Return a value and Skyward API turns it into a response. There's no `render`, no `halt`, and nothing is raised for an ordinary 404.
|
|
134
|
+
|
|
135
|
+
| Return | Status | Helper |
|
|
136
|
+
| -------------------------------------------------- | ------------------ | ------------------------- |
|
|
137
|
+
| a `Hash`, `Array`, `Data`, or anything with `to_h` | 200, JSON | — |
|
|
138
|
+
| `Skyward::Created[T]` | 201, JSON | `created(value)` |
|
|
139
|
+
| `Skyward::NoContent` | 204 | `no_content` |
|
|
140
|
+
| `Skyward::BadRequest` | 400, problem+json | `bad_request(detail)` |
|
|
141
|
+
| `Skyward::Unauthorized` | 401, problem+json | `unauthorized(detail)` |
|
|
142
|
+
| `Skyward::Forbidden` | 403, problem+json | `forbidden(detail)` |
|
|
143
|
+
| `Skyward::NotFound` | 404, problem+json | `not_found(detail = nil)` |
|
|
144
|
+
| `Skyward::Conflict` | 409, problem+json | `conflict(detail)` |
|
|
145
|
+
| `Skyward::Unprocessable` | 422, problem+json | `unprocessable(detail)` |
|
|
146
|
+
| `Skyward::Redirect` | 303 (or `status:`) | `redirect(to)` |
|
|
147
|
+
| a Rack triple `[status, headers, body]` | as given | — |
|
|
148
|
+
|
|
149
|
+
Every helper takes `headers:`:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
created todo, headers: { "location" => "/todos/#{todo.id}" }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Error responses use [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json`:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"type": "about:blank",
|
|
160
|
+
"title": "Not Found",
|
|
161
|
+
"status": 404,
|
|
162
|
+
"detail": "Todo 42 does not exist"
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Types: the bonus that pays rent
|
|
169
|
+
|
|
170
|
+
Everything above works without types. Add a signature and it does more:
|
|
171
|
+
|
|
172
|
+
- **Coercion.** `"42"` in the path becomes `42` when the signature says `Integer`.
|
|
173
|
+
- **Validation.** A body that doesn't match its type is rejected with a 422 that says exactly what was wrong, before your method runs.
|
|
174
|
+
- **Docs.** The signature becomes the OpenAPI operation, including every possible response.
|
|
175
|
+
- **Checking.** Steep checks your handlers like any other Ruby code, because they _are_ ordinary Ruby code.
|
|
176
|
+
|
|
177
|
+
Signatures are plain [RBS](https://github.com/ruby/rbs). Write them inline with [rbs-inline](https://github.com/soutaro/rbs-inline), or in `sig/*.rbs` files. Skyward API reads both, from the `.rbs` files under `sig/` (or the directory you pass as `Skyward.app(signatures:)`), so run rbs-inline before you boot. There's no schema DSL to learn and nothing to keep in sync: **the type you write for Steep is the API contract.**
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# rbs_inline: enabled
|
|
181
|
+
# @rbs use Skyward::*
|
|
182
|
+
|
|
183
|
+
Todo = Data.define(
|
|
184
|
+
:id, #: Integer
|
|
185
|
+
:title, #: String
|
|
186
|
+
:priority, #: :low | :normal | :high
|
|
187
|
+
:due_on, #: Date?
|
|
188
|
+
:done #: bool
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
NewTodo = Data.define(
|
|
192
|
+
:title, #: String
|
|
193
|
+
:priority, #: :low | :normal | :high
|
|
194
|
+
:due_on #: Date?
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
class TodosAPI < Skyward::API
|
|
198
|
+
description "Things to do, and the satisfaction of ticking them off."
|
|
199
|
+
|
|
200
|
+
get "/", to: :index, summary: "List todos, newest first."
|
|
201
|
+
get "/:id", to: :show, summary: "Fetch a single todo."
|
|
202
|
+
post "/", to: :create, summary: "Create a todo."
|
|
203
|
+
post "/:id/complete", to: :complete, summary: "Mark a todo as done.", description: <<~TEXT
|
|
204
|
+
Marking a todo that's already done is fine: it stays done, and the
|
|
205
|
+
response is the same.
|
|
206
|
+
TEXT
|
|
207
|
+
delete "/:id", to: :destroy, summary: "Delete a todo."
|
|
208
|
+
|
|
209
|
+
# @rbs @todos: TodoStore
|
|
210
|
+
|
|
211
|
+
# @rbs todos: TodoStore
|
|
212
|
+
# @rbs return: void
|
|
213
|
+
def initialize(todos:)
|
|
214
|
+
super()
|
|
215
|
+
@todos = todos
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @rbs done: bool?
|
|
219
|
+
# @rbs limit: Integer
|
|
220
|
+
# @rbs return: Array[Todo]
|
|
221
|
+
def index(done: nil, limit: 50)
|
|
222
|
+
@todos.list(done:, limit:)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @rbs id: Integer
|
|
226
|
+
# @rbs return: Todo | NotFound
|
|
227
|
+
def show(id:)
|
|
228
|
+
@todos.find(id) || not_found
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @rbs body: NewTodo
|
|
232
|
+
# @rbs return: Created[Todo]
|
|
233
|
+
def create(body:)
|
|
234
|
+
created @todos.create(**body.to_h)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @rbs id: Integer
|
|
238
|
+
# @rbs return: Todo | NotFound
|
|
239
|
+
def complete(id:)
|
|
240
|
+
@todos.complete(id) || not_found
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @rbs id: Integer
|
|
244
|
+
# @rbs return: NoContent | NotFound
|
|
245
|
+
def destroy(id:)
|
|
246
|
+
@todos.delete(id) ? no_content : not_found
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`body:` is now a real `NewTodo`, not a `Hash`. `id:` is an `Integer`. The union return type `Todo | NotFound` tells the docs this endpoint can answer with a 200 or a 404. `# @rbs use Skyward::*` at the top of the file lets the signatures say `Created[Todo]`, `NotFound` and `NoContent` without the `Skyward::` prefix.
|
|
252
|
+
|
|
253
|
+
### How types map
|
|
254
|
+
|
|
255
|
+
| RBS | Accepts | OpenAPI |
|
|
256
|
+
| -------------------------- | ----------------------------------------------------------------- | ---------------------------------------- |
|
|
257
|
+
| `String` | string | `string` |
|
|
258
|
+
| `Integer` | `42`, or `"42"` in a path or query | `integer` |
|
|
259
|
+
| `Float` | number | `number` |
|
|
260
|
+
| `bool` | `true` / `false`, or `"true"`, `"false"`, `"1"`, `"0"` in a query | `boolean` |
|
|
261
|
+
| `Date`, `Time` | ISO 8601 strings | `string`, format `date` / `date-time` |
|
|
262
|
+
| `T?` | absent or `null` | not required / nullable |
|
|
263
|
+
| `:low \| :high` | `"low"` or `"high"`, given to you as a Symbol | `enum` |
|
|
264
|
+
| `Array[T]` | array; repeated keys in a query (`?tag=a&tag=b`) | `array` |
|
|
265
|
+
| `Hash[String, T]` | object | `object` with `additionalProperties` |
|
|
266
|
+
| `{ id: Integer }` (record) | object | inline `object` |
|
|
267
|
+
| a class with typed readers | object | `object` component, referenced by `$ref` |
|
|
268
|
+
| `A \| B` (such classes) | either | `oneOf` |
|
|
269
|
+
|
|
270
|
+
A validation failure looks like this:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"type": "about:blank",
|
|
275
|
+
"title": "Unprocessable Content",
|
|
276
|
+
"status": 422,
|
|
277
|
+
"errors": [
|
|
278
|
+
{
|
|
279
|
+
"at": "body.priority",
|
|
280
|
+
"message": "expected :low | :normal | :high, got \"urgent\""
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"at": "body.due_on",
|
|
284
|
+
"message": "expected Date (YYYY-MM-DD), got \"next tuesday\""
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Mixing typed and untyped is fine. An endpoint without a signature behaves exactly as described in [Inputs](#inputs) and shows up in the docs with untyped parameters.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## OpenAPI docs
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
Skyward.app(docs: true) { ... }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
| Path | Serves |
|
|
301
|
+
| --------------- | ---------------------------- |
|
|
302
|
+
| `/openapi.json` | the OpenAPI 3.1 document |
|
|
303
|
+
| `/docs` | an interactive API reference |
|
|
304
|
+
|
|
305
|
+
Skyward API builds the document from:
|
|
306
|
+
|
|
307
|
+
- **Routes**, which become paths and operations.
|
|
308
|
+
- **Signatures**, which become parameters, request bodies, and one response per member of the return type.
|
|
309
|
+
- **The documentation you write in Ruby**, shown below.
|
|
310
|
+
- **Classes with typed readers**, such as `Data` classes or Skyward ORM records, which become reusable components.
|
|
311
|
+
- **Providers**, whose failure responses are added to every endpoint that uses them (see below).
|
|
312
|
+
|
|
313
|
+
### Documenting your API
|
|
314
|
+
|
|
315
|
+
Documentation is plain Ruby values, written where the thing it describes is declared. Every piece is optional.
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
App = Skyward.app(
|
|
319
|
+
docs: true,
|
|
320
|
+
title: "Best Todos Ever",
|
|
321
|
+
version: "1.0.0",
|
|
322
|
+
description: "Lists of things to do, and the todos on them."
|
|
323
|
+
) { ... }
|
|
324
|
+
|
|
325
|
+
class TodosAPI < Skyward::API
|
|
326
|
+
description "Things to do, and the satisfaction of ticking them off."
|
|
327
|
+
|
|
328
|
+
get "/", to: :index, summary: "List todos, newest first."
|
|
329
|
+
post "/:id/complete", to: :complete, summary: "Mark a todo as done.", description: <<~TEXT
|
|
330
|
+
Marking a todo that's already done is fine: it stays done, and the
|
|
331
|
+
response is the same.
|
|
332
|
+
TEXT
|
|
333
|
+
|
|
334
|
+
def index(done: nil, limit: 50) = ...
|
|
335
|
+
def complete(id:) = ...
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
| Where | Says | Becomes |
|
|
340
|
+
| ------------------------------ | --------------------------------------- | ------------------------------------------------------------------ |
|
|
341
|
+
| `Skyward.app(title:, version:)` | what the API is | the document's `info`, and the heading of `/docs` |
|
|
342
|
+
| `Skyward.app(description:)` | more about the API | the introduction on `/docs` |
|
|
343
|
+
| `description "..."` in a class | what this group of endpoints is for | its tag's description: a section of `/docs` |
|
|
344
|
+
| `summary:` on a route | what the endpoint does, in one line | the operation's summary |
|
|
345
|
+
| `description:` on a route | anything else a caller needs to know | the operation's description |
|
|
346
|
+
|
|
347
|
+
A `summary:` is one line, and Skyward API refuses to boot if it has a line break. A `description:` is Markdown.
|
|
348
|
+
|
|
349
|
+
Comments are never read. They're for people reading the Ruby, and they never reach your docs, so documentation can't hide in a comment, and a comment can't change what you publish.
|
|
350
|
+
|
|
351
|
+
Docs are served only when you pass `docs: true`, so leave it out to hide them. The document is built once, at boot. `/docs` renders it with [Scalar](https://github.com/scalar/scalar), loaded from the jsDelivr CDN at a pinned version. The document is your API's contract with the code that calls it. Skyward API doesn't write clients: write the document at build time, and generate a client from it with whatever tooling suits that code:
|
|
352
|
+
|
|
353
|
+
```sh
|
|
354
|
+
$ skyward openapi > openapi.json
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Building an app
|
|
360
|
+
|
|
361
|
+
`Skyward.app` is your composition root. It's where real collaborators are created and passed in, and the only place that knows how the pieces fit.
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
# app.rb
|
|
365
|
+
require "skyward/api"
|
|
366
|
+
require_relative "todos_api"
|
|
367
|
+
|
|
368
|
+
App = Skyward.app(docs: true) do |app|
|
|
369
|
+
todos = TodoStore.new(database: DB)
|
|
370
|
+
|
|
371
|
+
app.use Rack::Deflater
|
|
372
|
+
app.static "/", from: "public"
|
|
373
|
+
|
|
374
|
+
app.mount TodosAPI.new(todos:), at: "/todos"
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
# config.ru
|
|
380
|
+
require_relative "app"
|
|
381
|
+
run App
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- `mount` attaches an API object at a prefix.
|
|
385
|
+
- `use` is ordinary Rack middleware.
|
|
386
|
+
- `static` serves files from a directory.
|
|
387
|
+
- `Skyward.app` returns a frozen Rack app. Nothing about it changes after boot.
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Providers
|
|
392
|
+
|
|
393
|
+
Some inputs don't come from the request directly. The current user is one. Register a provider, and any endpoint that asks for that keyword gets its value:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
class CurrentUser
|
|
397
|
+
# @rbs @sessions: Sessions
|
|
398
|
+
|
|
399
|
+
# @rbs sessions: Sessions
|
|
400
|
+
# @rbs return: void
|
|
401
|
+
def initialize(sessions:)
|
|
402
|
+
@sessions = sessions
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# @rbs request: Skyward::Request
|
|
406
|
+
# @rbs return: User | Skyward::Unauthorized
|
|
407
|
+
def call(request)
|
|
408
|
+
@sessions.user_for(request.cookies["session"]) || Skyward.unauthorized("Sign in first")
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
App = Skyward.app do |app|
|
|
414
|
+
app.provide :current_user, CurrentUser.new(sessions: Sessions.new)
|
|
415
|
+
app.mount TodosAPI.new(todos:), at: "/todos"
|
|
416
|
+
end
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
get "/", to: :index
|
|
421
|
+
|
|
422
|
+
# @rbs current_user: User
|
|
423
|
+
# @rbs return: Array[Todo]
|
|
424
|
+
def index(current_user:)
|
|
425
|
+
@todos.for(current_user)
|
|
426
|
+
end
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
- A provider is anything that responds to `call(request)`, such as an object, a lambda or a method.
|
|
430
|
+
- If a provider returns a response (like `Unauthorized`), the request stops there and your method never runs. Authentication is just a provider.
|
|
431
|
+
- Because the provider has a signature, Skyward API knows that every endpoint asking for `current_user:` can respond with a 401, and documents it.
|
|
432
|
+
- Provided values never appear as query parameters.
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Errors
|
|
437
|
+
|
|
438
|
+
Skyward API checks your app thoroughly at boot, so mistakes surface when you start it, not at 3am:
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
Skyward::BootError: TodosAPI#show (GET /todos/:id)
|
|
442
|
+
The route has a path parameter :id, but the method has no `id:` keyword.
|
|
443
|
+
|
|
444
|
+
get "/:id", to: :show
|
|
445
|
+
...
|
|
446
|
+
def show(todo_id:)
|
|
447
|
+
^^^^^^^^
|
|
448
|
+
Did you mean `def show(id:)`?
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Boot also catches a route that names a method the class doesn't have, a route that shadows another, an unknown type in a signature, and a keyword that matches nothing. With `strict: true`, it also catches an endpoint that has no signature at all.
|
|
452
|
+
|
|
453
|
+
When your code raises at runtime, Skyward API responds with a 500 problem+json that carries the request ID and logs the exception. In development, the response also includes the backtrace.
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Testing
|
|
458
|
+
|
|
459
|
+
Endpoints are methods on plain objects, so the fastest test is a method call:
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
describe TodosAPI do
|
|
463
|
+
it "answers 404 for a todo that doesn't exist" do
|
|
464
|
+
api = TodosAPI.new(todos: FakeTodos.new)
|
|
465
|
+
|
|
466
|
+
assert_instance_of Skyward::NotFound, api.show(id: 404)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
No server, no Rack, no mocks. You pass the fake in through the front door.
|
|
472
|
+
|
|
473
|
+
When you want the whole stack, including routing, coercion, providers and middleware, use the test client:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
describe "POST /todos" do
|
|
477
|
+
let(:client) { Skyward::Test.client(App) }
|
|
478
|
+
|
|
479
|
+
it "rejects a priority that doesn't exist" do
|
|
480
|
+
response = client.post "/todos", json: { title: "Ship it", priority: "urgent" }
|
|
481
|
+
|
|
482
|
+
assert_equal 422, response.status
|
|
483
|
+
assert_equal "body.priority", response.json[:errors].first[:at]
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`Skyward::Test.client` works with Minitest and RSpec, and with any Rack app.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Production
|
|
493
|
+
|
|
494
|
+
Here's what you get without configuring anything:
|
|
495
|
+
|
|
496
|
+
- **Boot does the work. Requests don't.** Routes are compiled into a single matcher, and signatures are compiled into coercers and validators, all at boot. API objects are created once. A request is a route lookup, a coercion and a method call.
|
|
497
|
+
- **Request IDs.** Skyward API honours an incoming `X-Request-Id`, generates one when it's missing, and returns it on every response.
|
|
498
|
+
- **Structured logs.** Every request logs one JSON line with its method, path, status, duration and request ID. Pass your own with `Skyward.app(logger: ...)`.
|
|
499
|
+
- **Safe errors.** In production, a 500 never leaks a backtrace.
|
|
500
|
+
- **Strict mode.** Use `Skyward.app(strict: true)` and the app refuses to boot if any endpoint is untyped.
|
|
501
|
+
- **YJIT-friendly.** Hot paths are plain method calls with stable object shapes, with no `instance_exec` and no `method_missing`.
|
|
502
|
+
|
|
503
|
+
Skyward API doesn't manage processes. Run it under Falcon or Puma like any Rack app.
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## CLI
|
|
508
|
+
|
|
509
|
+
Skyward API and Skyward ORM share one `skyward` command. Each adds its own commands to it.
|
|
510
|
+
|
|
511
|
+
| Command | Does |
|
|
512
|
+
| ----------------- | ------------------------------------------------------- |
|
|
513
|
+
| `skyward serve` | runs the app in development and reloads on file changes |
|
|
514
|
+
| `skyward routes` | prints every route with its handler and signature |
|
|
515
|
+
| `skyward openapi` | writes the OpenAPI document to stdout |
|
|
516
|
+
| `skyward check` | boots the app in strict mode, runs Steep, and checks the generated signatures are current |
|
|
517
|
+
|
|
518
|
+
```
|
|
519
|
+
$ skyward routes
|
|
520
|
+
GET /todos TodosAPI#index (?done: bool?, ?limit: Integer) -> Array[Todo]
|
|
521
|
+
GET /todos/:id TodosAPI#show (id: Integer) -> (Todo | NotFound)
|
|
522
|
+
POST /todos TodosAPI#create (body: NewTodo) -> Created[Todo]
|
|
523
|
+
POST /todos/:id/complete TodosAPI#complete (id: Integer) -> (Todo | NotFound)
|
|
524
|
+
DELETE /todos/:id TodosAPI#destroy (id: Integer) -> (NoContent | NotFound)
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
The executable comes with Skyward Core, which every Skyward gem depends on. It finds each gem's `lib/skyward/<gem>/commands.rb` with `Gem.find_files` and loads it, and that file registers the gem's commands:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
Skyward::Commands.register("routes", "List every route and the method that serves it") do |args|
|
|
531
|
+
# ...
|
|
532
|
+
end
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Skyward API's commands run in your app's directory, and load `app.rb`, which assigns `App = Skyward.app { ... }`.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## Coming from…
|
|
540
|
+
|
|
541
|
+
- **Sinatra.** The routes will feel familiar, but the handler is a named method rather than a block, so you can call it, test it and type it.
|
|
542
|
+
- **Rails.** There's no controller instance per request and no `params` hash. Inputs arrive as arguments, and dependencies arrive through `initialize`.
|
|
543
|
+
- **FastAPI.** It's the same big idea: the function signature is the API contract. Here it's in Ruby, with RBS instead of type hints.
|
|
544
|
+
- **Grape.** You get the docs without the DSL.
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## Design principles
|
|
549
|
+
|
|
550
|
+
1. **It's just Ruby.** Classes, methods, keyword arguments and return values. If you can write a method, you can write an endpoint.
|
|
551
|
+
2. **Types are a bonus that pays rent.** Nothing requires them. Every signature you add buys coercion, validation, docs and type checking.
|
|
552
|
+
3. **One source of truth.** The signature is the contract, so there's no schema written twice.
|
|
553
|
+
4. **Responses are values.** You return what happened. Nothing is raised for an ordinary outcome.
|
|
554
|
+
5. **Dependencies come in through the front door.** Collaborators arrive through `initialize`, and per-request values through providers. Nothing reaches for globals.
|
|
555
|
+
6. **Boot does the work.** Skyward API fails at boot rather than in production, and pays the cost at boot rather than on each request.
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## Status
|
|
560
|
+
|
|
561
|
+
This README is the spec. Skyward 0.1 is a skeleton: the gem installs and loads, and none of the features above are built yet. Skyward API is built first, and this section lists each part as it lands.
|
data/lib/skyward/api.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: skyward-api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jake Bottrall
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: skyward-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: zeitwerk
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.8'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.8'
|
|
40
|
+
email:
|
|
41
|
+
- jakebottrall@gmail.com
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- README.md
|
|
47
|
+
- lib/skyward/api.rb
|
|
48
|
+
- lib/skyward/api/version.rb
|
|
49
|
+
- sig/generated/skyward/api.rbs
|
|
50
|
+
- sig/generated/skyward/api/version.rbs
|
|
51
|
+
homepage: https://github.com/bottrall/skyward
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/bottrall/skyward
|
|
56
|
+
source_code_uri: https://github.com/bottrall/skyward/tree/main/skyward-api
|
|
57
|
+
changelog_uri: https://github.com/bottrall/skyward/blob/main/CHANGELOG.md
|
|
58
|
+
bug_tracker_uri: https://github.com/bottrall/skyward/issues
|
|
59
|
+
rubygems_mfa_required: 'true'
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.3'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 4.0.20
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: A small, fast web framework where an endpoint is a method, typed with RBS.
|
|
77
|
+
test_files: []
|