g1t

syntaqx/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/api/src/openapi.rs

312 lines11,911 bytes
//! The OpenAPI document, generated from the same list the routes are.

use serde_json::{Map, Value, json};

use crate::operations::Op;
use crate::rest::{ROUTES, Route};

/// The section of the API reference an operation is listed under.
fn tag(op: Op) -> &'static str {
    let name = op.name();
    if op == Op::Whoami || name.contains("workspace") {
        "Accounts"
    } else if name.contains("session") {
        "Sessions"
    } else if name.contains("pull_request") {
        "Pull requests"
    } else if ["issue", "label", "comment"]
        .iter()
        .any(|word| name.contains(word))
    {
        "Issues"
    } else {
        "Repositories"
    }
}

/// A short title from an operation name: `create_issue` is "Create issue".
fn title(op: Op) -> String {
    if op == Op::Whoami {
        return "Get the current user".to_owned();
    }
    let words = op.name().replace('_', " ");
    let mut letters = words.chars();
    match letters.next() {
        Some(first) => first.to_uppercase().chain(letters).collect(),
        None => words,
    }
}

/// `/v1/repos/:owner/:name` as OpenAPI writes it: `/v1/repos/{owner}/{name}`.
fn openapi_path(route: &Route) -> String {
    route
        .path
        .split('/')
        .map(|segment| match segment.strip_prefix(':') {
            Some(name) => format!("{{{name}}}"),
            None => segment.to_owned(),
        })
        .collect::<Vec<_>>()
        .join("/")
}

fn error_response(description: &str) -> Value {
    json!({
        "description": description,
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
    })
}

fn operation(route: &Route) -> Value {
    let op = route.op;
    let path_params: Vec<&str> = route.params().collect();
    // `owner` and `name` in the path stand for the operation's `repo` input.
    let covered = |name: &str| name == "repo" || path_params.contains(&name);
    let mut properties = op.properties();
    properties.retain(|name, _| !covered(name));
    let required: Vec<String> = op
        .required()
        .into_iter()
        .filter(|name| !covered(name))
        .collect();

    let mut parameters: Vec<Value> = path_params
        .iter()
        .map(|name| {
            json!({
                "name": name,
                "in": "path",
                "required": true,
                "schema": { "type": if *name == "number" { "integer" } else { "string" } },
            })
        })
        .collect();
    let mut body = Value::Null;
    if route.method == "GET" {
        for (name, key) in route.query {
            parameters.push(json!({
                "name": name,
                "in": "query",
                "required": false,
                "schema": properties.get(*key).cloned().unwrap_or_else(|| json!({})),
            }));
        }
    } else if !properties.is_empty() {
        let mut schema = json!({ "type": "object", "properties": properties });
        if !required.is_empty() {
            schema["required"] = json!(required);
        }
        body = json!({
            "required": !required.is_empty(),
            "content": { "application/json": { "schema": schema } },
        });
    }

    let mut described = json!({
        "operationId": op.name(),
        "tags": [tag(op)],
        "summary": title(op),
        "description": op.description(),
        "parameters": parameters,
        "responses": {
            "200": {
                "description": "Success.",
                "content": { "application/json": { "schema": {} } },
            },
            "401": error_response("A token is required, or the one sent is not valid."),
            "403": error_response("Signed in, but not allowed to do this."),
            "404": error_response("It does not exist, or you cannot see it."),
            "409": error_response("The request conflicts with the current state."),
            "422": error_response("The input is not valid."),
        },
    });
    if !body.is_null() {
        described["requestBody"] = body;
    }
    described
}

