Skip to content

Commit e087353

Browse files
authored
Merge pull request #18 from CurtisTechSolutions/feat/openapi-contract
feat: generate OpenAPI from shared Rust types
2 parents 7b4941b + 7e60347 commit e087353

22 files changed

Lines changed: 573 additions & 45 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ jobs:
2929
GFA_SQLX_WORK_DIR: ${{ runner.temp }}/gfa-sqlx
3030
- run: cargo build -p gfa-cli --locked
3131
- run: python3 .github/scripts/local-server-smoke.py
32+
- name: Export generated OpenAPI
33+
run: cargo run -p gfa-http --example export-openapi --locked > target/openapi.json
34+
- uses: actions/upload-artifact@v4
35+
with:
36+
name: openapi
37+
path: target/openapi.json
3238
- run: cargo xtask check-deps
3339
- run: cargo check -p gfa-games --no-default-features --locked
3440
lint:

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ dist/
77
.env
88
.venv/
99
__pycache__/
10+
11+
/schemas/openapi.json

‎Cargo.lock‎

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ uuid = { version = "1", features = ["v4"] }
5050
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "handshake"] }
5151
futures-util = { version = "0.3", features = ["sink"] }
5252
sha2 = "0.10.9"
53+
utoipa = { version = "5.4.0", default-features = false, features = ["macros"] }
5354

5455
[workspace.lints.rust]
5556
unsafe_code = "forbid"

‎crates/adapters/gfa-http/Cargo.toml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ edition.workspace = true
55
license.workspace = true
66

77
[dependencies]
8-
gfa-core.workspace = true
9-
gfa-api-types.workspace = true
8+
gfa-core = { workspace = true, features = ["openapi"] }
9+
gfa-api-types = { workspace = true, features = ["openapi"] }
1010
gfa-service.workspace = true
1111
axum.workspace = true
1212
serde.workspace = true
1313
serde_json.workspace = true
1414
tokio.workspace = true
1515
sha2.workspace = true
16+
utoipa.workspace = true
1617

1718
[dev-dependencies]
1819
tower.workspace = true
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//! Export the host's generated OpenAPI contract to standard output.
2+
fn main() -> Result<(), Box<dyn std::error::Error>> {
3+
println!("{}", gfa_http::openapi_document(true).to_pretty_json()?);
4+
Ok(())
5+
}

‎crates/adapters/gfa-http/src/error.rs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ use axum::{
44
response::{IntoResponse, Response},
55
Json,
66
};
7-
use gfa_api_types::ApiError;
8-
use serde_json::json;
7+
use gfa_api_types::{ApiError, ErrorResponse};
98

