From cf47898ad5fda1664836b5da6c264ff951475f86 Mon Sep 17 00:00:00 2001 From: Christian Hoffmann Date: Sun, 27 Feb 2022 02:18:50 +0100 Subject: [PATCH 1/3] Util: Make TruncateString() available to headless builds as well --- src/util.cpp | 26 +++++++++++++------------- src/util.h | 4 +--- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/util.cpp b/src/util.cpp index 993116cba5..1fbbc7adb9 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -669,19 +669,6 @@ void CLanguageComboBox::OnLanguageActivated ( int iLanguageIdx ) } } -QString TruncateString ( QString str, int position ) -{ - QTextBoundaryFinder tbfString ( QTextBoundaryFinder::Grapheme, str ); - - tbfString.setPosition ( position ); - if ( !tbfString.isAtBoundary() ) - { - tbfString.toPreviousBoundary(); - position = tbfString.position(); - } - return str.left ( position ); -} - QSize CMinimumStackedLayout::sizeHint() const { // always use the size of the currently visible widget: @@ -1417,3 +1404,16 @@ QString MakeClientNameTitle ( QString win, QString client ) } return ( sReturnString ); } + +QString TruncateString ( QString str, int position ) +{ + QTextBoundaryFinder tbfString ( QTextBoundaryFinder::Grapheme, str ); + + tbfString.setPosition ( position ); + if ( !tbfString.isAtBoundary() ) + { + tbfString.toPreviousBoundary(); + position = tbfString.position(); + } + return str.left ( position ); +} diff --git a/src/util.h b/src/util.h index 8de9c9e74b..3c07fe8d5f 100644 --- a/src/util.h +++ b/src/util.h @@ -40,7 +40,6 @@ # include # include # include -# include # include # include "ui_aboutdlgbase.h" #endif @@ -52,6 +51,7 @@ #include #include #include +#include #include #include #include "global.h" @@ -101,9 +101,7 @@ inline int CalcBitRateBitsPerSecFromCodedBytes ( const int iCeltNumCodedBytes, c QString GetVersionAndNameStr ( const bool bDisplayInGui = true ); QString MakeClientNameTitle ( QString win, QString client ); -#ifndef HEADLESS QString TruncateString ( QString str, int position ); -#endif /******************************************************************************\ * CVector Base Class * From d7047171ad4c9e99a1f68baca845629dbf0deab0 Mon Sep 17 00:00:00 2001 From: dtinth on MBP M1 Date: Wed, 15 Sep 2021 22:02:35 +0700 Subject: [PATCH 2/3] Add JSON-RPC interface to control client and server Co-authored-by: ann0see <20726856+ann0see@users.noreply.github.com> Co-authored-by: Christian Hoffmann --- Jamulus.pro | 6 + src/clientrpc.cpp | 263 ++++++++++++++++++++++++++++++++++++++++++++ src/clientrpc.h | 43 ++++++++ src/global.h | 9 ++ src/main.cpp | 77 +++++++++++++ src/rpcserver.cpp | 271 ++++++++++++++++++++++++++++++++++++++++++++++ src/rpcserver.h | 84 ++++++++++++++ src/serverrpc.cpp | 239 ++++++++++++++++++++++++++++++++++++++++ src/serverrpc.h | 39 +++++++ 9 files changed, 1031 insertions(+) create mode 100644 src/clientrpc.cpp create mode 100644 src/clientrpc.h create mode 100644 src/rpcserver.cpp create mode 100644 src/rpcserver.h create mode 100644 src/serverrpc.cpp create mode 100644 src/serverrpc.h diff --git a/Jamulus.pro b/Jamulus.pro index 754d494f01..98cfeecb5c 100644 --- a/Jamulus.pro +++ b/Jamulus.pro @@ -443,6 +443,7 @@ FORMS_GUI = src/clientdlgbase.ui \ HEADERS += src/buffer.h \ src/channel.h \ src/client.h \ + src/clientrpc.h \ src/global.h \ src/protocol.h \ src/recorder/jamcontroller.h \ @@ -450,6 +451,8 @@ HEADERS += src/buffer.h \ src/server.h \ src/serverlist.h \ src/serverlogging.h \ + src/serverrpc.h \ + src/rpcserver.h \ src/settings.h \ src/socket.h \ src/soundbase.h \ @@ -545,12 +548,15 @@ HEADERS_OPUS_X86 = libs/opus/celt/x86/celt_lpc_sse.h \ SOURCES += src/buffer.cpp \ src/channel.cpp \ src/client.cpp \ + src/clientrpc.cpp \ src/main.cpp \ src/protocol.cpp \ src/recorder/jamcontroller.cpp \ src/server.cpp \ src/serverlist.cpp \ src/serverlogging.cpp \ + src/serverrpc.cpp \ + src/rpcserver.cpp \ src/settings.cpp \ src/signalhandler.cpp \ src/socket.cpp \ diff --git a/src/clientrpc.cpp b/src/clientrpc.cpp new file mode 100644 index 0000000000..5af4b62271 --- /dev/null +++ b/src/clientrpc.cpp @@ -0,0 +1,263 @@ +/******************************************************************************\ + * Copyright (c) 2021 + * + * Author(s): + * dtinth + * Christian Hoffmann + * + ****************************************************************************** + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + * + \******************************************************************************/ + +#include "clientrpc.h" + +CClientRpc::CClientRpc ( CClient* pClient, CRpcServer* pRpcServer, QObject* parent ) : QObject ( parent ) +{ + /// @rpc_notification jamulusclient/chatTextReceived + /// @brief Emitted when a chat text is received. + /// @param {string} params.chatText - The chat text. + connect ( pClient, &CClient::ChatTextReceived, [=] ( QString strChatText ) { + pRpcServer->BroadcastNotification ( "jamulusclient/chatTextReceived", + QJsonObject{ + { "chatText", strChatText }, + } ); + } ); + + /// @rpc_notification jamulusclient/connected + /// @brief Emitted when the client is connected to the server. + /// @param {number} params.id - The channel ID assigned to the client. + connect ( pClient, &CClient::ClientIDReceived, [=] ( int iChanID ) { + pRpcServer->BroadcastNotification ( "jamulusclient/connected", + QJsonObject{ + { "id", iChanID }, + } ); + } ); + + /// @rpc_notification jamulusclient/clientListReceived + /// @brief Emitted when the client list is received. + /// @param {array} params.clients - The client list. + /// @param {number} params.clients[*].id - The channel ID. + /// @param {string} params.clients[*].name - The musician’s name. + /// @param {string} params.clients[*].skillLevel - The musician’s skill level (beginner, intermediate, expert, or null). + /// @param {number} params.clients[*].countryId - The musician’s country ID (see QLocale::Country). + /// @param {string} params.clients[*].city - The musician’s city. + /// @param {number} params.clients[*].instrumentId - The musician’s instrument ID (see CInstPictures::GetTable). + connect ( pClient, &CClient::ConClientListMesReceived, [=] ( CVector vecChanInfo ) { + QJsonArray arrChanInfo; + for ( const auto& chanInfo : vecChanInfo ) + { + QJsonObject objChanInfo{ + { "id", chanInfo.iChanID }, + { "name", chanInfo.strName }, + { "skillLevel", SerializeSkillLevel ( chanInfo.eSkillLevel ) }, + { "countryId", chanInfo.eCountry }, + { "city", chanInfo.strCity }, + { "instrumentId", chanInfo.iInstrument }, + }; + arrChanInfo.append ( objChanInfo ); + } + pRpcServer->BroadcastNotification ( "jamulusclient/clientListReceived", + QJsonObject{ + { "clients", arrChanInfo }, + } ); + arrStoredChanInfo = arrChanInfo; + } ); + + /// @rpc_notification jamulusclient/channelLevelListReceived + /// @brief Emitted when the channel level list is received. + /// @param {array} params.channelLevelList - The channel level list. + /// Each item corresponds to the respective client retrieved from the jamulusclient/clientListReceived notification. + /// @param {number} params.channelLevelList[*] - The channel level, an integer between 0 and 9. + connect ( pClient, &CClient::CLChannelLevelListReceived, [=] ( CHostAddress /* unused */, CVector vecLevelList ) { + QJsonArray arrLevelList; + for ( const auto& level : vecLevelList ) + { + arrLevelList.append ( level ); + } + pRpcServer->BroadcastNotification ( "jamulusclient/channelLevelListReceived", + QJsonObject{ + { "channelLevelList", arrLevelList }, + } ); + } ); + + /// @rpc_notification jamulusclient/disconnected + /// @brief Emitted when the client is disconnected from the server. + /// @param {object} params - No parameters (empty object). + connect ( pClient, &CClient::Disconnected, [=]() { pRpcServer->BroadcastNotification ( "jamulusclient/disconnected", QJsonObject{} ); } ); + + /// @rpc_method jamulus/getMode + /// @brief Returns the current mode, i.e. whether Jamulus is running as a server or client. + /// @param {object} params - No parameters (empty object). + /// @result {string} result.mode - The current mode (server or client). + pRpcServer->HandleMethod ( "jamulus/getMode", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ { "mode", "client" } }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusclient/getClientInfo + /// @brief Returns the client information. + /// @param {object} params - No parameters (empty object). + /// @result {boolean} result.connected - Whether the client is connected to the server. + pRpcServer->HandleMethod ( "jamulusclient/getClientInfo", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ { "connected", pClient->IsConnected() } }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusclient/getChannelInfo + /// @brief Returns the client's profile information. + /// @param {object} params - No parameters (empty object). + /// @result {number} result.id - The channel ID. + /// @result {string} result.name - The musician’s name. + /// @result {string} result.skillLevel - The musician’s skill level (beginner, intermediate, expert, or null). + /// @result {number} result.countryId - The musician’s country ID (see QLocale::Country). + /// @result {string} result.city - The musician’s city. + /// @result {number} result.instrumentId - The musician’s instrument ID (see CInstPictures::GetTable). + /// @result {string} result.skillLevel - Your skill level (beginner, intermediate, expert, or null). + pRpcServer->HandleMethod ( "jamulusclient/getChannelInfo", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ + // TODO: We cannot include "id" here is pClient->ChannelInfo is a CChannelCoreInfo which lacks that field. + { "name", pClient->ChannelInfo.strName }, + { "countryId", pClient->ChannelInfo.eCountry }, + { "city", pClient->ChannelInfo.strCity }, + { "instrumentId", pClient->ChannelInfo.iInstrument }, + { "skillLevel", SerializeSkillLevel ( pClient->ChannelInfo.eSkillLevel ) }, + }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusclient/getClientList + /// @brief Returns the client list. + /// @param {object} params - No parameters (empty object). + /// @result {array} result.clients - The client list. See jamulusclient/clientListReceived for the format. + pRpcServer->HandleMethod ( "jamulusclient/getClientList", [=] ( const QJsonObject& params, QJsonObject& response ) { + if ( !pClient->IsConnected() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( 1, "Client is not connected" ); + return; + } + + QJsonObject result{ + { "clients", arrStoredChanInfo }, + }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusclient/setName + /// @brief Sets your name. + /// @param {string} params.name - The new name. + /// @result {string} result - Always "ok". + pRpcServer->HandleMethod ( "jamulusclient/setName", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonName = params["name"]; + if ( !jsonName.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: name is not a string" ); + return; + } + + pClient->ChannelInfo.strName = TruncateString ( jsonName.toString(), MAX_LEN_FADER_TAG ); + pClient->SetRemoteInfo(); + + response["result"] = "ok"; + } ); + + /// @rpc_method jamulusclient/setSkillLevel + /// @brief Sets your skill level. + /// @param {string} params.skillLevel - The new skill level (beginner, intermediate, expert, or null). + /// @result {string} result - Always "ok". + pRpcServer->HandleMethod ( "jamulusclient/setSkillLevel", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonSkillLevel = params["skillLevel"]; + if ( jsonSkillLevel.isNull() ) + { + pClient->ChannelInfo.eSkillLevel = SL_NOT_SET; + pClient->SetRemoteInfo(); + return; + } + + if ( !jsonSkillLevel.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: skillLevel is not a string" ); + return; + } + + auto strSkillLevel = jsonSkillLevel.toString(); + if ( strSkillLevel == "beginner" ) + { + pClient->ChannelInfo.eSkillLevel = SL_BEGINNER; + } + else if ( strSkillLevel == "intermediate" ) + { + pClient->ChannelInfo.eSkillLevel = SL_INTERMEDIATE; + } + else if ( strSkillLevel == "expert" ) + { + pClient->ChannelInfo.eSkillLevel = SL_PROFESSIONAL; + } + else + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, + "Invalid params: skillLevel is not beginner, intermediate or expert" ); + return; + } + + pClient->SetRemoteInfo(); + response["result"] = "ok"; + } ); + + /// @rpc_method jamulusclient/sendChatText + /// @brief Sends a chat text message. + /// @param {string} params.chatText - The chat text message. + /// @result {string} result - Always "ok". + pRpcServer->HandleMethod ( "jamulusclient/sendChatText", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonMessage = params["chatText"]; + if ( !jsonMessage.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: chatText is not a string" ); + return; + } + if ( !pClient->IsConnected() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( 1, "Client is not connected" ); + return; + } + + pClient->CreateChatTextMes ( jsonMessage.toString() ); + + response["result"] = "ok"; + } ); +} + +QJsonValue CClientRpc::SerializeSkillLevel ( ESkillLevel eSkillLevel ) +{ + switch ( eSkillLevel ) + { + case SL_BEGINNER: + return QJsonValue ( "beginner" ); + + case SL_INTERMEDIATE: + return QJsonValue ( "intermediate" ); + + case SL_PROFESSIONAL: + return QJsonValue ( "expert" ); + + default: + return QJsonValue ( QJsonValue::Null ); + } +} diff --git a/src/clientrpc.h b/src/clientrpc.h new file mode 100644 index 0000000000..45026b6ebc --- /dev/null +++ b/src/clientrpc.h @@ -0,0 +1,43 @@ +/******************************************************************************\ + * Copyright (c) 2021 + * + * Author(s): + * dtinth + * Christian Hoffmann + * + ****************************************************************************** + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + * +\******************************************************************************/ + +#pragma once + +#include "client.h" +#include "util.h" +#include "rpcserver.h" + +/* Classes ********************************************************************/ +class CClientRpc : public QObject +{ + Q_OBJECT + +public: + CClientRpc ( CClient* pClient, CRpcServer* pRpcServer, QObject* parent = nullptr ); + +private: + QJsonArray arrStoredChanInfo; + static QJsonValue SerializeSkillLevel ( ESkillLevel skillLevel ); +}; diff --git a/src/global.h b/src/global.h index 68e58e9b8f..22ae0c52bf 100644 --- a/src/global.h +++ b/src/global.h @@ -106,6 +106,9 @@ LED bar: lbr #define CENTSERV_GENRE_CLASSICAL_FOLK "classical.jamulus.io:22524" #define CENTSERV_GENRE_CHORAL "choral.jamulus.io:22724" +// specify an invalid port to disable the server +#define INVALID_PORT -1 + // servers to check for new versions #define UPDATECHECK1_ADDRESS "updatecheck1.jamulus.io" #define UPDATECHECK2_ADDRESS "updatecheck2.jamulus.io" @@ -278,6 +281,12 @@ LED bar: lbr // mixer settings file name suffix #define MIX_SETTINGS_FILE_SUFFIX "jch" +// minimum length of JSON-RPC secret string (main.cpp) +#define JSON_RPC_MINIMUM_SECRET_LENGTH 16 + +// JSON-RPC listen address +#define JSON_RPC_LISTEN_ADDRESS "127.0.0.1" + #define _MAXSHORT 32767 #define _MINSHORT ( -32768 ) #define INVALID_INDEX -1 // define invalid index as a negative value (a valid index must always be >= 0) diff --git a/src/main.cpp b/src/main.cpp index 3ddc8cf4ff..888be6778d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -42,6 +42,10 @@ # include "mac/activity.h" extern void qt_set_sequence_auto_mnemonic ( bool bEnable ); #endif +#include +#include "rpcserver.h" +#include "serverrpc.h" +#include "clientrpc.h" // Implementation ************************************************************** @@ -85,6 +89,7 @@ int main ( int argc, char** argv ) bool bEnableIPv6 = false; int iNumServerChannels = DEFAULT_USED_NUM_CHANNELS; quint16 iPortNumber = DEFAULT_PORT_NUMBER; + int iJsonRpcPortNumber = INVALID_PORT; quint16 iQosNumber = DEFAULT_QOS_NUMBER; ELicenceType eLicenceType = LT_NO_LICENCE; QString strMIDISetup = ""; @@ -101,6 +106,7 @@ int main ( int argc, char** argv ) QString strServerListFilter = ""; QString strWelcomeMessage = ""; QString strClientName = ""; + QString strJsonRpcSecretFileName = ""; #if !defined( HEADLESS ) && defined( _WIN32 ) if ( AttachConsole ( ATTACH_PARENT_PROCESS ) ) @@ -163,6 +169,24 @@ int main ( int argc, char** argv ) continue; } + // JSON-RPC port number ------------------------------------------------ + if ( GetNumericArgument ( argc, argv, i, "--jsonrpcport", "--jsonrpcport", 0, 65535, rDbleArgument ) ) + { + iJsonRpcPortNumber = static_cast ( rDbleArgument ); + qInfo() << qUtf8Printable ( QString ( "- JSON-RPC port number: %1" ).arg ( iJsonRpcPortNumber ) ); + CommandLineOptions << "--jsonrpcport"; + continue; + } + + // JSON-RPC secret file name ------------------------------------------- + if ( GetStringArgument ( argc, argv, i, "--jsonrpcsecretfile", "--jsonrpcsecretfile", strArgument ) ) + { + strJsonRpcSecretFileName = strArgument; + qInfo() << qUtf8Printable ( QString ( "- JSON-RPC secret file: %1" ).arg ( strJsonRpcSecretFileName ) ); + CommandLineOptions << "--jsonrpcsecretfile"; + continue; + } + // Quality of Service -------------------------------------------------- if ( GetNumericArgument ( argc, argv, i, "-Q", "--qos", 0, 255, rDbleArgument ) ) { @@ -785,6 +809,43 @@ int main ( int argc, char** argv ) //CTestbench Testbench ( "127.0.0.1", DEFAULT_PORT_NUMBER ); // clang-format on + CRpcServer* pRpcServer = nullptr; + + if ( iJsonRpcPortNumber != INVALID_PORT ) + { + if ( strJsonRpcSecretFileName.isEmpty() ) + { + qCritical() << qUtf8Printable ( QString ( "- JSON-RPC: --jsonrpcsecretfile is required. Exiting." ) ); + exit ( 1 ); + } + + QFile qfJsonRpcSecretFile ( strJsonRpcSecretFileName ); + if ( !qfJsonRpcSecretFile.open ( QFile::OpenModeFlag::ReadOnly ) ) + { + qCritical() << qUtf8Printable ( QString ( "- JSON-RPC: Unable to open secret file %1. Exiting." ).arg ( strJsonRpcSecretFileName ) ); + exit ( 1 ); + } + QTextStream qtsJsonRpcSecretStream ( &qfJsonRpcSecretFile ); + QString strJsonRpcSecret = qtsJsonRpcSecretStream.readLine(); + if ( strJsonRpcSecret.length() < JSON_RPC_MINIMUM_SECRET_LENGTH ) + { + qCritical() << qUtf8Printable ( QString ( "JSON-RPC: Refusing to run with secret of length %1 (required: %2). Exiting." ) + .arg ( strJsonRpcSecret.length() ) + .arg ( JSON_RPC_MINIMUM_SECRET_LENGTH ) ); + exit ( 1 ); + } + + qWarning() << "- JSON-RPC: This interface is experimental and is subject to breaking changes even on patch versions " + "(not subject to semantic versioning) during the initial phase."; + + pRpcServer = new CRpcServer ( pApp, iJsonRpcPortNumber, strJsonRpcSecret ); + if ( !pRpcServer->Start() ) + { + qCritical() << qUtf8Printable ( QString ( "- JSON-RPC: Server failed to start. Exiting." ) ); + exit ( 1 ); + } + } + try { if ( bIsClient ) @@ -811,6 +872,11 @@ int main ( int argc, char** argv ) CInstPictures::UpdateTableOnLanguageChange(); } + if ( pRpcServer ) + { + new CClientRpc ( &Client, pRpcServer, pRpcServer ); + } + #ifndef HEADLESS if ( bUseGUI ) { @@ -863,6 +929,11 @@ int main ( int argc, char** argv ) bEnableIPv6, eLicenceType ); + if ( pRpcServer ) + { + new CServerRpc ( &Server, pRpcServer, pRpcServer ); + } + #ifndef HEADLESS if ( bUseGUI ) { @@ -946,6 +1017,12 @@ QString UsageArguments ( char** argv ) " (not supported for headless server mode)\n" " -n, --nogui disable GUI (\"headless\")\n" " -p, --port set the local port number\n" + " --jsonrpcport enable JSON-RPC server, set TCP port number\n" + " (EXPERIMENTAL, APIs might still change;\n" + " only accessible from localhost)\n" + " --jsonrpcsecretfile\n" + " path to a single-line file which contains a freely\n" + " chosen secret to authenticate JSON-RPC users.\n" " -Q, --qos set the QoS value. Default is 128. Disable with 0\n" " (see the Jamulus website to enable QoS on Windows)\n" " -t, --notranslation disable translation (use English language)\n" diff --git a/src/rpcserver.cpp b/src/rpcserver.cpp new file mode 100644 index 0000000000..bb13dcb0d5 --- /dev/null +++ b/src/rpcserver.cpp @@ -0,0 +1,271 @@ +/******************************************************************************\ + * Copyright (c) 2021 + * + * Author(s): + * dtinth + * Christian Hoffmann + * + ****************************************************************************** + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + * + \******************************************************************************/ + +#include "global.h" +#include "rpcserver.h" + +CRpcServer::CRpcServer ( QObject* parent, int iPort, QString strSecret ) : + QObject ( parent ), + iPort ( iPort ), + strSecret ( strSecret ), + pTransportServer ( new QTcpServer ( this ) ) +{ + connect ( pTransportServer, &QTcpServer::newConnection, this, &CRpcServer::OnNewConnection ); + + /// @rpc_method jamulus/getVersion + /// @brief Returns Jamulus version. + /// @param {object} params - No parameters (empty object). + /// @result {string} result.version - The Jamulus version. + HandleMethod ( "jamulus/getVersion", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ { "version", VERSION } }; + response["result"] = result; + Q_UNUSED ( params ); + } ); +} + +CRpcServer::~CRpcServer() +{ + if ( pTransportServer->isListening() ) + { + qInfo() << "- stopping RPC server"; + pTransportServer->close(); + } +} + +bool CRpcServer::Start() +{ + if ( iPort < 0 ) + { + return false; + } + if ( pTransportServer->listen ( QHostAddress ( JSON_RPC_LISTEN_ADDRESS ), iPort ) ) + { + qInfo() << qUtf8Printable ( QString ( "- JSON-RPC: Server started on %1:%2" ) + .arg ( pTransportServer->serverAddress().toString() ) + .arg ( pTransportServer->serverPort() ) ); + return true; + } + qInfo() << "- JSON-RPC: Unable to start server:" << pTransportServer->errorString(); + return false; +} + +QJsonObject CRpcServer::CreateJsonRpcError ( int code, QString message ) +{ + QJsonObject error; + error["code"] = QJsonValue ( code ); + error["message"] = QJsonValue ( message ); + return error; +} + +QJsonObject CRpcServer::CreateJsonRpcErrorReply ( int code, QString message ) +{ + QJsonObject object; + object["jsonrpc"] = QJsonValue ( "2.0" ); + object["error"] = CreateJsonRpcError ( code, message ); + return object; +} + +void CRpcServer::OnNewConnection() +{ + QTcpSocket* pSocket = pTransportServer->nextPendingConnection(); + if ( !pSocket ) + { + return; + } + + qDebug() << "- JSON-RPC: received connection from:" << pSocket->peerAddress().toString(); + vecClients.append ( pSocket ); + isAuthenticated[pSocket] = false; + + connect ( pSocket, &QTcpSocket::disconnected, [this, pSocket]() { + qDebug() << "- JSON-RPC: connection from:" << pSocket->peerAddress().toString() << "closed"; + vecClients.removeAll ( pSocket ); + isAuthenticated.remove ( pSocket ); + pSocket->deleteLater(); + } ); + + connect ( pSocket, &QTcpSocket::readyRead, [this, pSocket]() { + while ( pSocket->canReadLine() ) + { + QByteArray line = pSocket->readLine(); + if ( line.trimmed().isEmpty() ) + { + Send ( pSocket, QJsonDocument ( CreateJsonRpcErrorReply ( iErrParseError, "Parse error: Blank line received" ) ) ); + continue; + } + QJsonParseError parseError; + QJsonDocument data = QJsonDocument::fromJson ( line, &parseError ); + + if ( parseError.error != QJsonParseError::NoError ) + { + Send ( pSocket, QJsonDocument ( CreateJsonRpcErrorReply ( iErrParseError, "Parse error: Invalid JSON received" ) ) ); + pSocket->disconnectFromHost(); + return; + } + + if ( data.isArray() ) + { + // JSON-RPC batch mode: multiple requests in an array + QJsonArray output; + for ( auto item : data.array() ) + { + if ( !item.isObject() ) + { + output.append ( + CreateJsonRpcErrorReply ( iErrInvalidRequest, "Invalid request: Non-object item encountered in a batch request array" ) ); + pSocket->disconnectFromHost(); + return; + } + auto object = item.toObject(); + QJsonObject response; + response["jsonrpc"] = QJsonValue ( "2.0" ); + response["id"] = object["id"]; + ProcessMessage ( pSocket, object, response ); + output.append ( response ); + } + if ( output.size() < 1 ) + { + Send ( pSocket, + QJsonDocument ( CreateJsonRpcErrorReply ( iErrInvalidRequest, "Invalid request: Empty batch request encountered" ) ) ); + pSocket->disconnectFromHost(); + return; + } + Send ( pSocket, QJsonDocument ( output ) ); + continue; + } + + if ( data.isObject() ) + { + auto object = data.object(); + QJsonObject response; + response["jsonrpc"] = QJsonValue ( "2.0" ); + response["id"] = object["id"]; + ProcessMessage ( pSocket, object, response ); + Send ( pSocket, QJsonDocument ( response ) ); + continue; + } + + Send ( + pSocket, + QJsonDocument ( CreateJsonRpcErrorReply ( iErrInvalidRequest, + "Invalid request: Unrecognized JSON; a request must be either an object or an array" ) ) ); + pSocket->disconnectFromHost(); + return; + } + } ); +} + +void CRpcServer::Send ( QTcpSocket* pSocket, const QJsonDocument& aMessage ) { pSocket->write ( aMessage.toJson ( QJsonDocument::Compact ) + "\n" ); } + +void CRpcServer::HandleApiAuth ( QTcpSocket* pSocket, const QJsonObject& params, QJsonObject& response ) +{ + auto userSecret = params["secret"]; + if ( !userSecret.isString() ) + { + response["error"] = CreateJsonRpcError ( iErrInvalidParams, "Invalid params: secret is not a string" ); + return; + } + + if ( userSecret == strSecret ) + { + isAuthenticated[pSocket] = true; + response["result"] = "ok"; + qInfo() << "- JSON-RPC: accepted valid authentication secret from" << pSocket->peerAddress().toString(); + return; + } + response["error"] = CreateJsonRpcError ( CRpcServer::iErrAuthenticationFailed, "Authentication failed." ); + qWarning() << "- JSON-RPC: rejected invalid authentication secret from" << pSocket->peerAddress().toString(); +} + +void CRpcServer::HandleMethod ( const QString& strMethod, CRpcHandler pHandler ) { mapMethodHandlers[strMethod] = pHandler; } + +void CRpcServer::ProcessMessage ( QTcpSocket* pSocket, QJsonObject message, QJsonObject& response ) +{ + if ( !message["method"].isString() ) + { + response["error"] = CreateJsonRpcError ( iErrInvalidRequest, "Invalid request: The `method` member is not a string" ); + return; + } + + // Obtain the params + auto jsonParams = message["params"]; + if ( !jsonParams.isObject() ) + { + response["error"] = CreateJsonRpcError ( iErrInvalidParams, "Invalid params: The `params` member is not an object" ); + return; + } + auto params = jsonParams.toObject(); + + // Obtain the method name + auto method = message["method"].toString(); + + // Authentication must be allowed when un-authed + if ( method == "jamulus/apiAuth" ) + { + /// @rpc_method jamulus/apiAuth + /// @brief Authenticates the connection which is a requirement for calling further methods. + /// @param {object} params - No parameters (empty object). + /// @result {string} result - "ok" on success + HandleApiAuth ( pSocket, params, response ); + return; + } + + // Require authentication for everything else + if ( !isAuthenticated[pSocket] ) + { + response["error"] = CreateJsonRpcError ( iErrUnauthenticated, "Unauthenticated: Please authenticate using jamulus/apiAuth first" ); + qInfo() << "- JSON-RPC: rejected unauthenticated request from" << pSocket->peerAddress().toString(); + return; + } + + // Obtain the method handler + auto it = mapMethodHandlers.find ( method ); + if ( it == mapMethodHandlers.end() ) + { + response["error"] = CreateJsonRpcError ( iErrMethodNotFound, "Method not found" ); + return; + } + + // Call the method handler + auto methodHandler = mapMethodHandlers[method]; + methodHandler ( params, response ); + Q_UNUSED ( pSocket ); +} + +void CRpcServer::BroadcastNotification ( const QString& strMethod, const QJsonObject& aParams ) +{ + for ( auto socket : vecClients ) + { + if ( !isAuthenticated[socket] ) + { + continue; + } + QJsonObject notification; + notification["jsonrpc"] = "2.0"; + notification["method"] = strMethod; + notification["params"] = aParams; + Send ( socket, QJsonDocument ( notification ) ); + } +} diff --git a/src/rpcserver.h b/src/rpcserver.h new file mode 100644 index 0000000000..7b640e9990 --- /dev/null +++ b/src/rpcserver.h @@ -0,0 +1,84 @@ +/******************************************************************************\ + * Copyright (c) 2021 + * + * Author(s): + * dtinth + * Christian Hoffmann + * + ****************************************************************************** + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + * +\******************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef std::function CRpcHandler; + +/* Classes ********************************************************************/ +class CRpcServer : public QObject +{ + Q_OBJECT + +public: + CRpcServer ( QObject* parent, int iPort, QString secret ); + virtual ~CRpcServer(); + + bool Start(); + void HandleMethod ( const QString& strMethod, CRpcHandler pHandler ); + void BroadcastNotification ( const QString& strMethod, const QJsonObject& aParams ); + + static QJsonObject CreateJsonRpcError ( int code, QString message ); + + // JSON-RPC standard error codes + static const int iErrInvalidRequest = -32600; + static const int iErrMethodNotFound = -32601; + static const int iErrInvalidParams = -32602; + static const int iErrParseError = -32700; + + // Our errors + static const int iErrAuthenticationFailed = 400; + static const int iErrUnauthenticated = 401; + +private: + int iPort; + QString strSecret; + QTcpServer* pTransportServer; + + // A map from method name to handler functions + QMap mapMethodHandlers; + QMap isAuthenticated; + QVector vecClients; + + void HandleApiAuth ( QTcpSocket* pSocket, const QJsonObject& params, QJsonObject& response ); + void ProcessMessage ( QTcpSocket* pSocket, QJsonObject message, QJsonObject& response ); + void Send ( QTcpSocket* pSocket, const QJsonDocument& aMessage ); + + static QJsonObject CreateJsonRpcErrorReply ( int code, QString message ); + +protected slots: + void OnNewConnection(); +}; diff --git a/src/serverrpc.cpp b/src/serverrpc.cpp new file mode 100644 index 0000000000..f4c5ba3c68 --- /dev/null +++ b/src/serverrpc.cpp @@ -0,0 +1,239 @@ +/******************************************************************************\ + * Copyright (c) 2021 + * + * Author(s): + * dtinth + * Christian Hoffmann + * + ****************************************************************************** + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + * + \******************************************************************************/ + +#include "serverrpc.h" + +CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* parent ) : QObject ( parent ) +{ + // API doc already part of CClientRpc + pRpcServer->HandleMethod ( "jamulus/getMode", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ { "mode", "server" } }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusserver/getRecorderStatus + /// @brief Returns the recorder state. + /// @param {object} params - No parameters (empty object). + /// @result {boolean} result.initialised - True if the recorder is initialised. + /// @result {string} result.errorMessage - The recorder error message, if any. + /// @result {boolean} result.enabled - True if the recorder is enabled. + /// @result {string} result.recordingDirectory - The recorder recording directory. + pRpcServer->HandleMethod ( "jamulusserver/getRecorderStatus", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ + { "initialised", pServer->GetRecorderInitialised() }, + { "errorMessage", pServer->GetRecorderErrMsg() }, + { "enabled", pServer->GetRecordingEnabled() }, + { "recordingDirectory", pServer->GetRecordingDir() }, + }; + + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusserver/getClients + /// @brief Returns the list of connected clients along with details about them. + /// @param {object} params - No parameters (empty object). + /// @result {array} result.clients - The list of connected clients. + /// @result {number} result.clients[*].id - The client’s channel id. + /// @result {string} result.clients[*].address - The client’s address (ip:port). + /// @result {string} result.clients[*].name - The client’s name. + /// @result {number} result.clients[*].jitterBufferSize - The client’s jitter buffer size. + /// @result {number} result.clients[*].channels - The number of audio channels of the client. + pRpcServer->HandleMethod ( "jamulusserver/getClients", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonArray clients; + CVector vecHostAddresses; + CVector vecsName; + CVector veciJitBufNumFrames; + CVector veciNetwFrameSizeFact; + + pServer->GetConCliParam ( vecHostAddresses, vecsName, veciJitBufNumFrames, veciNetwFrameSizeFact ); + + // we assume that all vectors have the same length + const int iNumChannels = vecHostAddresses.Size(); + + // fill list with connected clients + for ( int i = 0; i < iNumChannels; i++ ) + { + if ( vecHostAddresses[i].InetAddr == QHostAddress ( static_cast ( 0 ) ) ) + { + continue; + } + QJsonObject client{ + { "id", i }, + { "address", vecHostAddresses[i].toString ( CHostAddress::SM_IP_PORT ) }, + { "name", vecsName[i] }, + { "jitterBufferSize", veciJitBufNumFrames[i] }, + { "channels", pServer->GetClientNumAudioChannels ( i ) }, + }; + clients.append ( client ); + } + + // create result object + QJsonObject result{ + { "clients", clients }, + }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusserver/getServerProfile + /// @brief Returns the server registration profile and status. + /// @param {object} params - No parameters (empty object). + /// @result {string} result.name - The server name. + /// @result {string} result.city - The server city. + /// @result {number} result.countryId - The server country ID (see QLocale::Country). + /// @result {string} result.welcomeMessage - The server welcome message. + /// @result {string} result.registrationStatus - The server registration status as string (see ESvrRegStatus and SerializeRegistrationStatus). + pRpcServer->HandleMethod ( "jamulusserver/getServerProfile", [=] ( const QJsonObject& params, QJsonObject& response ) { + QJsonObject result{ + { "name", pServer->GetServerName() }, + { "city", pServer->GetServerCity() }, + { "countryId", pServer->GetServerCountry() }, + { "welcomeMessage", pServer->GetWelcomeMessage() }, + { "registrationStatus", SerializeRegistrationStatus ( pServer->GetSvrRegStatus() ) }, + }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusserver/setServerName + /// @brief Sets the server name. + /// @param {string} params.serverName - The new server name. + /// @result {string} result - Always "ok". + pRpcServer->HandleMethod ( "jamulusserver/setServerName", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonServerName = params["serverName"]; + if ( !jsonServerName.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: serverName is not a string" ); + return; + } + + pServer->SetServerName ( jsonServerName.toString() ); + response["result"] = "ok"; + } ); + + /// @rpc_method jamulusserver/setWelcomeMessage + /// @brief Sets the server welcome message. + /// @param {string} params.welcomeMessage - The new welcome message. + /// @result {string} result - Always "ok". + pRpcServer->HandleMethod ( "jamulusserver/setWelcomeMessage", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonWelcomeMessage = params["welcomeMessage"]; + if ( !jsonWelcomeMessage.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: welcomeMessage is not a string" ); + return; + } + + pServer->SetWelcomeMessage ( jsonWelcomeMessage.toString() ); + response["result"] = "ok"; + } ); + + /// @rpc_method jamulusserver/setRecordingDirectory + /// @brief Sets the server recording directory. + /// @param {string} params.recordingDirectory - The new recording directory. + /// @result {string} result - Always "acknowledged". + /// To check if the directory was changed, call `jamulusserver/getRecorderStatus` again. + pRpcServer->HandleMethod ( "jamulusserver/setRecordingDirectory", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonRecordingDirectory = params["recordingDirectory"]; + if ( !jsonRecordingDirectory.isString() ) + { + response["error"] = + CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: recordingDirectory is not a string" ); + return; + } + + pServer->SetRecordingDir ( jsonRecordingDirectory.toString() ); + response["result"] = "acknowledged"; + } ); + + /// @rpc_method jamulusserver/startRecording + /// @brief Starts the server recording. + /// @param {object} params - No parameters (empty object). + /// @result {string} result - Always "acknowledged". + /// To check if the recording was enabled, call `jamulusserver/getRecorderStatus` again. + pRpcServer->HandleMethod ( "jamulusserver/startRecording", [=] ( const QJsonObject& params, QJsonObject& response ) { + pServer->SetEnableRecording ( true ); + response["result"] = "acknowledged"; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusserver/stopRecording + /// @brief Stops the server recording. + /// @param {object} params - No parameters (empty object). + /// @result {string} result - Always "acknowledged". + /// To check if the recording was disabled, call `jamulusserver/getRecorderStatus` again. + pRpcServer->HandleMethod ( "jamulusserver/stopRecording", [=] ( const QJsonObject& params, QJsonObject& response ) { + pServer->SetEnableRecording ( false ); + response["result"] = "acknowledged"; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusserver/restartRecording + /// @brief Restarts the recording into a new directory. + /// @param {object} params - No parameters (empty object). + /// @result {string} result - Always "acknowledged". + /// To check if the recording was restarted or if there is any error, call `jamulusserver/getRecorderStatus` again. + pRpcServer->HandleMethod ( "jamulusserver/restartRecording", [=] ( const QJsonObject& params, QJsonObject& response ) { + pServer->RequestNewRecording(); + response["result"] = "acknowledged"; + Q_UNUSED ( params ); + } ); +} + +QJsonValue CServerRpc::SerializeRegistrationStatus ( ESvrRegStatus eSvrRegStatus ) +{ + switch ( eSvrRegStatus ) + { + case SRS_NOT_REGISTERED: + return "not_registered"; + + case SRS_BAD_ADDRESS: + return "bad_address"; + + case SRS_REQUESTED: + return "requested"; + + case SRS_TIME_OUT: + return "time_out"; + + case SRS_UNKNOWN_RESP: + return "unknown_resp"; + + case SRS_REGISTERED: + return "registered"; + + case SRS_SERVER_LIST_FULL: + return "directory_server_full"; + + case SRS_VERSION_TOO_OLD: + return "server_version_too_old"; + + case SRS_NOT_FULFILL_REQUIREMENTS: + return "requirements_not_fulfilled"; + } + + return QString ( "unknown(%1)" ).arg ( eSvrRegStatus ); +} diff --git a/src/serverrpc.h b/src/serverrpc.h new file mode 100644 index 0000000000..1cce8877a0 --- /dev/null +++ b/src/serverrpc.h @@ -0,0 +1,39 @@ +/******************************************************************************\ + * Copyright (c) 2021 + * + * Author(s): + * dtinth + * Christian Hoffmann + * + ****************************************************************************** + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + * +\******************************************************************************/ + +#pragma once + +#include "server.h" +#include "rpcserver.h" + +/* Classes ********************************************************************/ +class CServerRpc : public QObject +{ + Q_OBJECT + +public: + CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* parent = nullptr ); + static QJsonValue SerializeRegistrationStatus ( ESvrRegStatus eSvrRegStatus ); +}; From bb1b6ee03d5fd73bcf376002d48cbe65b1062b7a Mon Sep 17 00:00:00 2001 From: Thai Pangsakulyanont Date: Wed, 2 Mar 2022 12:21:52 +0100 Subject: [PATCH 3/3] JSON-RPC: Docs: Add and CI-integrate API doc generator Co-authored-by: Christian Hoffmann --- .github/workflows/check-json-rpcs-docs.yml | 22 + docs/JSON-RPC.md | 446 +++++++++++++++++++++ tools/generate_json_rpc_docs.py | 225 +++++++++++ 3 files changed, 693 insertions(+) create mode 100644 .github/workflows/check-json-rpcs-docs.yml create mode 100644 docs/JSON-RPC.md create mode 100755 tools/generate_json_rpc_docs.py diff --git a/.github/workflows/check-json-rpcs-docs.yml b/.github/workflows/check-json-rpcs-docs.yml new file mode 100644 index 0000000000..35b75d8fac --- /dev/null +++ b/.github/workflows/check-json-rpcs-docs.yml @@ -0,0 +1,22 @@ +name: Check JSON-RPC docs + +on: + pull_request: + branches: + - master + paths: + - 'tools/generate_json_rpc_docs.py' + - 'src/*rpc*.cpp' + +jobs: + check-json-rpc-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: ./tools/generate_json_rpc_docs.py + - env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + [[ -z "$(git status --porcelain=v1)" ]] && exit 0 + echo "Please run ./tools/generate_json_rpc_docs.py to regenerate docs/JSON-RPC.md" + exit 1 diff --git a/docs/JSON-RPC.md b/docs/JSON-RPC.md new file mode 100644 index 0000000000..19f57478ad --- /dev/null +++ b/docs/JSON-RPC.md @@ -0,0 +1,446 @@ + +# Jamulus JSON-RPC Documentation + + + +A JSON-RPC interface is available for both Jamulus client and server to allow programmatic access. +To use the JSON-RPC interface, run Jamulus with the `--jsonrpcport --jsonrpcsecretfile /file/with/a/secret.txt` options. +This will start a JSON-RPC server on the specified port on the localhost. + +The file referenced by `--jsonrpcsecretfile` must contain a single line with a freely chosen string with at least 16 characters. +It can be generated like this: +``` +$ openssl rand -base64 10 > /file/with/a/secret.txt +``` + +## Wire protocol + +The JSON-RPC server is based on the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) protocol, using [streaming newline-delimited JSON over TCP](https://clue.engineering/2018/introducing-reactphp-ndjson) as the transport. There are three main types of messages being exchanged: + +- A **request** from the consumer to Jamulus. +- A **response** from Jamulus to the consumer. +- A **notification** from Jamulus to the consumer. + +## Example + +After opening a TCP connection to the JSON-RPC server, the connection must be authenticated: + +```json +{"id":1,"jsonrpc":"2.0","method":"jamulus/apiAuth","params":{"secret": "...the secret from the file in --jsonrpcsecretfile..."}} +``` + +Request must be sent as a single line of JSON-encoded data, followed by a newline character. Jamulus will send back a **response** in the same manner: + +```json +{"id":1,"jsonrpc":"2.0","result":"ok"} +``` +After successful authentication, the following **request** can be sent: + +```json +{"id":2,"jsonrpc":"2.0","method":"jamulus/getMode","params":{}} +``` + +The request must be sent as a single line of JSON-encoded data, followed by a newline character. Jamulus will send back a **response** in the same manner: + +```json +{"id":2,"jsonrpc":"2.0","result":{"mode":"client"}} +``` + +Jamulus will also send **notifications** to the consumer: + +```json +{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"text":"(01:23:45 AM) user test"}} +``` + +## Method reference +### jamulus/apiAuth + +Authenticates the connection which is a requirement for calling further methods. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | "ok" on success | + + +### jamulus/getMode + +Returns the current mode, i.e. whether Jamulus is running as a server or client. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.mode | string | The current mode (server or client). | + + +### jamulus/getVersion + +Returns Jamulus version. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.version | string | The Jamulus version. | + + +### jamulusclient/getChannelInfo + +Returns the client's profile information. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.id | number | The channel ID. | +| result.name | string | The musician’s name. | +| result.skillLevel | string | The musician’s skill level (beginner, intermediate, expert, or null). | +| result.countryId | number | The musician’s country ID (see QLocale::Country). | +| result.city | string | The musician’s city. | +| result.instrumentId | number | The musician’s instrument ID (see CInstPictures::GetTable). | +| result.skillLevel | string | Your skill level (beginner, intermediate, expert, or null). | + + +### jamulusclient/getClientInfo + +Returns the client information. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.connected | boolean | Whether the client is connected to the server. | + + +### jamulusclient/getClientList + +Returns the client list. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.clients | array | The client list. See jamulusclient/clientListReceived for the format. | + + +### jamulusclient/sendChatText + +Sends a chat text message. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.chatText | string | The chat text message. | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "ok". | + + +### jamulusclient/setName + +Sets your name. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.name | string | The new name. | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "ok". | + + +### jamulusclient/setSkillLevel + +Sets your skill level. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.skillLevel | string | The new skill level (beginner, intermediate, expert, or null). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "ok". | + + +### jamulusserver/getClients + +Returns the list of connected clients along with details about them. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.clients | array | The list of connected clients. | +| result.clients[*].id | number | The client’s channel id. | +| result.clients[*].address | string | The client’s address (ip:port). | +| result.clients[*].name | string | The client’s name. | +| result.clients[*].jitterBufferSize | number | The client’s jitter buffer size. | +| result.clients[*].channels | number | The number of audio channels of the client. | + + +### jamulusserver/getRecorderStatus + +Returns the recorder state. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.initialised | boolean | True if the recorder is initialised. | +| result.errorMessage | string | The recorder error message, if any. | +| result.enabled | boolean | True if the recorder is enabled. | +| result.recordingDirectory | string | The recorder recording directory. | + + +### jamulusserver/getServerProfile + +Returns the server registration profile and status. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.name | string | The server name. | +| result.city | string | The server city. | +| result.countryId | number | The server country ID (see QLocale::Country). | +| result.welcomeMessage | string | The server welcome message. | +| result.registrationStatus | string | The server registration status as string (see ESvrRegStatus and SerializeRegistrationStatus). | + + +### jamulusserver/restartRecording + +Restarts the recording into a new directory. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "acknowledged". To check if the recording was restarted or if there is any error, call `jamulusserver/getRecorderStatus` again. | + + +### jamulusserver/setRecordingDirectory + +Sets the server recording directory. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.recordingDirectory | string | The new recording directory. | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "acknowledged". To check if the directory was changed, call `jamulusserver/getRecorderStatus` again. | + + +### jamulusserver/setServerName + +Sets the server name. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.serverName | string | The new server name. | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "ok". | + + +### jamulusserver/setWelcomeMessage + +Sets the server welcome message. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.welcomeMessage | string | The new welcome message. | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "ok". | + + +### jamulusserver/startRecording + +Starts the server recording. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "acknowledged". To check if the recording was enabled, call `jamulusserver/getRecorderStatus` again. | + + +### jamulusserver/stopRecording + +Stops the server recording. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "acknowledged". To check if the recording was disabled, call `jamulusserver/getRecorderStatus` again. | + + +## Notification reference +### jamulusclient/channelLevelListReceived + +Emitted when the channel level list is received. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.channelLevelList | array | The channel level list. Each item corresponds to the respective client retrieved from the jamulusclient/clientListReceived notification. | +| params.channelLevelList[*] | number | The channel level, an integer between 0 and 9. | + + +### jamulusclient/chatTextReceived + +Emitted when a chat text is received. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.chatText | string | The chat text. | + + +### jamulusclient/clientListReceived + +Emitted when the client list is received. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.clients | array | The client list. | +| params.clients[*].id | number | The channel ID. | +| params.clients[*].name | string | The musician’s name. | +| params.clients[*].skillLevel | string | The musician’s skill level (beginner, intermediate, expert, or null). | +| params.clients[*].countryId | number | The musician’s country ID (see QLocale::Country). | +| params.clients[*].city | string | The musician’s city. | +| params.clients[*].instrumentId | number | The musician’s instrument ID (see CInstPictures::GetTable). | + + +### jamulusclient/connected + +Emitted when the client is connected to the server. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.id | number | The channel ID assigned to the client. | + + +### jamulusclient/disconnected + +Emitted when the client is disconnected from the server. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + + diff --git a/tools/generate_json_rpc_docs.py b/tools/generate_json_rpc_docs.py new file mode 100755 index 0000000000..a6f89db2d1 --- /dev/null +++ b/tools/generate_json_rpc_docs.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Generates the JSON RPC documentation from the source code and writes it into +../docs/JSON-RPC.md. + +Usage: +./tools/generate_json_rpc_docs.py + +""" + +import os +import re + +source_files = [ + "src/rpcserver.cpp", + "src/serverrpc.cpp", + "src/clientrpc.cpp", +] + +repo_root = os.path.join(os.path.dirname(__file__), '..') + +class DocumentationItem: + """ + Represents a documentation item. In the source code they look like this: + + /// @rpc_notification jamulusclient/channelLevelListReceived + /// @brief Emitted when the channel level list is received. + /// @param {array} params.channelLevelList - The channel level list. + /// Each item corresponds to the respective client retrieved from the jamulusclient/clientListReceived notification. + /// @param {number} params.channelLevelList[*] - The channel level, an integer between 0 and 9. + """ + + def __init__(self, name, type): + self.name = name + self.type = type + self.brief = DocumentationText() + self.params = [] + self.results = [] + + def handle_tag(self, tag_name): + if tag_name == "brief": + self.current_tag = self.brief + elif tag_name == "param": + self.current_tag = DocumentationText() + self.params.append(self.current_tag) + elif tag_name == "result": + self.current_tag = DocumentationText() + self.results.append(self.current_tag) + else: + raise Exception("Unknown tag: " + tag_name) + + def handle_text(self, text): + self.current_tag.add_text(text) + + def sort_key(self): + return self.type + ": " + self.name + + def to_markdown(self): + output = [] + output.append("### " + self.name) + output.append("") + output.append(str(self.brief)) + output.append("") + + if len(self.params) > 0: + output.append("Parameters:") + output.append("") + output.append(DocumentationTable(self.params).to_markdown()) + output.append("") + + if len(self.results) > 0: + output.append("Results:") + output.append("") + output.append(DocumentationTable(self.results).to_markdown()) + output.append("") + + return "\n".join(output) + + +class DocumentationText: + """ + Represents text inside the documentation. + """ + + def __init__(self): + self.parts = [] + + def add_text(self, text): + self.parts.append(text) + + def __str__(self): + return " ".join(self.parts) + + +class DocumentationTable: + """ + Represents parameters and results table. + """ + + def __init__(self, tags): + self.tags = tags + + def to_markdown(self): + output = [] + output.append("| Name | Type | Description |") + output.append("| --- | --- | --- |") + for tag in self.tags: + text = str(tag) + # Parse tag in form of "{type} name - description" + match = re.match(r"^\{(\w+)\}\s+([\S]+)\s+-\s+(.*)$", text, re.DOTALL) + if match: + type = match.group(1) + name = match.group(2) + description = re.sub(r"^\s+", " ", match.group(3)).strip() + output.append("| " + name + " | " + type + " | " + description + " |") + return "\n".join(output) + + +items = [] +current_item = None + +# Parse the source code +for source_file in source_files: + with open(os.path.join(repo_root, source_file), "r") as f: + for line in f.readlines(): + line = line.strip() + if line.startswith("/// @rpc_notification "): + current_item = DocumentationItem( + line[len("/// @rpc_notification "):], "notification" + ) + items.append(current_item) + elif line.startswith("/// @rpc_method "): + current_item = DocumentationItem( + line[len("/// @rpc_method "):], "method" + ) + items.append(current_item) + elif line.startswith("/// @brief "): + current_item.handle_tag("brief") + current_item.handle_text(line[len("/// @brief "):]) + elif line.startswith("/// @param "): + current_item.handle_tag("param") + current_item.handle_text(line[len("/// @param "):]) + elif line.startswith("/// @result "): + current_item.handle_tag("result") + current_item.handle_text(line[len("/// @result "):]) + elif line.startswith("///"): + current_item.handle_text(line[len("///"):]) + elif line == "": + pass + else: + current_item = None + +items.sort(key=lambda item: item.name) + +preamble = """ +# Jamulus JSON-RPC Documentation + + + +A JSON-RPC interface is available for both Jamulus client and server to allow programmatic access. +To use the JSON-RPC interface, run Jamulus with the `--jsonrpcport --jsonrpcsecretfile /file/with/a/secret.txt` options. +This will start a JSON-RPC server on the specified port on the localhost. + +The file referenced by `--jsonrpcsecretfile` must contain a single line with a freely chosen string with at least 16 characters. +It can be generated like this: +``` +$ openssl rand -base64 10 > /file/with/a/secret.txt +``` + +## Wire protocol + +The JSON-RPC server is based on the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) protocol, using [streaming newline-delimited JSON over TCP](https://clue.engineering/2018/introducing-reactphp-ndjson) as the transport. There are three main types of messages being exchanged: + +- A **request** from the consumer to Jamulus. +- A **response** from Jamulus to the consumer. +- A **notification** from Jamulus to the consumer. + +## Example + +After opening a TCP connection to the JSON-RPC server, the connection must be authenticated: + +```json +{"id":1,"jsonrpc":"2.0","method":"jamulus/apiAuth","params":{"secret": "...the secret from the file in --jsonrpcsecretfile..."}} +``` + +Request must be sent as a single line of JSON-encoded data, followed by a newline character. Jamulus will send back a **response** in the same manner: + +```json +{"id":1,"jsonrpc":"2.0","result":"ok"} +``` +After successful authentication, the following **request** can be sent: + +```json +{"id":2,"jsonrpc":"2.0","method":"jamulus/getMode","params":{}} +``` + +The request must be sent as a single line of JSON-encoded data, followed by a newline character. Jamulus will send back a **response** in the same manner: + +```json +{"id":2,"jsonrpc":"2.0","result":{"mode":"client"}} +``` + +Jamulus will also send **notifications** to the consumer: + +```json +{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"text":"(01:23:45 AM) user test"}} +``` + +""" + +docs_path = os.path.join(repo_root, 'docs', 'JSON-RPC.md') +with open(docs_path, "w") as f: + f.write(preamble) + + f.write("## Method reference\n") + for item in filter(lambda item: item.type == "method", items): + f.write(item.to_markdown() + "\n\n") + + f.write("## Notification reference\n") + for item in filter(lambda item: item.type == "notification", items): + f.write(item.to_markdown() + "\n\n")