Browse Source

Produce Swagger schema for REST API

master
buttercat1791 2 months ago
parent
commit
0cf675ef6e
  1. 10
      config/config.exs
  2. 94
      lib/gc_index_relay_web/controllers/event_controller.ex
  3. 33
      lib/gc_index_relay_web/controllers/filter_controller.ex
  4. 19
      lib/gc_index_relay_web/router.ex
  5. 3
      mix.exs
  6. 1
      mix.lock
  7. 191
      prd-1-index-relay.md
  8. 236
      priv/static/swagger.json

10
config/config.exs

@ -31,6 +31,16 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint, @@ -31,6 +31,16 @@ config :gc_index_relay, GcIndexRelayWeb.Endpoint,
# at the `config/runtime.exs`.
config :gc_index_relay, GcIndexRelay.Mailer, adapter: Swoosh.Adapters.Local
config :gc_index_relay, :phoenix_swagger,
swagger_files: %{
"priv/static/swagger.json" => [
router: GcIndexRelayWeb.Router,
endpoint: GcIndexRelayWeb.Endpoint
]
}
config :phoenix_swagger, json_library: Jason
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",

94
lib/gc_index_relay_web/controllers/event_controller.ex

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
defmodule GcIndexRelayWeb.EventController do
use GcIndexRelayWeb, :controller
use PhoenixSwagger
alias GcIndexRelay.Nostr
alias GcIndexRelay.Nostr.Event
@ -7,6 +8,17 @@ defmodule GcIndexRelayWeb.EventController do @@ -7,6 +8,17 @@ defmodule GcIndexRelayWeb.EventController do
action_fallback GcIndexRelayWeb.FallbackController
swagger_path :create do
post("/api/events")
summary("Publish a Nostr event")
description("Accepts a signed Nostr event JSON. Event ID and signature are validated.")
produces("application/json")
tag("Events")
operation_id("create_event")
response(201, "Created", Schema.ref(:PubEvent))
response(400, "BadRequest")
end
def create(conn, %{"event" => event_params}) do
# TODO: Add 400 for invalid event in FallbackController
with {:ok, %Event{} = event} <- Nostr.create_event(event_params) do
@ -17,16 +29,98 @@ defmodule GcIndexRelayWeb.EventController do @@ -17,16 +29,98 @@ defmodule GcIndexRelayWeb.EventController do
end
end
swagger_path :show do
get("/api/events/{event_id}")
summary("Retrieve a Nostr event by ID")
produces("application/json")
tag("Events")
operation_id("show_event")
parameters do
id(:path, :string, "Event ID", required: true)
end
response(200, "OK", Schema.ref(:PubEvent))
response(404, "NotFound")
end
def show(conn, %{"id" => id}) do
# TODO: Add 404 for event not found in FallbackController
event = Nostr.get_event(id)
render(conn, :show, event: event)
end
swagger_path :delete do
PhoenixSwagger.Path.delete("/api/events/{event_id}")
summary("Delete a Nostr event by ID")
tag("Events")
operation_id("delete_event")
parameters do
id(:path, :string, "Event ID", required: true)
end
response(204, "NoContent")
response(404, "NotFound")
end
def delete(conn, %{"id" => id}) do
with {:ok, %PubEvent{} = pub_event} <- Nostr.get_event(id),
{:ok, %Event{} = _} <- Nostr.delete_event(pub_event) do
send_resp(conn, :no_content, "")
end
end
def swagger_definitions do
%{
PubEvent:
swagger_schema do
title("PubEvent")
description("A signed Nostr event")
properties do
id(:string, "32-byte lowercase hex event ID (SHA-256 of serialized event)",
required: true
)
pubkey(:string, "32-byte lowercase hex public key of the event creator",
required: true
)
created_at(:integer, "Unix timestamp in seconds", required: true)
kind(:integer, "Nostr event kind", required: true)
tags(
%Schema{
type: :array,
items: %Schema{type: :array, items: %Schema{type: :string}}
},
"List of tags, each an array of strings",
required: true
)
content(:string, "Arbitrary event content", required: true)
sig(:string, "64-byte lowercase hex Schnorr signature", required: true)
end
example(%{
id: "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65",
pubkey: "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
created_at: 1_673_347_337,
kind: 1,
tags: [],
content: "Walled gardens became prisons, and users, lost.",
sig:
"908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd0092619a2c8c1221e581946e0191f2af505dfdf8657a414dbca329186f009262"
})
end,
PubEventList:
swagger_schema do
title("PubEventList")
description("A list of Nostr events")
type(:array)
items(Schema.ref(:PubEvent))
end
}
end
end

33
lib/gc_index_relay_web/controllers/filter_controller.ex