109
pub(crate) struct HttpError {
1110
pub status: StatusCode,
@@ -54,6 +53,6 @@ impl From<JsonRejection> for HttpError {
5453

5554
impl IntoResponse for HttpError {
5655
fn into_response(self) -> Response {
57-
(self.status, Json(json!({ "error": self.error }))).into_response()
56+
(self.status, Json(ErrorResponse { error: self.error })).into_response()
5857
}
5958
}

‎crates/adapters/gfa-http/src/info.rs‎

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,20 @@ use axum::{
44
http::{header, HeaderMap, StatusCode},
55
response::{IntoResponse, Response},
66
};
7-
use gfa_api_types::{Briefing, InfoDetail};
7+
use gfa_api_types::{
8+
Briefing, GameInfoQuery as GameQuery, InfoFormat as Format, MatchInfoQuery as MatchQuery,
9+
};
810
use gfa_service::GameService;
9-
use serde::Deserialize;
1011
use serde_json::Value;
1112
use sha2::{Digest, Sha256};
1213
use std::sync::Arc;
1314

14-
#[derive(Clone, Copy, Default, Deserialize)]
15-
#[serde(rename_all = "snake_case")]
16-
pub(crate) enum Format {
17-
#[default]
18-
Json,
19-
Markdown,
20-
}
21-
22-
#[derive(Default, Deserialize)]
23-
#[serde(deny_unknown_fields)]
24-
pub(crate) struct GameQuery {
25-
#[serde(default)]
26-
format: Format,
27-
#[serde(default)]
28-
detail: InfoDetail,
29-
config: Option<String>,
30-
seat: Option<u8>,
31-
}
15+
#[utoipa::path(
16+
get, path = "/v1/games/{game_id}/info", tag = "Briefings",
3217
18+
params(("game_id" = String, Path, description = "Registered game identifier"),gfa_api_types::GameInfoQuery, ("If-None-Match" = Option<String>, Header, description = "ETag, weak tag, list, or *")),
19+
responses((status = 200, description = "Deterministic synthetic briefing", content((Briefing = "application/json"), (String = "text/markdown")), headers(("ETag" = String, description = "SHA-256 of this representation"))), (status = 304, description = "Unchanged; no body"), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
20+
)]
3321
pub(crate) async fn game_info(
3422
State(service): State<Arc<GameService>>,
3523
path: Id,
@@ -99,16 +87,12 @@ pub(crate) fn render(
9987
Ok(response)
10088
}
10189

102-
#[derive(Default, Deserialize)]
103-
#[serde(deny_unknown_fields)]
104-
pub(crate) struct MatchQuery {
105-
#[serde(default)]
106-
format: Format,
107-
#[serde(default)]
108-
detail: InfoDetail,
109-
seat: Option<u8>,
110-
}
90+
#[utoipa::path(
91+
get, path = "/v1/matches/{id}/info", tag = "Briefings",
11192
93+
params(("id" = String, Path, description = "Match identifier"),gfa_api_types::MatchInfoQuery),
94+
responses((status = 200, description = "Live match briefing; never cached", content((Briefing = "application/json"), (String = "text/markdown"))), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
95+
)]
11296
pub(crate) async fn match_info(
11397
State(service): State<Arc<GameService>>,
11498
path: Id,

‎crates/adapters/gfa-http/src/lib.rs‎

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
mod access;
77
mod error;
88
mod info;
9+
mod openapi;
10+
pub use openapi::openapi_document;
911
mod stream;
1012

1113
pub use stream::LiveUpdates;
@@ -26,6 +28,7 @@ use gfa_api_types::{ApiError, CreateMatch, MatchState, MoveRequest, MoveResult,
2628
use gfa_core::{GameSpec, Viewer};
2729
use gfa_service::GameService;
2830
use serde::Deserialize;
31+
#[cfg(test)]
2932
use serde_json::{json, Value};
3033
use std::{net::SocketAddr, sync::Arc};
3134

@@ -64,7 +67,18 @@ fn build_router(
6467
"Bind 127.0.0.1 or ::1, then pass listener.local_addr().",
6568
));
6669
}
70+
let document = openapi_document(updates.is_some()).to_json().map_err(|_| {
71+
ApiError::new(
72+
"INVALID_CONFIG",
73+
"API documentation could not be generated",
74+
"Check schema derives.",
75+
)
76+
})?;
6777
let mut router = Router::new()
78+
.route(
79+
"/v1/openapi.json",
80+
get(openapi::document).layer(axum::Extension(openapi::Document(document))),
81+
)
6882
.route("/healthz", get(health))
6983
.route("/v1/games", get(games))
7084
.route("/v1/games/{game_id}", get(game))
@@ -122,14 +136,35 @@ fn id(path: Id) -> Result<String, HttpError> {
122136
.map_err(|error| HttpError::request(error.body_text()))
123137
}
124138

125-
async fn health() -> Json<Value> {
126-
Json(json!({ "status": "ok", "mode": "local" }))
139+
#[utoipa::path(
140+
get, path = "/healthz", tag = "Health",
141+
142+
143+
responses((status = 200, description = "Successful health response", body = gfa_api_types::Health), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
144+
)]
145+
async fn health() -> Json<gfa_api_types::Health> {
146+
Json(gfa_api_types::Health {
147+
status: "ok".into(),
148+
mode: "local".into(),
149+
})
127150
}
128151

152+
#[utoipa::path(
153+
get, path = "/v1/games", tag = "Games",
154+
155+
156+
responses((status = 200, description = "Successful games response", body = Vec<GameSpec>), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
157+
)]
129158
async fn games(State(service): State<Arc<GameService>>) -> Json<Vec<GameSpec>> {
130159
Json(service.list_games())
131160
}
132161

162+
#[utoipa::path(
163+
get, path = "/v1/games/{game_id}", tag = "Games",
164+
165+
params(("game_id" = String, Path, description = "Registered game identifier")),
166+
responses((status = 200, description = "Successful game response", body = GameSpec), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
167+
)]
133168
async fn game(
134169
State(service): State<Arc<GameService>>,
135170
path: Id,
@@ -150,6 +185,12 @@ async fn game(
150185
})
151186
}
152187