/// Entries for device sign-in, which is not an operation.
fn onboarding() -> Map<String, Value> {
    let paths = json!({
        "/v1/device/code": {
            "post": {
                "operationId": "device_code",
                "tags": ["Accounts"],
                "summary": "Start signing in",
                "description": "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/v1/device/token`.",
                "security": [],
                "requestBody": {
                    "content": { "application/json": { "schema": {
                        "type": "object",
                        "properties": {
                            "client_name": {
                                "type": "string",
                                "description": "What is asking, shown to the person approving. For example, Claude Code.",
                            },
                        },
                    } } },
                },
                "responses": { "200": {
                    "description": "The codes for this sign-in.",
                    "content": { "application/json": { "schema": {
                        "type": "object",
                        "properties": {
                            "device_code": { "type": "string", "description": "Secret. Send it to /v1/device/token." },
                            "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." },
                            "verification_uri": { "type": "string" },
                            "verification_uri_complete": {
                                "type": "string",
                                "description": "The link to give the person; it carries the code.",
                            },
                            "expires_in": { "type": "integer", "description": "Seconds until the codes expire." },
                            "interval": { "type": "integer", "description": "Seconds to wait between polls." },
                        },
                    } } },
                } },
            },
        },
        "/v1/device/token": {
            "post": {
                "operationId": "device_token",
                "tags": ["Accounts"],
                "summary": "Finish signing in",
                "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.",
                "security": [],
                "requestBody": {
                    "required": true,
                    "content": { "application/json": { "schema": {
                        "type": "object",
                        "required": ["device_code"],
                        "properties": { "device_code": { "type": "string" } },
                    } } },
                },
                "responses": { "200": {
                    "description": "The state of the sign-in.",
                    "content": { "application/json": { "schema": {
                        "type": "object",
                        "required": ["status"],
                        "properties": {
                            "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] },
                            "token": { "type": "string", "description": "Present when approved." },
                            "username": { "type": "string" },
                            "verified": {
                                "type": "boolean",
                                "description": "Whether the account's email is confirmed.",
                            },
                        },
                    } } },
                } },
            },
        },
    });
    match paths {
        Value::Object(paths) => paths,
        _ => Map::new(),
    }
}

pub fn document() -> Value {
    let mut paths = onboarding();
    for route in ROUTES {
        let entry = paths
            .entry(openapi_path(route))
            .or_insert_with(|| json!({}));
        entry[route.method.to_lowercase()] = operation(route);
    }
    json!({
        "openapi": "3.1.0",
        "info": {
            "title": "g1t API",
            "version": "1",
            "description": "The REST API for g1t, a git forge built for agents. The same operations are available to agents as MCP tools at https://mcp.g1t.sh.",
            "license": { "name": "MIT", "identifier": "MIT" },
        },
        "servers": [{ "url": "https://api.g1t.sh" }],
        "security": [{ "token": [] }, {}],
        "tags": [
            { "name": "Accounts", "description": "Signing in from a tool, and the current user." },
            { "name": "Repositories" },
            {
                "name": "Issues",
                "description": "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
            },
            {
                "name": "Pull requests",
                "description": "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.",
            },
            { "name": "Sessions", "description": "The record of how a pull request was made." },
        ],
        "paths": paths,
        "components": {
            "securitySchemes": {
                "token": {
                    "type": "http",
                    "scheme": "bearer",
                    "description": "An access token, `g1t_…`. Public data needs none.",
                },
            },
            "schemas": {
                "Error": {
                    "type": "object",
                    "required": ["error"],
                    "properties": {
                        "error": {
                            "type": "object",
                            "required": ["code", "message"],
                            "properties": {
                                "code": {
                                    "type": "string",
                                    "enum": ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"],
                                },
                                "message": { "type": "string" },
                            },
                        },
                    },
                },
            },
        },
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_route_is_documented_once() {
        let document = document();
        let mut ids = Vec::new();
        for (_, methods) in document["paths"].as_object().unwrap() {
            for (_, operation) in methods.as_object().unwrap() {
                ids.push(operation["operationId"].as_str().unwrap().to_owned());
            }
        }
        for op in Op::ALL {
            assert_eq!(
                ids.iter().filter(|id| *id == op.name()).count(),
                1,
                "{}",
                op.name()
            );
        }
    }

    #[test]
    fn path_and_query_inputs_are_not_repeated_in_the_body() {
        let document = document();
        let merge = &document["paths"]["/v1/repos/{owner}/{name}/pulls/{number}/merge"]["post"];
        let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"];
        assert!(body.get("keep_issue_open").is_some());
        assert!(body.get("repo").is_none() && body.get("number").is_none());
        let list = &document["paths"]["/v1/repos"]["get"];
        assert_eq!(list["parameters"][0]["name"], "q");
        assert!(list.get("requestBody").is_none());
    }

    #[test]
    fn titles_read_as_sentences() {
        assert_eq!(title(Op::CreateIssue), "Create issue");
        assert_eq!(title(Op::Whoami), "Get the current user");
    }
}