@ -1,10 +1,34 @@ @@ -1,10 +1,34 @@
defmodule GcIndexRelayWeb.FilterController do
use GcIndexRelayWeb, :controller
use PhoenixSwagger
alias GcIndexRelay.Nostr
action_fallback GcIndexRelayWeb.FallbackController
swagger_path :index do
get("/api/events")
summary("Query events by specifying NIP-01 filter parameters in the URL query string.")
description("""
The `since`, `until`, and `limit` parameters are required. This ensures every query generates a
unique, repeatable response. Queries that do not specify `since`, `until`, or `limit` should be
made against POST /api/events/filter.
""")
tag("Events")
operation_id("query_events")
parameters do
since(:query, :integer, "Start time", required: true)
until(:query, :integer, "End time", required: true)
limit(:query, :integer, "Maximum number of events", required: true)
end
response(200, "OK", Schema.ref(:PubEventList))
response(400, "Bad Request")
end
@doc """
GET /api/events - Query events by specifying NIP-01 filter parameters in the URL query string.
@ -21,6 +45,15 @@ defmodule GcIndexRelayWeb.FilterController do @@ -21,6 +45,15 @@ defmodule GcIndexRelayWeb.FilterController do
end
end
swagger_path :query do
post("/api/events/filter")
summary("Query events using a JSON filter in the request body.")
tag("Events")
operation_id("query_events")
response(200, "OK", Schema.ref(:PubEventList))
response(400, "Bad Request")
end
@doc """
POST /api/events/filter - Query events using a JSON filter in the request body.
"""

19
lib/gc_index_relay_web/router.ex

@ -28,6 +28,25 @@ defmodule GcIndexRelayWeb.Router do @@ -28,6 +28,25 @@ defmodule GcIndexRelayWeb.Router do
resources "/events", EventController, only: [:show, :create, :delete]
end
def swagger_info do
%{
schemes: ["https", "wss"],
info: %{
version: "0.1",
# Change this to read from config/env for deployed name
title: "Isidore Relay"
},
consumes: ["application/json"],
produces: ["application/json"]
}
end
scope "/api/swagger" do
forward "/", PhoenixSwagger.Plug.SwaggerUI,
otp_app: :gc_index_relay,
swagger_file: "swagger.json"
end
# Other scopes may use custom stacks.
# scope "/api", GcIndexRelayWeb do
# pipe_through :api

3
mix.exs

@ -61,7 +61,8 @@ defmodule GcIndexRelay.MixProject do @@ -61,7 +61,8 @@ defmodule GcIndexRelay.MixProject do
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:lib_secp256k1, "~> 0.7.1"}
{:lib_secp256k1, "~> 0.7.1"},
{:phoenix_swagger, "~> 0.8"}
]
end

1
mix.lock

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.20", "4f20850ee700b309b21906a0e510af1b916b454b4f810fb8581ada016eb42dfc", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c16abd605a21f778165cb0079946351ef20ef84eb1ef467a862fb9a173b1d27d"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swagger": {:hex, :phoenix_swagger, "0.8.5", "2141114352cd7f352b21c6662e5ae7539c78dfea33f12c50c3176c4b75b8fbed", [:mix], [{:ex_json_schema, "~> 0.9", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2c62b8a7d3bdd5a65a0dfb7598ef75d7965ddc4f97e1ee075184c2c35d3acc62"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},

191
prd-1-index-relay.md

@ -0,0 +1,191 @@ @@ -0,0 +1,191 @@
# PRD-1: Index Relay
A general-purpose Nostr relay with specializations that serve the needs of the Alexandria application.
## Market
### Customer Base
The Index Relay is intended to serve three types of customers:
1. **GitCitadel.** The relay will store the event kinds required by Alexandria, including publications indexes (kind 30040), publication fragments (kind 30041), and conversation threads (kinds 11 and 1111). It will allow graph queries to support efficient publication retrieval and visualization, and it will store and serve event embeddings to enable AI-powered semantic search.
2. **Nostr App Developers.** The relay will provide both a NIP-01-compliant WebSocket API and a well-structured REST API. The REST API will exclusively support full-text searches, graph queries, and semantic search. The REST API will provide a low-friction on-ramp for developers wishing to build apps for Nostr, especially those leveraging AI for coding tasks.
3. **Nostr Users.** The relay will act as an event and profile data store for Nostr users who trust the GitCitadel brand and mission. Users of the Index Relay can expect an environment free of spam, pornography, and other abusive content.
### Monetization
The Index Relay will provide a mixture of free and paid services.
#### Free Services
- **Standard Nostr WebSocket API.** Nostr app developers are free to use the Index Relay's WebSocket API within reasonable limits.
- **Basic REST API.** Standard Nostr relay features exposed over the WebSocket API are mirrored on the REST API. App developers are free to use this API within reasonable limits.
- **Outbox Usage.** Any trusted Nostr user can publish events to the relay. Long-term storage is not guaranteed.
#### Paid Services
- **User Profile Directory.** Permanent, guaranteed profile data storage for paid Nostr users. This allows Index Relay to serve as a profile data bootstrap relay for users logging onto new Nostr apps.
- **Long-Term Storage.** Paid Nostr users receive guaranteed long-term storage of their published events. Events published by unpaid users may be deleted periodically to keep data storage costs down.
- **Inbox Usage.** Index Relay will guarantee long-term storage of events that tag Nostr users whose profiles are stored in the relay. This allows Nostr users to designate Index Relay as an inbox.
- **Advanced REST API.** Advanced relay features—full-text search, semantic search, and graph queries—are exposed only over the REST API. App developers using these features must pay to obtain API keys to access these features.
- **Event Revision History.** Previous revisions of replaceable events are retained to allow users to see the change history of their documents, wiki articles, or other publications.
## Functional Requirements
Functional requirements are divided into phases. Higher-priority requirements should be met in earlier phases. This allows the relay to go live sooner, and gradually add capabilities.
### Phase 1
Support Alexandria's core capability set.
- Long-term storage of:
- Kind 30040 and 30041 publication events
- Kind 11 and 1111 comment thread events
- Kind 0 user profile events
- Kind 30-33 citation events
- Kind 30817 and 30818 wiki events
- REST API support for:
- Full publication retrieval
- Full comment thread retrieval
- Single event publication and retrieval
- User profile CRUD operations
- REST API access requires API keys
### Phase 2
Support general-purpose Nostr use cases.
- WebSocket API compliant with NIP-01
- NIP-42 authentication
- Nostr pubkey premium user list and block list
- Abusive content filtering
- Automatic deletion of old events
- Events signed by pubkeys that have a kind 0 profile on the relay are exempted
- Support negentropy sync with other relays
### Phase 3
Add administrative features.
- Admin dashboard that supports:
- Adding pubkeys to a block list
- Adding pubkeys to the premium user list
- Deleting events by ID or address
### Phase 4
Expand the REST API to support premium features.
- Full-text search of event contents and relevant tags (including title and author)
- Dynamic graph queries
- Previous version retention for replaceable events
### Phase 5
Support artificial intelligence workloads.
- Storage of event embeddings
- Retrieval of event embeddings in signed ephemeral events via WebSocket API
- Semantic search over stored embeddings via REST API
## Non-Functional Requirements
- Response timings and error traces from the production deployment of the relay must be _observable_.
- The system must be vertically _scalable_.
- Provisioning more resources on a single instance must allow the relay to support larger workloads.
- Horizontal scalability is a nice-to-have, but we should not over-index on distributed processing early in development.
- The relay must be _responsive_.
- Publications of 10,000 events should return in 2 seconds or less.
- Single events fetched by ID or address should return in 0.2 seconds or less.
- The relay must support high _concurrency_, with large numbers of clients using the relay simultaneously.
- The relay must be _fault-tolerant_, such that failed transactions are cleanly rolled back to avoid database corruption.
- The relay must be _correct_.
- Unauthorized users must be rejected.
- Events with invalid IDs or signatures must be rejected.
- Unpaid users must not be able to access paid features.
- Transactions must be properly _isolated_ from one another to ensure consistency.
- The relay must support _pub/sub_ patterns for ephemeral events and long-running client subscriptions.
- The relay's database must work with _graph_ data to efficiently traverse Nostr event networks.
- The relay's database must work with _relational_ data to efficiently handle Nostr filter queries.
- The relay must tend towards being _complete_ within its domain.
- To support this, the relay should implement the Negentropy protocol described in [NIP-77](https://github.com/nostr-protocol/nips/blob/master/77.md).
- The relay should synchronize with trusted third-party relays to compare its event collection and retrieve missing events.
## Architecture
### System Diagram
```mermaid
graph TD
subgraph "Client Layer"
ADMIN[Admin Dashboard]
end
subgraph "Index Relay Application"
subgraph "API Surface"
WSAPI[WebSocket API]
RESTAPI[REST API]
end
subgraph "IAM"
AUTHNMOD[Authentication]
AUTHZMOD[Authorization]
end
subgraph "Events"
EVTVAL[Event Validator]
EVTMOD[Event Moderation]
EVTREPO[Event Repository]
end
subgraph "Subscriptions"
SUBMAN[Subscription Manager]
PUBSUB[Pub/Sub]
end
subgraph "Queries"
FTSVC[Full-Text Search Service]
GRAPHSVC[Graph Query Service]
SEMSVC[Semantic Search Service]
end
subgraph "AI"
EMBEDGEN[Embedding Generator]
end
subgraph "Data"
PGDB[("PostgreSQL")]
AGE[Apache AGE]
PGVEC[pgvector]
end
end
ADMIN --> RESTAPI
WSAPI --> IAM
WSAPI --> SUBMAN
WSAPI --> EVTVAL
RESTAPI --> IAM
RESTAPI --> EVTVAL
RESTAPI --> PUBSUB
RESTAPI --> Queries
AUTHNMOD --> PGDB
AUTHZMOD --> PGDB
EVTVAL --> EVTMOD
EVTMOD --> EVTREPO
EVTMOD --> EMBEDGEN
EVTREPO --> PGDB
SUBMAN --> PUBSUB
PUBSUB --> EVTREPO
FTSVC --> PGDB
SEMSVC --> PGVEC
GRAPHSVC --> AGE
EMBEDGEN --> PGVEC
AGE --> PGDB
PGVEC --> PGDB
```

236
priv/static/swagger.json

@ -0,0 +1,236 @@ @@ -0,0 +1,236 @@
{
"info": {
"version": "0.1",
"title": "Isidore Relay"
},
"host": "localhost:4000",
"definitions": {
"PubEventList": {
"description": "A list of Nostr events",
"items": {
"$ref": "#/definitions/PubEvent"
},
"title": "PubEventList",
"type": "array"
},
"PubEvent": {
"description": "A signed Nostr event",
"example": {
"content": "Walled gardens became prisons, and users, lost.",
"created_at": 1673347337,
"id": "4376c65d2f232afbe9b882a35baa4f6fe8667c4e684749af565f981833ed6a65",
"kind": 1,
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd0092619a2c8c1221e581946e0191f2af505dfdf8657a414dbca329186f009262",
"tags": []
},
"properties": {
"content": {
"description": "Arbitrary event content",
"type": "string"
},
"created_at": {
"description": "Unix timestamp in seconds",
"type": "integer"
},
"id": {
"description": "32-byte lowercase hex event ID (SHA-256 of serialized event)",
"type": "string"
},
"kind": {
"description": "Nostr event kind",
"type": "integer"
},
"pubkey": {
"description": "32-byte lowercase hex public key of the event creator",
"type": "string"
},
"sig": {
"description": "64-byte lowercase hex Schnorr signature",
"type": "string"
},
"tags": {
"description": "List of tags, each an array of strings",
"items": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "array"
}
},
"required": [
"sig",
"content",
"tags",
"kind",
"created_at",
"pubkey",
"id"
],
"title": "PubEvent",
"type": "object"
}
},
"schemes": [
"https",
"wss"
],
"paths": {
"/api/events": {
"get": {
"description": "The `since`, `until`, and `limit` parameters are required. This ensures every query generates a\nunique, repeatable response. Queries that do not specify `since`, `until`, or `limit` should be\nmade against POST /api/events/filter.\n",
"operationId": "query_events",
"parameters": [
{
"description": "Start time",
"in": "query",
"name": "since",
"required": true,
"type": "integer"
},
{
"description": "End time",
"in": "query",
"name": "until",
"required": true,
"type": "integer"
},
{
"description": "Maximum number of events",
"in": "query",
"name": "limit",
"required": true,
"type": "integer"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/PubEventList"
}
},
"400": {
"description": "Bad Request"
}
},
"summary": "Query events by specifying NIP-01 filter parameters in the URL query string.",
"tags": [
"Events"
]
},
"post": {
"description": "Accepts a signed Nostr event JSON. Event ID and signature are validated.",
"operationId": "create_event",
"parameters": [],
"produces": [
"application/json"
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/PubEvent"
}
},
"400": {
"description": "BadRequest"
}
},
"summary": "Publish a Nostr event",
"tags": [
"Events"
]
}
},
"/api/events/filter": {
"post": {
"description": "",
"operationId": "query_events",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/PubEventList"
}
},
"400": {
"description": "Bad Request"
}
},
"summary": "Query events using a JSON filter in the request body.",
"tags": [
"Events"
]
}
},
"/api/events/{event_id}": {
"delete": {
"description": "",
"operationId": "delete_event",
"parameters": [
{
"description": "Event ID",
"in": "path",
"name": "id",
"required": true,
"type": "string"
}
],
"responses": {
"204": {
"description": "NoContent"
},
"404": {
"description": "NotFound"
}
},
"summary": "Delete a Nostr event by ID",
"tags": [
"Events"
]
},
"get": {
"description": "",
"operationId": "show_event",
"parameters": [
{
"description": "Event ID",
"in": "path",
"name": "id",
"required": true,
"type": "string"
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/PubEvent"
}
},
"404": {
"description": "NotFound"
}
},
"summary": "Retrieve a Nostr event by ID",
"tags": [
"Events"
]
}
}
},
"swagger": "2.0",
"consumes": [
"application/json"
],
"produces": [
"application/json"
]
}
Loading…
Cancel
Save