From e7a7839f265aacc5811446853a2df7114f861117 Mon Sep 17 00:00:00 2001 From: rocord01 Date: Fri, 19 Jun 2026 11:07:06 -0400 Subject: [PATCH] LiteNet Developer API Release --- pnpm-lock.yaml | 25 +- pnpm-workspace.yaml | 3 + public/api-spec/openapi.json | 1456 +++++++++++++++++++++++ public/api-spec/openapi.yaml | 781 ++++++++++++ src/app/developers/DevelopersClient.jsx | 1066 +++++++++++++++++ src/app/developers/page.jsx | 24 + src/components/footer.jsx | 4 + src/components/header.jsx | 10 + src/data/api-spec/openapi.yaml | 781 ++++++++++++ 9 files changed, 4144 insertions(+), 6 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 public/api-spec/openapi.json create mode 100644 public/api-spec/openapi.yaml create mode 100644 src/app/developers/DevelopersClient.jsx create mode 100644 src/app/developers/page.jsx create mode 100644 src/data/api-spec/openapi.yaml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b69e88..1506dc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,10 +115,10 @@ importers: version: 0.468.0(react@19.1.0) next: specifier: ^15.1.1 - version: 15.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-plausible: specifier: ^3.12.4 - version: 3.12.4(next@15.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 3.12.4(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -480,6 +480,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1618,6 +1622,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -3483,6 +3490,9 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api@1.9.0': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -4232,7 +4242,7 @@ snapshots: '@types/react@19.1.8': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/unist@2.0.11': {} @@ -4637,6 +4647,8 @@ snapshots: csstype@3.1.3: {} + csstype@3.2.3: {} + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -5789,9 +5801,9 @@ snapshots: natural-compare@1.4.0: {} - next-plausible@3.12.4(next@15.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-plausible@3.12.4(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - next: 15.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5800,7 +5812,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.1.1 '@swc/counter': 0.1.3 @@ -5820,6 +5832,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.1.1 '@next/swc-win32-arm64-msvc': 15.1.1 '@next/swc-win32-x64-msvc': 15.1.1 + '@opentelemetry/api': 1.9.0 sharp: 0.33.5 transitivePeerDependencies: - '@babel/core' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dd1b4fd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + sharp: true + unrs-resolver: true diff --git a/public/api-spec/openapi.json b/public/api-spec/openapi.json new file mode 100644 index 0000000..57482d1 --- /dev/null +++ b/public/api-spec/openapi.json @@ -0,0 +1,1456 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "LiteNet API", + "description": "LiteNet is a free community PBX based on FreePBX. This API allows you to\nmanage your extension, access voicemails, view conferences, and interact\nwith the LiteNet PBX programmatically.\n\n**Authentication:** Most endpoints require a Bearer token obtained via\nDiscord OAuth. Pass it in the `Authorization` header:\n`Authorization: Bearer *** version: \"1.0.0\"\n", + "contact": { + "name": "LiteNet", + "url": "https://litenet.tel" + } + }, + "servers": [ + { + "url": "https://api.litenet.tel", + "description": "Production API" + }, + { + "url": "http://localhost:3001", + "description": "Local development" + } + ], + "paths": { + "/extensions/me": { + "get": { + "summary": "Get current extension", + "description": "Returns the authenticated user's extension details, including their Discord profile and PBX device configuration.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Extension details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/extensions/me/devicestatus": { + "get": { + "summary": "Get device registration status", + "description": "Returns whether the user's SIP device is currently registered with the PBX. Polled every 5 seconds on the dashboard.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Device status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "extension": { + "type": "string", + "description": "The extension number", + "example": "1010" + }, + "deviceState": { + "type": "string", + "description": "Human-readable state (e.g. \"Registered\", \"In use\")", + "example": "In use" + }, + "activeChannels": { + "type": "string", + "nullable": true, + "description": "Active channel info or null if none", + "example": null + } + } + } + } + } + } + } + } + }, + "/extensions/me/calls": { + "get": { + "summary": "List active calls", + "description": "Returns all currently active calls for the authenticated user's extension. Polled every 5 seconds on the dashboard.", + "tags": [ + "Calls" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of active calls", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveCall" + } + } + } + } + } + } + }, + "delete": { + "summary": "Hangup all calls", + "description": "Hangs up every active call for the user's extension.", + "tags": [ + "Calls" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "All calls hung up", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Status message", + "example": "Successfully requested termination for 1 call(s) for extension 1010." + }, + "terminatedChannels": { + "type": "array", + "description": "List of terminated channel IDs", + "items": { + "type": "string" + }, + "example": [ + "PJSIP/1010-00000002" + ] + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/extensions/me/callme": { + "post": { + "summary": "Call me", + "description": "Triggers the PBX to call the user's extension. Supports music on hold and echo test modes.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "mode", + "required": true, + "schema": { + "type": "string", + "enum": [ + "hold", + "echo" + ] + }, + "description": "`hold` \u2014 play music on hold\n`echo` \u2014 echo test (play back what you say)\n" + }, + { + "in": "query", + "name": "callerId", + "required": false, + "schema": { + "type": "string" + }, + "description": "Custom caller ID to display (defaults to the extension number)" + }, + { + "in": "query", + "name": "autoAnswerMode", + "required": false, + "schema": { + "type": "string" + }, + "description": "Auto-answer mode for the call" + } + ], + "responses": { + "200": { + "description": "Call initiated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Status message", + "example": "Successfully initiated hold call to extension 1010." + } + } + } + } + } + } + } + } + }, + "/extensions/me/resetsecret": { + "post": { + "summary": "Reset SIP secret", + "description": "Resets the SIP password for the extension. Returns the new secret once \u2014 it cannot be retrieved again.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Secret reset", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "newSecret": { + "type": "string", + "description": "The new SIP secret (displayed once)", + "example": "aB3xK9mP2qR7" + } + } + } + } + } + } + } + } + }, + "/extensions/me/dnd": { + "get": { + "summary": "Get Do Not Disturb status", + "description": "Returns whether Do Not Disturb is currently enabled.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "DND status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "extension": { + "type": "string", + "description": "The extension number", + "example": "1010" + }, + "dndStatus": { + "type": "boolean", + "description": "Whether DND is enabled", + "example": false + } + } + } + } + } + } + } + }, + "post": { + "summary": "Toggle Do Not Disturb", + "description": "Enables or disables Do Not Disturb for the extension.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "dndStatus" + ], + "properties": { + "dndStatus": { + "type": "boolean", + "description": "true to enable, false to disable" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "DND updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Status message", + "example": "DND status for extension 1010 set to true." + }, + "extension": { + "type": "string", + "description": "The extension number", + "example": "1010" + }, + "dndStatus": { + "type": "boolean", + "description": "Updated DND state", + "example": true + } + } + } + } + } + } + } + } + }, + "/extensions/me/endpoint": { + "get": { + "summary": "List registered endpoints", + "description": "Returns all SIP endpoints/devices registered to the user's extension. Polled every 10 seconds on the dashboard.", + "tags": [ + "Extensions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of registered devices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RegisteredDevice" + } + } + } + } + } + } + } + }, + "/extensions/me/voicemails": { + "get": { + "summary": "List voicemails", + "description": "Returns voicemail messages for the authenticated user.", + "tags": [ + "Voicemail" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of voicemails", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Voicemail" + } + } + } + } + } + } + } + }, + "/extensions/me/voicemails/{messageId}/download": { + "get": { + "summary": "Download voicemail audio", + "description": "Downloads the WAV audio file for a specific voicemail.", + "tags": [ + "Voicemail" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "messageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "WAV audio file", + "content": { + "audio/wav": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/extensions/me/voicemails/{messageId}/move": { + "patch": { + "summary": "Move voicemail to folder", + "description": "Moves a voicemail into a different folder for organization.", + "tags": [ + "Voicemail" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "messageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "targetFolder" + ], + "properties": { + "targetFolder": { + "type": "string", + "enum": [ + "INBOX", + "Family", + "Friends", + "Old", + "Work", + "Urgent" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Voicemail moved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Status message", + "example": "Voicemail moved from Old to Friends." + }, + "messageId": { + "type": "string", + "description": "The voicemail message ID", + "example": "849c9236" + }, + "sourceFolder": { + "type": "string", + "description": "Original folder", + "example": "Old" + }, + "targetFolder": { + "type": "string", + "description": "Destination folder", + "example": "Friends" + }, + "newMessageNumber": { + "type": "string", + "description": "New message number after move", + "example": "msg0001" + } + } + } + } + } + } + } + } + }, + "/extensions/me/voicemails/{messageId}": { + "delete": { + "summary": "Delete voicemail", + "description": "Permanently deletes a voicemail message.", + "tags": [ + "Voicemail" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "messageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Voicemail deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Status message", + "example": "Voicemail msg0033 deleted permanently from Old." + }, + "messageId": { + "type": "string", + "description": "The voicemail message ID", + "example": "37aca380" + }, + "folder": { + "type": "string", + "description": "The folder the voicemail was in", + "example": "Old" + }, + "originalMessageId": { + "type": "string", + "description": "The original message number", + "example": "msg0033" + } + } + } + } + } + } + } + } + }, + "/conferences": { + "get": { + "summary": "List conference rooms", + "description": "Returns all currently active conference rooms and their participant counts.", + "tags": [ + "Conferences" + ], + "responses": { + "200": { + "description": "List of conference rooms", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConferenceRoom" + } + } + } + } + } + } + } + }, + "/conferences/{conferenceId}": { + "get": { + "summary": "Get conference participants", + "description": "Returns the list of participants currently in a specific conference room.", + "tags": [ + "Conferences" + ], + "parameters": [ + { + "in": "path", + "name": "conferenceId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of participants", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Participant" + } + } + } + } + } + } + } + }, + "/conferences/{conferenceId}/connectme": { + "post": { + "summary": "Connect to conference", + "description": "Dials the authenticated user's extension and bridges them into the specified conference room.", + "tags": [ + "Conferences" + ], + "parameters": [ + { + "in": "path", + "name": "conferenceId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Connected to conference" + } + } + } + }, + "/system/records": { + "get": { + "summary": "Get call statistics", + "description": "Returns aggregate call statistics for the LiteNet PBX. Public endpoint \u2014 no authentication required.", + "tags": [ + "System" + ], + "responses": { + "200": { + "description": "Call statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CallRecords" + } + } + } + } + } + } + }, + "/system/survey": { + "get": { + "summary": "Get hardware survey data", + "description": "Returns aggregated hardware survey data showing device types, brands, and devices needing categorization. Public endpoint \u2014 no authentication required.", + "tags": [ + "System" + ], + "responses": { + "200": { + "description": "Survey data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SurveyData" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "UUID", + "description": "Obtain a token via Discord OAuth at `/auth/discord`.\nPass it as `Authorization: Bearer *** schemas:\n" + }, + "ExtensionDetails": { + "type": "object", + "properties": { + "extensionId": { + "type": "string", + "description": "The 4-digit extension number", + "example": "1010" + }, + "user": { + "type": "object", + "description": "Discord user profile", + "properties": { + "id": { + "type": "string", + "description": "Discord user ID", + "example": "123456789012345678" + }, + "name": { + "type": "string", + "description": "Discord display name", + "example": "rocord" + }, + "avatar": { + "type": "string", + "description": "Discord avatar hash", + "example": "a1b2c3d4e5f6" + }, + "voicemail": { + "type": "string", + "description": "Voicemail context", + "example": "default" + }, + "extPassword": { + "type": "string", + "description": "SIP secret (masked in dashboard)" + } + } + }, + "coreDevice": { + "type": "object", + "description": "PBX device configuration", + "properties": { + "deviceid": { + "type": "string", + "example": "1010" + }, + "tech": { + "type": "string", + "example": "SIP" + }, + "dial": { + "type": "string", + "example": "SIP/1010" + } + } + } + } + }, + "ActiveCall": { + "type": "object", + "properties": { + "uniqueId": { + "type": "string", + "description": "Unique call identifier" + }, + "caller": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "number": { + "type": "string" + } + } + }, + "connectedLine": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "number": { + "type": "string" + } + } + }, + "state": { + "type": "string", + "description": "Call state (e.g. \"Up\", \"Ring\")" + }, + "duration": { + "type": "integer", + "description": "Call duration in seconds" + } + } + }, + "RegisteredDevice": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "SIP contact URI", + "example": "sip:1010@192.168.1.100:5060" + }, + "useragent": { + "type": "string", + "description": "User-Agent header from the device", + "example": "Linphone/4.4.0" + }, + "ip": { + "type": "string", + "description": "Client IP address", + "example": "192.168.1.100" + }, + "port": { + "type": "string", + "description": "Client port", + "example": "5060" + }, + "pingMs": { + "type": "string", + "description": "Round-trip latency in milliseconds", + "example": "12.34" + } + } + }, + "Voicemail": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Unique voicemail identifier" + }, + "callerIdNum": { + "type": "string", + "description": "Caller's phone number" + }, + "callerIdName": { + "type": "string", + "description": "Caller's name (if available)" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Timestamp of the voicemail" + }, + "duration": { + "type": "number", + "description": "Duration in seconds" + }, + "fileSize": { + "type": "integer", + "description": "Audio file size in bytes" + }, + "hasAudio": { + "type": "boolean", + "description": "Whether audio is available for playback" + }, + "folder": { + "type": "string", + "enum": [ + "INBOX", + "Family", + "Friends", + "Old", + "Work", + "Urgent" + ], + "description": "Current folder" + }, + "originalMessageId": { + "type": "string", + "description": "Original message ID before any moves" + } + } + }, + "ConferenceRoom": { + "type": "object", + "properties": { + "conferenceId": { + "type": "string", + "description": "Conference room identifier", + "example": 2000 + }, + "parties": { + "type": "integer", + "description": "Number of participants", + "example": 3 + }, + "locked": { + "type": "boolean", + "description": "Whether the room is locked" + } + } + }, + "Participant": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Asterisk channel identifier" + }, + "callerIdNum": { + "type": "string", + "description": "Participant's extension number" + }, + "callerIdName": { + "type": "string", + "description": "Participant's display name" + }, + "muted": { + "type": "boolean", + "description": "Whether the participant is muted" + }, + "admin": { + "type": "boolean", + "description": "Whether the participant is a conference admin" + }, + "talking": { + "type": "boolean", + "description": "Whether the participant is currently speaking" + } + } + }, + "CallRecords": { + "type": "object", + "properties": { + "records": { + "type": "object", + "properties": { + "total_calls_ever_placed": { + "type": "integer", + "description": "Total calls since launch", + "example": 5324 + }, + "record_calls": { + "type": "object", + "description": "Single-day call record", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date" + } + } + }, + "last_updated": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": { + "type": "integer", + "description": "Monthly call totals (e.g. monthly_total_2025-01)" + } + } + } + }, + "SurveyData": { + "type": "object", + "properties": { + "phoneTypes": { + "type": "object", + "description": "Device types and their counts", + "additionalProperties": { + "type": "integer" + }, + "example": { + "softphone": 45, + "ip_phone": 23, + "ata": 8 + } + }, + "brands": { + "type": "object", + "description": "Device brands and their counts", + "additionalProperties": { + "type": "integer" + }, + "example": { + "Polycom": 12, + "Cisco": 8, + "Yealink": 7 + } + }, + "needsCategorization": { + "type": "array", + "description": "Devices that couldn't be automatically categorized", + "items": { + "type": "object", + "properties": { + "ua": { + "type": "string", + "description": "User-Agent string" + } + } + } + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "description": "When the survey data was last refreshed" + } + } + } + }, + "schemas": { + "ExtensionDetails": { + "type": "object", + "properties": { + "status": { + "type": "boolean", + "description": "Whether the request was successful", + "example": true + }, + "message": { + "type": "string", + "description": "Status message", + "example": "Extension found successfully" + }, + "id": { + "type": "string", + "description": "Base64-encoded extension identifier", + "example": "ZXh0ZW5zaW9uOjEwMTA=" + }, + "extensionId": { + "type": "string", + "description": "The 4-digit extension number", + "example": "1010" + }, + "user": { + "type": "object", + "description": "User profile and PBX settings", + "properties": { + "name": { + "type": "string", + "description": "Discord display name", + "example": "rocord" + }, + "outboundCid": { + "type": "string", + "description": "Outbound caller ID override", + "example": "" + }, + "voicemail": { + "type": "string", + "description": "Voicemail context", + "example": "default" + }, + "ringtimer": { + "type": "integer", + "description": "Ring time in seconds before voicemail", + "example": 0 + }, + "noanswer": { + "type": "string", + "description": "No-answer destination", + "example": "" + }, + "noanswerDestination": { + "type": "string", + "description": "No-answer destination context", + "example": "" + }, + "noanswerCid": { + "type": "string", + "description": "Caller ID on no-answer", + "example": "" + }, + "busyCid": { + "type": "string", + "description": "Caller ID on busy", + "example": "" + }, + "sipname": { + "type": "string", + "description": "SIP display name", + "example": "" + }, + "extPassword": { + "type": "string", + "description": "SIP secret (masked in dashboard)", + "example": "REDACTED" + } + } + }, + "coreDevice": { + "type": "object", + "description": "PBX device configuration", + "properties": { + "deviceId": { + "type": "string", + "description": "Device identifier", + "example": "1010" + }, + "dial": { + "type": "string", + "description": "Asterisk dial string", + "example": "PJSIP/1010" + }, + "devicetype": { + "type": "string", + "description": "Device type (fixed, adhoc, etc.)", + "example": "fixed" + }, + "description": { + "type": "string", + "description": "Human-readable device description", + "example": "rocord" + }, + "emergencyCid": { + "type": "string", + "description": "Emergency caller ID override", + "example": "" + }, + "tech": { + "type": "string", + "description": "SIP technology driver", + "example": "pjsip" + } + } + } + } + }, + "ActiveCall": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Asterisk channel identifier", + "example": "PJSIP/1010-00000002" + }, + "uniqueId": { + "type": "string", + "description": "Unique call identifier", + "example": "1781687772.6" + }, + "caller": { + "type": "object", + "properties": { + "number": { + "type": "string", + "description": "Caller number", + "example": "1010" + }, + "name": { + "type": "string", + "description": "Caller display name", + "example": "Call Me Test (MusicOnHold)" + } + } + }, + "connectedLine": { + "type": "object", + "properties": { + "number": { + "type": "string", + "description": "Connected line number", + "example": "1010" + }, + "name": { + "type": "string", + "description": "Connected line display name", + "example": "Call Me Test (MusicOnHold)" + } + } + }, + "state": { + "type": "string", + "description": "Call state (e.g. \"Up\", \"Ring\")", + "example": "Up" + }, + "duration": { + "type": "string", + "description": "Call duration in HH:MM:SS format", + "example": "00:01:49" + }, + "application": { + "type": "string", + "description": "Asterisk application name", + "example": "MusicOnHold" + }, + "applicationData": { + "type": "string", + "description": "Application data/arguments", + "example": "" + } + } + }, + "RegisteredDevice": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "SIP contact URI", + "example": "sip:1010@192.168.1.100:5060" + }, + "useragent": { + "type": "string", + "description": "User-Agent header from the device", + "example": "Linphone/4.4.0" + }, + "ip": { + "type": "string", + "description": "Client IP address", + "example": "192.168.1.100" + }, + "port": { + "type": "string", + "description": "Client port", + "example": "5060" + }, + "pingMs": { + "type": "string", + "description": "Round-trip latency in milliseconds", + "example": "12.34" + } + } + }, + "Voicemail": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Unique voicemail identifier" + }, + "callerIdNum": { + "type": "string", + "description": "Caller's phone number" + }, + "callerIdName": { + "type": "string", + "description": "Caller's name (if available)" + }, + "date": { + "type": "string", + "description": "Timestamp of the voicemail", + "example": "Wed Mar 18 11:57:09 PM UTC 2026" + }, + "duration": { + "type": "number", + "description": "Duration in seconds" + }, + "fileSize": { + "type": "integer", + "description": "Audio file size in bytes" + }, + "hasAudio": { + "type": "boolean", + "description": "Whether audio is available for playback" + }, + "folder": { + "type": "string", + "enum": [ + "INBOX", + "Family", + "Friends", + "Old", + "Work", + "Urgent" + ], + "description": "Current folder" + }, + "originalMessageId": { + "type": "string", + "description": "Original message ID before any moves" + } + } + }, + "ConferenceRoom": { + "type": "object", + "properties": { + "conferenceId": { + "type": "string", + "description": "Conference room identifier", + "example": "400" + }, + "parties": { + "type": "integer", + "description": "Number of participants", + "example": 1 + }, + "marked": { + "type": "integer", + "description": "Number of marked participants", + "example": 0 + }, + "locked": { + "type": "boolean", + "description": "Whether the room is locked", + "example": false + } + } + }, + "Participant": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Asterisk channel identifier" + }, + "callerIdNum": { + "type": "string", + "description": "Participant's extension number" + }, + "callerIdName": { + "type": "string", + "description": "Participant's display name" + }, + "muted": { + "type": "boolean", + "description": "Whether the participant is muted" + }, + "admin": { + "type": "boolean", + "description": "Whether the participant is a conference admin" + }, + "talking": { + "type": "boolean", + "description": "Whether the participant is currently speaking" + } + } + }, + "CallRecords": { + "type": "object", + "properties": { + "records": { + "type": "object", + "properties": { + "total_calls_ever_placed": { + "type": "integer", + "description": "Total calls since launch", + "example": 5324 + }, + "record_calls": { + "type": "object", + "description": "Single-day call record", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date" + } + } + }, + "last_updated": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": { + "type": "integer", + "description": "Monthly call totals (e.g. monthly_total_2025-01)" + } + } + } + }, + "SurveyData": { + "type": "object", + "properties": { + "phoneTypes": { + "type": "object", + "description": "Device types and their counts", + "additionalProperties": { + "type": "integer" + }, + "example": { + "softphone": 45, + "ip_phone": 23, + "ata": 8 + } + }, + "brands": { + "type": "object", + "description": "Device brands and their counts", + "additionalProperties": { + "type": "integer" + }, + "example": { + "Polycom": 12, + "Cisco": 8, + "Yealink": 7 + } + }, + "needsCategorization": { + "type": "array", + "description": "Devices that couldn't be automatically categorized", + "items": { + "type": "object", + "properties": { + "ua": { + "type": "string", + "description": "User-Agent string" + } + } + } + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "description": "When the survey data was last refreshed" + } + } + } + } + } +} \ No newline at end of file diff --git a/public/api-spec/openapi.yaml b/public/api-spec/openapi.yaml new file mode 100644 index 0000000..1c772f9 --- /dev/null +++ b/public/api-spec/openapi.yaml @@ -0,0 +1,781 @@ +openapi: "3.0.3" +info: + title: LiteNet API + description: | + LiteNet is a free community PBX based on FreePBX. This API allows you to + manage your extension, access voicemails, view conferences, and interact + with the LiteNet PBX programmatically. + + **Authentication:** Most endpoints require a Bearer token obtained via + Discord OAuth. Pass it in the `Authorization` header: + `Authorization: Bearer *** version: "1.0.0" + contact: + name: LiteNet + url: https://litenet.tel +servers: + - url: https://api.litenet.tel + description: Production API + - url: http://localhost:3001 + description: Local development +paths: + /extensions/me: + get: + summary: Get current extension + description: Returns the authenticated user's extension details, + including their Discord profile and PBX device configuration. + tags: + - Extensions + security: + - BearerAuth: [] + responses: + "200": + description: Extension details + content: + application/json: + schema: + $ref: "#/components/schemas/ExtensionDetails" + "401": + description: Unauthorized + /extensions/me/devicestatus: + get: + summary: Get device registration status + description: Returns whether the user's SIP device is currently + registered with the PBX. Polled every 5 seconds on the dashboard. + tags: + - Extensions + security: + - BearerAuth: [] + responses: + "200": + description: Device status + content: + application/json: + schema: + type: object + properties: + extension: + type: string + description: The extension number + example: "1010" + deviceState: + type: string + description: Human-readable state (e.g. "Registered", "In use") + example: In use + activeChannels: + type: string + nullable: true + description: Active channel info or null if none + example: null + /extensions/me/calls: + get: + summary: List active calls + description: Returns all currently active calls for the authenticated + user's extension. Polled every 5 seconds on the dashboard. + tags: + - Calls + security: + - BearerAuth: [] + responses: + "200": + description: List of active calls + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ActiveCall" + delete: + summary: Hangup all calls + description: Hangs up every active call for the user's extension. + tags: + - Calls + security: + - BearerAuth: [] + responses: + "200": + description: All calls hung up + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Status message + example: "Successfully requested termination for 1 call(s) for extension 1010." + terminatedChannels: + type: array + description: List of terminated channel IDs + items: + type: string + example: ["PJSIP/1010-00000002"] + "401": + description: Unauthorized + /extensions/me/callme: + post: + summary: Call me + description: Triggers the PBX to call the user's extension. Supports + music on hold and echo test modes. + tags: + - Extensions + security: + - BearerAuth: [] + parameters: + - in: query + name: mode + required: true + schema: + type: string + enum: [hold, echo] + description: | + `hold` — play music on hold + `echo` — echo test (play back what you say) + - in: query + name: callerId + required: false + schema: + type: string + description: Custom caller ID to display (defaults to the extension number) + - in: query + name: autoAnswerMode + required: false + schema: + type: string + description: Auto-answer mode for the call + responses: + "200": + description: Call initiated + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Status message + example: "Successfully initiated hold call to extension 1010." + /extensions/me/resetsecret: + post: + summary: Reset SIP secret + description: Resets the SIP password for the extension. Returns the + new secret once — it cannot be retrieved again. + tags: + - Extensions + security: + - BearerAuth: [] + responses: + "200": + description: Secret reset + content: + application/json: + schema: + type: object + properties: + newSecret: + type: string + description: The new SIP secret (displayed once) + example: "aB3xK9mP2qR7" + /extensions/me/dnd: + get: + summary: Get Do Not Disturb status + description: Returns whether Do Not Disturb is currently enabled. + tags: + - Extensions + security: + - BearerAuth: [] + responses: + "200": + description: DND status + content: + application/json: + schema: + type: object + properties: + extension: + type: string + description: The extension number + example: "1010" + dndStatus: + type: boolean + description: Whether DND is enabled + example: false + post: + summary: Toggle Do Not Disturb + description: Enables or disables Do Not Disturb for the extension. + tags: + - Extensions + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - dndStatus + properties: + dndStatus: + type: boolean + description: true to enable, false to disable + responses: + "200": + description: DND updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Status message + example: "DND status for extension 1010 set to true." + extension: + type: string + description: The extension number + example: "1010" + dndStatus: + type: boolean + description: Updated DND state + example: true + /extensions/me/endpoint: + get: + summary: List registered endpoints + description: Returns all SIP endpoints/devices registered to the + user's extension. Polled every 10 seconds on the dashboard. + tags: + - Extensions + security: + - BearerAuth: [] + responses: + "200": + description: List of registered devices + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RegisteredDevice" + + /extensions/me/voicemails: + get: + summary: List voicemails + description: Returns voicemail messages for the authenticated user. + tags: + - Voicemail + security: + - BearerAuth: [] + responses: + "200": + description: List of voicemails + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Voicemail" + /extensions/me/voicemails/{messageId}/download: + get: + summary: Download voicemail audio + description: Downloads the WAV audio file for a specific voicemail. + tags: + - Voicemail + security: + - BearerAuth: [] + parameters: + - in: path + name: messageId + required: true + schema: + type: string + responses: + "200": + description: WAV audio file + content: + audio/wav: + schema: + type: string + format: binary + /extensions/me/voicemails/{messageId}/move: + patch: + summary: Move voicemail to folder + description: Moves a voicemail into a different folder for organization. + tags: + - Voicemail + security: + - BearerAuth: [] + parameters: + - in: path + name: messageId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - targetFolder + properties: + targetFolder: + type: string + enum: [INBOX, Family, Friends, Old, Work, Urgent] + responses: + "200": + description: Voicemail moved + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Status message + example: "Voicemail moved from Old to Friends." + messageId: + type: string + description: The voicemail message ID + example: "849c9236" + sourceFolder: + type: string + description: Original folder + example: Old + targetFolder: + type: string + description: Destination folder + example: Friends + newMessageNumber: + type: string + description: New message number after move + example: msg0001 + /extensions/me/voicemails/{messageId}: + delete: + summary: Delete voicemail + description: Permanently deletes a voicemail message. + tags: + - Voicemail + security: + - BearerAuth: [] + parameters: + - in: path + name: messageId + required: true + schema: + type: string + responses: + "200": + description: Voicemail deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: Status message + example: "Voicemail msg0033 deleted permanently from Old." + messageId: + type: string + description: The voicemail message ID + example: "37aca380" + folder: + type: string + description: The folder the voicemail was in + example: Old + originalMessageId: + type: string + description: The original message number + example: msg0033 + + /conferences: + get: + summary: List conference rooms + description: Returns all currently active conference rooms and + their participant counts. + tags: + - Conferences + responses: + "200": + description: List of conference rooms + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ConferenceRoom" + /conferences/{conferenceId}: + get: + summary: Get conference participants + description: Returns the list of participants currently in a + specific conference room. + tags: + - Conferences + parameters: + - in: path + name: conferenceId + required: true + schema: + type: string + responses: + "200": + description: List of participants + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Participant" + /conferences/{conferenceId}/connectme: + post: + summary: Connect to conference + description: Dials the authenticated user's extension and bridges + them into the specified conference room. + tags: + - Conferences + parameters: + - in: path + name: conferenceId + required: true + schema: + type: string + security: + - BearerAuth: [] + responses: + "200": + description: Connected to conference + + /system/records: + get: + summary: Get call statistics + description: Returns aggregate call statistics for the LiteNet PBX. + Public endpoint — no authentication required. + tags: + - System + responses: + "200": + description: Call statistics + content: + application/json: + schema: + $ref: "#/components/schemas/CallRecords" + /system/survey: + get: + summary: Get hardware survey data + description: Returns aggregated hardware survey data showing device + types, brands, and devices needing categorization. + Public endpoint — no authentication required. + tags: + - System + responses: + "200": + description: Survey data + content: + application/json: + schema: + $ref: "#/components/schemas/SurveyData" +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: UUID + description: | + Obtain a token via Discord OAuth at `/auth/discord`. + Pass it as `Authorization: Bearer *** + schemas: + ExtensionDetails: + type: object + properties: + status: + type: boolean + description: Whether the request was successful + example: true + message: + type: string + description: Status message + example: "Extension found successfully" + id: + type: string + description: Base64-encoded extension identifier + example: "ZXh0ZW5zaW9uOjEwMTA=" + extensionId: + type: string + description: The 4-digit extension number + example: "1010" + user: + type: object + description: User profile and PBX settings + properties: + name: + type: string + description: Discord display name + example: rocord + outboundCid: + type: string + description: Outbound caller ID override + example: "" + voicemail: + type: string + description: Voicemail context + example: default + ringtimer: + type: integer + description: Ring time in seconds before voicemail + example: 0 + noanswer: + type: string + description: No-answer destination + example: "" + noanswerDestination: + type: string + description: No-answer destination context + example: "" + noanswerCid: + type: string + description: Caller ID on no-answer + example: "" + busyCid: + type: string + description: Caller ID on busy + example: "" + sipname: + type: string + description: SIP display name + example: "" + extPassword: + type: string + description: SIP secret (masked in dashboard) + example: "REDACTED" + coreDevice: + type: object + description: PBX device configuration + properties: + deviceId: + type: string + description: Device identifier + example: "1010" + dial: + type: string + description: Asterisk dial string + example: PJSIP/1010 + devicetype: + type: string + description: Device type (fixed, adhoc, etc.) + example: fixed + description: + type: string + description: Human-readable device description + example: rocord + emergencyCid: + type: string + description: Emergency caller ID override + example: "" + tech: + type: string + description: SIP technology driver + example: pjsip + ActiveCall: + type: object + properties: + channel: + type: string + description: Asterisk channel identifier + example: PJSIP/1010-00000002 + uniqueId: + type: string + description: Unique call identifier + example: "1781687772.6" + caller: + type: object + properties: + number: + type: string + description: Caller number + example: "1010" + name: + type: string + description: Caller display name + example: "Call Me Test (MusicOnHold)" + connectedLine: + type: object + properties: + number: + type: string + description: Connected line number + example: "1010" + name: + type: string + description: Connected line display name + example: "Call Me Test (MusicOnHold)" + state: + type: string + description: Call state (e.g. "Up", "Ring") + example: Up + duration: + type: string + description: Call duration in HH:MM:SS format + example: "00:01:49" + application: + type: string + description: Asterisk application name + example: MusicOnHold + applicationData: + type: string + description: Application data/arguments + example: "" + RegisteredDevice: + type: object + properties: + uri: + type: string + description: SIP contact URI + example: sip:1010@192.168.1.100:5060 + useragent: + type: string + description: User-Agent header from the device + example: Linphone/4.4.0 + ip: + type: string + description: Client IP address + example: 192.168.1.100 + port: + type: string + description: Client port + example: "5060" + pingMs: + type: string + description: Round-trip latency in milliseconds + example: "12.34" + Voicemail: + type: object + properties: + messageId: + type: string + description: Unique voicemail identifier + callerIdNum: + type: string + description: Caller's phone number + callerIdName: + type: string + description: Caller's name (if available) + date: + type: string + description: Timestamp of the voicemail + example: "Wed Mar 18 11:57:09 PM UTC 2026" + duration: + type: number + description: Duration in seconds + fileSize: + type: integer + description: Audio file size in bytes + hasAudio: + type: boolean + description: Whether audio is available for playback + folder: + type: string + enum: [INBOX, Family, Friends, Old, Work, Urgent] + description: Current folder + originalMessageId: + type: string + description: Original message ID before any moves + ConferenceRoom: + type: object + properties: + conferenceId: + type: string + description: Conference room identifier + example: "400" + parties: + type: integer + description: Number of participants + example: 1 + marked: + type: integer + description: Number of marked participants + example: 0 + locked: + type: boolean + description: Whether the room is locked + example: false + Participant: + type: object + properties: + channel: + type: string + description: Asterisk channel identifier + callerIdNum: + type: string + description: Participant's extension number + callerIdName: + type: string + description: Participant's display name + muted: + type: boolean + description: Whether the participant is muted + admin: + type: boolean + description: Whether the participant is a conference admin + talking: + type: boolean + description: Whether the participant is currently speaking + CallRecords: + type: object + properties: + records: + type: object + properties: + total_calls_ever_placed: + type: integer + description: Total calls since launch + example: 5324 + record_calls: + type: object + description: Single-day call record + properties: + count: + type: integer + date: + type: string + format: date + last_updated: + type: string + format: date-time + additionalProperties: + type: integer + description: Monthly call totals (e.g. monthly_total_2025-01) + SurveyData: + type: object + properties: + phoneTypes: + type: object + description: Device types and their counts + additionalProperties: + type: integer + example: + softphone: 45 + ip_phone: 23 + ata: 8 + brands: + type: object + description: Device brands and their counts + additionalProperties: + type: integer + example: + Polycom: 12 + Cisco: 8 + Yealink: 7 + needsCategorization: + type: array + description: Devices that couldn't be automatically categorized + items: + type: object + properties: + ua: + type: string + description: User-Agent string + lastUpdated: + type: string + format: date-time + description: When the survey data was last refreshed \ No newline at end of file diff --git a/src/app/developers/DevelopersClient.jsx b/src/app/developers/DevelopersClient.jsx new file mode 100644 index 0000000..1c9ee1d --- /dev/null +++ b/src/app/developers/DevelopersClient.jsx @@ -0,0 +1,1066 @@ +"use client"; + +import { useEffect, useState, useCallback } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { ChevronDown, ChevronRight, ExternalLink, Play, AlertCircle, CheckCircle2, Loader2, Copy, Lock } from 'lucide-react'; +import { useAuth } from '@/contexts/AuthContext'; +import { usePlausible } from 'next-plausible'; + +const methodColors = { + get: 'bg-blue-600/20 border-blue-600/30 text-blue-400', + post: 'bg-green-600/20 border-green-600/30 text-green-400', + put: 'bg-orange-600/20 border-orange-600/30 text-orange-400', + patch: 'bg-purple-600/20 border-purple-600/30 text-purple-400', + delete: 'bg-red-600/20 border-red-600/30 text-red-400', +}; + +function MethodBadge({ method }) { + const m = method.toLowerCase(); + return ( + + {method} + + ); +} + +/** Resolve a $ref string against the schemas table, with cycle protection. */ +function resolveSchema(schema, schemas, visited = new Set()) { + if (!schema) return null; + if (schema.$ref) { + const name = schema.$ref.split('/').pop(); + if (!schemas?.[name] || visited.has(name)) return { $refName: name, resolved: false }; + visited.add(name); + const resolved = resolveSchema(schemas[name], schemas, visited); + if (resolved?.$refName && !resolved.resolved) return { $refName: name, resolved: false }; + return resolved; + } + if (schema.type === 'object' && schema.properties) { + const props = {}; + for (const [key, prop] of Object.entries(schema.properties)) { + props[key] = resolveSchema(prop, schemas, new Set(visited)); + } + return { type: 'object', properties: props, required: schema.required || [] }; + } + if (schema.type === 'array' && schema.items) { + return { type: 'array', items: resolveSchema(schema.items, schemas, new Set(visited)) }; + } + if (schema.allOf) { + const merged = { type: 'object', properties: {}, required: [] }; + for (const sub of schema.allOf) { + const r = resolveSchema(sub, schemas, new Set(visited)); + if (r?.type === 'object' && r.properties) { + Object.assign(merged.properties, r.properties); + merged.required.push(...(r.required || [])); + } + } + return merged; + } + return { type: schema.type, format: schema.format, example: schema.example, enum: schema.enum, nullable: schema.nullable, description: schema.description }; +} + +function buildSchemaExample(schema, schemas, visited = new Set()) { + if (!schema) return null; + if (schema.$ref) { + const name = schema.$ref.split('/').pop(); + if (!schemas?.[name] || visited.has(name)) return `«${name}»`; + visited.add(name); + return buildSchemaExample(schemas[name], schemas, visited); + } + if (schema.allOf) { + const merged = {}; + for (const sub of schema.allOf) { + Object.assign(merged, buildSchemaExample(sub, schemas, new Set(visited)) || {}); + } + return merged; + } + if (schema.type === 'object' && schema.properties) { + const obj = {}; + for (const [key, prop] of Object.entries(schema.properties)) { + const resolved = resolveSchema(prop, schemas, new Set(visited)); + if (resolved?.example !== undefined) { + obj[key] = resolved.example; + } else if (resolved?.type === 'string') { + obj[key] = resolved.enum ? resolved.enum[0] : 'string'; + } else if (resolved?.type === 'integer' || resolved?.type === 'number') { + obj[key] = 0; + } else if (resolved?.type === 'boolean') { + obj[key] = true; + } else if (resolved?.type === 'object') { + obj[key] = buildExample(prop, schemas, new Set(visited)); + } else if (resolved?.type === 'array') { + obj[key] = [buildExample(prop.items || prop, schemas, new Set(visited))]; + } else { + obj[key] = null; + } + } + return obj; + } + if (schema.type === 'array') { + return [buildExample(schema.items, schemas, new Set(visited))]; + } + return null; +} + +function buildExample(schema, schemas, visited) { + if (!schema) return null; + if (schema.$ref) { + const name = schema.$ref.split('/').pop(); + if (!schemas?.[name] || visited.has(name)) return `«${name}»`; + visited.add(name); + return buildExample(schemas[name], schemas, visited); + } + if (schema.allOf) { + const merged = {}; + for (const sub of schema.allOf) { + Object.assign(merged, buildExample(sub, schemas, new Set(visited)) || {}); + } + return merged; + } + if (schema.type === 'object' && schema.properties) { + const obj = {}; + for (const [key, prop] of Object.entries(schema.properties)) { + const r = resolveSchema(prop, schemas, new Set(visited)); + if (r?.example !== undefined) { + obj[key] = r.example; + } else if (r?.type === 'string') { + obj[key] = r.enum?.[0] || 'string'; + } else if (r?.type === 'integer' || r?.type === 'number') { + obj[key] = 0; + } else if (r?.type === 'boolean') { + obj[key] = true; + } else if (r?.type === 'object') { + obj[key] = buildExample(prop, schemas, new Set(visited)); + } else if (r?.type === 'array') { + obj[key] = [buildExample(prop.items || prop, schemas, new Set(visited))]; + } else { + obj[key] = null; + } + } + return obj; + } + if (schema.type === 'array') { + return [buildExample(schema.items, schemas, new Set(visited))]; + } + return null; +} + +function canShowStructuredFields(schema, schemas) { + if (!schema) return false; + const resolved = resolveSchema(schema, schemas, new Set()); + if (!resolved || resolved.type !== 'object' || !resolved.properties) return false; + return Object.values(resolved.properties).every(prop => + ['string', 'boolean', 'integer', 'number'].includes(prop.type) + ); +} + +function SchemaViewer({ schema, schemas, depth = 0, visited }) { + if (!schema) return any; + + const resolveMaybe = (s) => { + if (s?.$ref) { + const name = s.$ref.split('/').pop(); + if (schemas?.[name] && !visited?.has(name)) { + const next = new Set(visited || []); + next.add(name); + return resolveSchema(s, schemas); + } + return { $refName: name }; + } + return s; + }; + + const resolved = resolveMaybe(schema); + + if (resolved?.$refName) { + return {resolved.$refName}; + } + + if (resolved?.type === 'object' && resolved.properties) { + const nextVisited = new Set(visited || []); + return ( +
0 ? 'ml-4 pl-4 border-l border-gray-700/50' : ''}`}> + {Object.entries(resolved.properties).map(([key, prop]) => { + const required = resolved.required?.includes(key); + const p = resolveMaybe(prop); + return ( +
+
+ {key} + {required && required} + + {p.type}{p.enum ? ` (enum: ${p.enum.join(', ')})` : ''} + {p.nullable ? ' | null' : ''} + +
+ {p.description &&

{p.description}

} + {p.type === 'array' && ( +
+ Array of + +
+ )} + {p.type === 'object' && p.properties && ( + + )} +
+ ); + })} +
+ ); + } + + if (schema.type === 'array' && schema.items) { + return ( +
+ Array of + +
+ ); + } + + if (schema.enum) { + return enum: {schema.enum.join(', ')}; + } + + return ( + + {schema.type || 'any'} + {schema.format ? ` (${schema.format})` : ''} + + ); +} + +function EndpointCard({ method, path, endpoint, schemas, apiKey, baseUrl, isUsingSessionKey }) { + const [expanded, setExpanded] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testError, setTestError] = useState(null); + const plausible = usePlausible(); + + // Editable parameter values and request body + const [paramValues, setParamValues] = useState({}); + const [bodyText, setBodyText] = useState(null); + const [bodyFields, setBodyFields] = useState({}); + + const hasParams = endpoint.parameters && endpoint.parameters.length > 0; + const hasRequestBody = endpoint.requestBody; + const hasResponses = endpoint.responses; + const requiresAuth = endpoint.security && endpoint.security.length > 0; + + const needsApiKey = requiresAuth && (!apiKey || apiKey.trim() === ''); + + // Structured body fields — used when schema is a simple flat object with only scalar props + const bodySchema = endpoint.requestBody?.content?.['application/json']?.schema; + const resolvedBodySchema = bodySchema ? resolveSchema(bodySchema, schemas, new Set()) : null; + const useStructuredBody = canShowStructuredFields(bodySchema, schemas); + + const handleToggle = () => { + const next = !expanded; + setExpanded(next); + if (next) { + plausible('API Doc Endpoint Expand', { props: { method: method.toUpperCase(), path } }); + } + }; + + // Initialize paramValues from spec defaults when the card is first expanded + useEffect(() => { + if (expanded && hasParams && Object.keys(paramValues).length === 0) { + const init = {}; + for (const p of endpoint.parameters) { + init[p.name] = p.schema?.enum?.[0] || p.schema?.example || p.schema?.default || ''; + } + setParamValues(init); + } + }, [expanded]); + + // Initialize body state from spec when first expanded + useEffect(() => { + if (expanded && hasRequestBody) { + if (useStructuredBody && resolvedBodySchema?.properties && Object.keys(bodyFields).length === 0) { + const init = {}; + for (const [key, prop] of Object.entries(resolvedBodySchema.properties)) { + if (prop.type === 'boolean') { + init[key] = prop.example !== undefined ? prop.example : false; + } else if (prop.enum) { + init[key] = prop.example !== undefined ? prop.example : prop.enum[0]; + } else { + init[key] = prop.example !== undefined ? String(prop.example) : ''; + } + } + setBodyFields(init); + } else if (!useStructuredBody && bodyText === null) { + const example = bodySchema + ? buildExample(bodySchema, schemas, new Set()) + : null; + setBodyText(example ? formatJson(example) : ''); + } + } + }, [expanded]); + + const runTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + setTestError(null); + plausible('API Doc Test Send', { props: { method: method.toUpperCase(), path, auth: isUsingSessionKey ? 'session' : (apiKey ? 'manual' : 'none') } }); + + try { + // Build the URL, replacing path params with user values + let url = baseUrl + path.replace(/\{(\w+)\}/g, (_, name) => { + return encodeURIComponent(paramValues[name] || `{${name}}`); + }); + + // Build query string for query parameters + const queryParams = endpoint.parameters?.filter(p => p.in === 'query') || []; + if (queryParams.length > 0) { + const qs = queryParams.map(p => { + const val = paramValues[p.name]; + if (val === undefined || val === '') return null; + return `${encodeURIComponent(p.name)}=${encodeURIComponent(val)}`; + }).filter(Boolean).join('&'); + if (qs) url += '?' + qs; + } + + // Build headers + const headers = { 'Content-Type': 'application/json' }; + if (requiresAuth && apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + // Build request body from structured fields or raw textarea + let body = null; + if (needsRequestBody) { + if (useStructuredBody) { + // Coerce types back from string inputs before sending + const coerced = {}; + for (const [key, prop] of Object.entries(resolvedBodySchema?.properties || {})) { + const val = bodyFields[key]; + if (prop.type === 'boolean') { + coerced[key] = val === true || val === 'true'; + } else if (prop.type === 'integer' || prop.type === 'number') { + coerced[key] = Number(val); + } else { + coerced[key] = val; + } + } + body = JSON.stringify(coerced); + } else if (bodyText !== null && bodyText.trim() !== '') { + try { + body = JSON.stringify(JSON.parse(bodyText)); + } catch { + body = bodyText; + } + } + } + + const fetchOptions = { + method: method.toUpperCase(), + headers, + }; + if (body && method.toLowerCase() !== 'get') { + fetchOptions.body = body; + } + + const res = await fetch(url, fetchOptions); + const contentType = res.headers.get('content-type') || ''; + let data; + if (contentType.includes('application/json')) { + data = await res.json(); + } else { + data = await res.text(); + } + + setTestResult({ + status: res.status, + statusText: res.statusText, + data, + headers: Object.fromEntries(res.headers.entries()), + }); + + if (!res.ok) { + setTestError(`HTTP ${res.status} ${res.statusText}`); + } + plausible('API Doc Test Result', { + props: { + method: method.toUpperCase(), + path, + status: res.status, + category: res.ok ? 'success' : (res.status >= 500 ? 'server_error' : 'client_error'), + } + }); + } catch (err) { + setTestError(err.message); + plausible('API Doc Test Result', { + props: { method: method.toUpperCase(), path, status: 0, category: 'network_error' } + }); + } finally { + setTesting(false); + } + }, [method, path, endpoint, schemas, apiKey, baseUrl, requiresAuth, paramValues, bodyText, bodyFields, useStructuredBody, resolvedBodySchema, plausible, isUsingSessionKey]); + + const clearTest = () => { + setTestResult(null); + setTestError(null); + }; + + const needsPathParams = endpoint.parameters?.some(p => p.in === 'path') || false; + const needsQueryParams = endpoint.parameters?.some(p => p.in === 'query') || false; + const needsRequestBody = hasRequestBody && method.toLowerCase() !== 'get'; + + return ( +
+ + + {expanded && ( +
+ {/* Description */} + {endpoint.description && endpoint.description !== endpoint.summary && ( +
+

Description

+

{endpoint.description}

+
+ )} + + {/* Parameters */} + {hasParams && ( +
+

Parameters

+
+ {endpoint.parameters.map((param, i) => ( +
+
+ {param.name} + {param.in} + {param.required && required} + {param.schema?.type} +
+ {param.description &&

{param.description}

} + {param.schema?.enum &&

enum: {param.schema.enum.join(', ')}

} +
+ ))} +
+
+ )} + + {/* Request Body */} + {hasRequestBody && ( +
+

Request Body

+
+ {endpoint.requestBody.description && ( +

{endpoint.requestBody.description}

+ )} + + {endpoint.requestBody.required &&

required

} + + {/* JSON example */} + {endpoint.requestBody.content?.['application/json']?.schema && ( + <> +
+
Example JSON
+ +
+
+                                            {formatJson(buildExample(endpoint.requestBody.content['application/json'].schema, schemas, new Set()))}
+                                        
+ + )} +
+
+ )} + + {/* Responses */} + {hasResponses && ( +
+

Responses

+
+ {Object.entries(endpoint.responses).map(([code, resp]) => ( +
+
+ + {code} + + {resp.description} +
+ + {/* Schema fields */} + {resp.content?.['application/json']?.schema && ( +
+ +
+ )} + + {/* JSON example */} + {resp.content?.['application/json']?.schema && ( +
+
+
Example JSON
+ +
+
+                                                    {formatJson(buildExample(resp.content['application/json'].schema, schemas, new Set()))}
+                                                
+
+ )} +
+ ))} +
+
+ )} + + {/* Curl Example */} +
+
+

Curl

+ +
+
+                            {buildCurlExample(method, path, endpoint, schemas, apiKey, isUsingSessionKey)}
+                        
+
+ + {/* Test Section */} +
+
+

Test Endpoint

+ {needsApiKey && ( + + + Enter API key above to test + + )} +
+ + {!needsApiKey ? ( +
+ {/* Editable Path Parameters */} + {needsPathParams && ( +
+
Path Parameters
+
+ {endpoint.parameters.filter(p => p.in === 'path').map((param) => ( +
+ {param.name} + setParamValues(prev => ({...prev, [param.name]: e.target.value}))} + placeholder={param.schema?.example || `Enter ${param.name}...`} + className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-1.5 text-sm font-mono text-gray-200 placeholder-gray-600 outline-none focus:border-blue-500/50 transition-colors" + /> +
+ ))} +
+
+ )} + + {/* Editable Query Parameters */} + {needsQueryParams && ( +
+
Query Parameters
+
+ {endpoint.parameters.filter(p => p.in === 'query').map((param) => { + const enumValues = param.schema?.enum; + return ( +
+ {param.name} + {enumValues ? ( + + ) : ( + setParamValues(prev => ({...prev, [param.name]: e.target.value}))} + placeholder={param.schema?.example || `Enter ${param.name}...`} + className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-1.5 text-sm font-mono text-gray-200 placeholder-gray-600 outline-none focus:border-blue-500/50 transition-colors" + /> + )} + {param.required ? 'required' : 'optional'} +
+ ); + })} +
+
+ )} + + {/* Editable Request Body */} + {needsRequestBody && ( +
+
+ Request Body + {!useStructuredBody && bodyText && (() => { + try { JSON.parse(bodyText); return null; } + catch { return (invalid JSON); } + })()} +
+ {useStructuredBody && resolvedBodySchema?.properties ? ( +
+ {Object.entries(resolvedBodySchema.properties).map(([key, prop]) => { + const required = resolvedBodySchema.required?.includes(key); + return ( +
+ {key} + {prop.type === 'boolean' ? ( + + ) : prop.enum ? ( + + ) : ( + setBodyFields(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={prop.example !== undefined ? String(prop.example) : `Enter ${key}...`} + className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-1.5 text-sm font-mono text-gray-200 placeholder-gray-600 outline-none focus:border-blue-500/50 transition-colors" + /> + )} + {required ? 'required' : 'optional'} +
+ ); + })} +
+ ) : ( +