188+
#[utoipa::path(
189+
post, path = "/v1/matches", tag = "Matches",
190+
request_body(content = gfa_api_types::CreateMatch, example = json!({"game_id":"tictactoe","seed":42})),
191+
params(("seat" = Option<u8>, Query, description = "Zero-based seat; omit for spectator view. Creation and position validation default to seat 0.", minimum = 0, maximum = 255)),
192+
responses((status = 201, description = "Successful create response", body = gfa_api_types::CreatedMatch), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
193+
)]
153194
async fn create(
154195
State(service): State<Arc<GameService>>,
155196
query: View,
@@ -161,6 +202,12 @@ async fn create(
161202
Ok((StatusCode::CREATED, Json(state)).into_response())
162203
}
163204

205+
#[utoipa::path(
206+
get, path = "/v1/matches/{id}/state", tag = "Matches",
207+
208+
params(("id" = String, Path, description = "Match identifier"),("seat" = Option<u8>, Query, description = "Zero-based seat; omit for spectator view. Creation and position validation default to seat 0.", minimum = 0, maximum = 255)),
209+
responses((status = 200, description = "Successful state response", body = MatchState), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
210+
)]
164211
async fn state(
165212
State(service): State<Arc<GameService>>,
166213
path: Id,
@@ -171,17 +218,32 @@ async fn state(
171218
))
172219
}
173220

221+
#[utoipa::path(
222+
get, path = "/v1/matches/{id}/legal-actions", tag = "Matches",
223+
224+
params(("id" = String, Path, description = "Match identifier"),("seat" = Option<u8>, Query, description = "Zero-based seat; omit for spectator view. Creation and position validation default to seat 0.", minimum = 0, maximum = 255)),
225+
responses((status = 200, description = "Successful legal actions response", body = gfa_api_types::LegalActions), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
226+
)]
174227
async fn legal_actions(
175228
State(service): State<Arc<GameService>>,
176229
path: Id,
177230
query: View,
178-
) -> Result<Json<Value>, HttpError> {
231+
) -> Result<Json<gfa_api_types::LegalActions>, HttpError> {
179232
let state = service.get_state(&id(path)?, view(query)?.viewer()).await?;
180-
Ok(Json(
181-
json!({ "turn": state.turn, "to_act": state.to_act, "legal_actions": state.legal_actions, "action_mask": state.action_mask }),
182-
))
233+
Ok(Json(gfa_api_types::LegalActions {
234+
turn: state.turn,
235+
to_act: state.to_act,
236+
legal_actions: state.legal_actions,
237+
action_mask: state.action_mask,
238+
}))
183239
}
184240

241+
#[utoipa::path(
242+
post, path = "/v1/matches/{id}/actions", tag = "Matches",
243+
request_body(content = MoveRequest, example = json!({"seat":0,"turn":0,"action":"r2c2"})),
244+
params(("id" = String, Path, description = "Match identifier"),("Idempotency-Key" = Option<String>, Header, description = "1–128 bytes; identical retries return the original result.", min_length = 1, max_length = 128)),
245+
responses((status = 200, description = "Successful make move response", body = MoveResult), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
246+
)]
185247
async fn make_move(
186248
State(service): State<Arc<GameService>>,
187249
path: Id,
@@ -206,6 +268,12 @@ async fn make_move(
206268
Ok(Json(service.make_move(&id, request, key).await?))
207269
}
208270

271+
#[utoipa::path(
272+
get, path = "/v1/matches/{id}/replay", tag = "Matches",
273+
274+
params(("id" = String, Path, description = "Match identifier"),("seat" = Option<u8>, Query, description = "Zero-based seat; omit for spectator view. Creation and position validation default to seat 0.", minimum = 0, maximum = 255)),
275+
responses((status = 200, description = "Successful replay response", body = Replay), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
276+
)]
209277
async fn replay(
210278
State(service): State<Arc<GameService>>,
211279
path: Id,
@@ -239,6 +307,12 @@ async fn method_not_allowed() -> HttpError {
239307
#[cfg(test)]
240308
mod tests;
241309

310+
#[utoipa::path(
311+
post, path = "/v1/games/{game_id}/positions/validate", tag = "Positions",
312+
request_body = gfa_api_types::ValidatePosition,
313+
params(("game_id" = String, Path, description = "Registered game identifier"),("seat" = Option<u8>, Query, description = "Zero-based seat; omit for spectator view. Creation and position validation default to seat 0.", minimum = 0, maximum = 255)),
314+
responses((status = 200, description = "Successful validate position response", body = gfa_api_types::ValidatedPosition), (status = "default", description = "Structured recoverable error; local access requires a loopback peer and matching Host/Origin.", body = gfa_api_types::ErrorResponse))
315+
)]
242316
async fn validate_position(
243317
State(service): State<Arc<GameService>>,
244318
path: Id,

0 commit comments

Comments
 (0)