From 1888eef7f98473b2a02032456e30b85e9feff49d Mon Sep 17 00:00:00 2001 From: Antonino Bonanno Date: Mon, 24 Feb 2025 18:29:48 +0100 Subject: [PATCH] fix: handled the case where the server runs InvokeAsync (#29) * fix: handled the case where the server runs InvokeAsync Refs: #28 * fix: send a response to the server if a response is expected but there are no callbacks Refs: #28 * build: upgrade asp-net example to version 9.0 Refs: #28 * test: update test for client result required from signalr server Refs: #28 * docs: update docs and examples Refs: #28 --- .idea/.gitignore | 8 + AspNetAuthExample/.dockerignore | 30 ++ AspNetAuthExample/.gitignore | 402 ++++++++++++++++++ .../.idea.AspNetAuthExample/.idea/.gitignore | 13 + .../.idea/encodings.xml | 4 + .../.idea/git_toolbox_blame.xml | 6 + .../.idea/indexLayout.xml | 8 + .../inspectionProfiles/Project_Default.xml | 12 + .../.idea.AspNetAuthExample/.idea/vcs.xml | 12 + AspNetAuthExample/AspNetAuthExample.csproj | 12 +- AspNetAuthExample/AspNetAuthExample.sln | 22 + .../Controller/AuthController.cs | 67 --- .../Controllers/AuthController.cs | 65 +++ AspNetAuthExample/Dockerfile | 25 +- AspNetAuthExample/Hub/WeatherHub.cs | 44 -- AspNetAuthExample/Hubs/WeatherHub.cs | 51 +++ AspNetAuthExample/Program.cs | 74 +++- .../Properties/launchSettings.json | 22 + AspNetAuthExample/Startup.cs | 80 ---- .../appsettings.Development.json | 8 + AspNetAuthExample/appsettings.json | 9 + README.md | 25 +- example.py | 9 + example_with_token.py | 9 + src/pysignalr/client.py | 46 +- src/pysignalr/messages.py | 16 + tests/test_pysignalr/test_pysignalr.py | 72 ++++ 27 files changed, 918 insertions(+), 233 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 AspNetAuthExample/.dockerignore create mode 100644 AspNetAuthExample/.gitignore create mode 100644 AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/.gitignore create mode 100644 AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/encodings.xml create mode 100644 AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/git_toolbox_blame.xml create mode 100644 AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/indexLayout.xml create mode 100644 AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/inspectionProfiles/Project_Default.xml create mode 100644 AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/vcs.xml create mode 100644 AspNetAuthExample/AspNetAuthExample.sln delete mode 100644 AspNetAuthExample/Controller/AuthController.cs create mode 100644 AspNetAuthExample/Controllers/AuthController.cs delete mode 100644 AspNetAuthExample/Hub/WeatherHub.cs create mode 100644 AspNetAuthExample/Hubs/WeatherHub.cs create mode 100644 AspNetAuthExample/Properties/launchSettings.json delete mode 100644 AspNetAuthExample/Startup.cs create mode 100644 AspNetAuthExample/appsettings.Development.json create mode 100644 AspNetAuthExample/appsettings.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/AspNetAuthExample/.dockerignore b/AspNetAuthExample/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/AspNetAuthExample/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/AspNetAuthExample/.gitignore b/AspNetAuthExample/.gitignore new file mode 100644 index 0000000..58a52d6 --- /dev/null +++ b/AspNetAuthExample/.gitignore @@ -0,0 +1,402 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +.qodo diff --git a/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/.gitignore b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/.gitignore new file mode 100644 index 0000000..3b87ea0 --- /dev/null +++ b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.AspNetAuthExample.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/encodings.xml b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/git_toolbox_blame.xml b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/git_toolbox_blame.xml new file mode 100644 index 0000000..7dc1249 --- /dev/null +++ b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/git_toolbox_blame.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/indexLayout.xml b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/inspectionProfiles/Project_Default.xml b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7e7ac73 --- /dev/null +++ b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/vcs.xml b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/vcs.xml new file mode 100644 index 0000000..efccd08 --- /dev/null +++ b/AspNetAuthExample/.idea/.idea.AspNetAuthExample/.idea/vcs.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/AspNetAuthExample/AspNetAuthExample.csproj b/AspNetAuthExample/AspNetAuthExample.csproj index 6c8a170..3e6e086 100644 --- a/AspNetAuthExample/AspNetAuthExample.csproj +++ b/AspNetAuthExample/AspNetAuthExample.csproj @@ -1,14 +1,16 @@ - net6.0 + net9.0 + enable + enable + Linux + . - - - - + + diff --git a/AspNetAuthExample/AspNetAuthExample.sln b/AspNetAuthExample/AspNetAuthExample.sln new file mode 100644 index 0000000..a3e0963 --- /dev/null +++ b/AspNetAuthExample/AspNetAuthExample.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35728.132 d17.12 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetAuthExample", "AspNetAuthExample.csproj", "{208859B8-8540-43F1-AE46-902336890B9B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {208859B8-8540-43F1-AE46-902336890B9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {208859B8-8540-43F1-AE46-902336890B9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {208859B8-8540-43F1-AE46-902336890B9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {208859B8-8540-43F1-AE46-902336890B9B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/AspNetAuthExample/Controller/AuthController.cs b/AspNetAuthExample/Controller/AuthController.cs deleted file mode 100644 index 546804e..0000000 --- a/AspNetAuthExample/Controller/AuthController.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; - -namespace AspNetAuthExample.Controllers -{ - // Define the route for the API controller - [Route("api/[controller]")] - [ApiController] - public class AuthController : ControllerBase - { - // Define the POST endpoint for login - [HttpPost("login")] - public IActionResult Login([FromBody] LoginModel login) - { - // Check if the provided username and password match the predefined values - if (login.Username == "test" && login.Password == "password") - { - // Generate a JWT token if credentials are correct - var token = GenerateToken(); - // Return the token in the response - return Ok(new { token }); - } - // Return Unauthorized status if credentials are incorrect - return Unauthorized(); - } - - // Method to generate a JWT token - private string GenerateToken() - { - // Define the security key using a secret key - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("yoursecretkeyheretoSignalRserver")); - // Define the signing credentials using HMAC-SHA256 algorithm - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); - - // Define the claims to be included in the token - var claims = new[] - { - new Claim(JwtRegisteredClaimNames.Sub, "testuser"), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; - - // Create the JWT token with specified claims and expiration time - var token = new JwtSecurityToken( - issuer: null, - audience: null, - claims: claims, - expires: DateTime.Now.AddMinutes(30), - signingCredentials: credentials); - - // Return the serialized token as a string - return new JwtSecurityTokenHandler().WriteToken(token); - } - } - - // Model to represent the login request payload - public class LoginModel - { - // Username property - public string Username { get; set; } - // Password property - public string Password { get; set; } - } -} diff --git a/AspNetAuthExample/Controllers/AuthController.cs b/AspNetAuthExample/Controllers/AuthController.cs new file mode 100644 index 0000000..bb02c6c --- /dev/null +++ b/AspNetAuthExample/Controllers/AuthController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace AspNetAuthExample.Controllers; + +// Define the route for the API controller +[Route("api/[controller]")] +[ApiController] +public class AuthController : ControllerBase +{ + // Define the POST endpoint for login + [HttpPost("login")] + public IActionResult Login([FromBody] LoginModel login) + { + // Check if the provided username and password match the predefined values + if (login.Username == "test" && login.Password == "password") + { + // Generate a JWT token if credentials are correct + var token = GenerateToken(); + // Return the token in the response + return Ok(new { token }); + } + // Return Unauthorized status if credentials are incorrect + return Unauthorized(); + } + + // Method to generate a JWT token + private string GenerateToken() + { + // Define the security key using a secret key + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("yoursecretkeyheretoSignalRserver")); + // Define the signing credentials using HMAC-SHA256 algorithm + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + // Define the claims to be included in the token + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, "testuser"), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + // Create the JWT token with specified claims and expiration time + var token = new JwtSecurityToken( + issuer: null, + audience: null, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: credentials); + + // Return the serialized token as a string + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + +// Model to represent the login request payload +public class LoginModel +{ + // Username property + public string Username { get; set; } + // Password property + public string Password { get; set; } +} diff --git a/AspNetAuthExample/Dockerfile b/AspNetAuthExample/Dockerfile index 4bef388..80d4687 100644 --- a/AspNetAuthExample/Dockerfile +++ b/AspNetAuthExample/Dockerfile @@ -1,21 +1,28 @@ -# Use the official ASP.NET Core runtime as a parent image -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +# See https://aka.ms/customizecontainer for information on how to customize the debug container and how Visual Studio uses this Dockerfile to build images for faster debugging. + +# This phase is used when running from Visual Studio in Quick mode (the default for debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID WORKDIR /app EXPOSE 80 -# Use the SDK image to build and publish the app -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build + +# This stage is used to compile the service project +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ["AspNetAuthExample.csproj", "./"] -RUN dotnet restore "AspNetAuthExample.csproj" +COPY ["AspNetAuthExample.csproj", "."] +RUN dotnet restore "./AspNetAuthExample.csproj" COPY . . WORKDIR "/src/." -RUN dotnet build "AspNetAuthExample.csproj" -c Release -o /app/build +RUN dotnet build "./AspNetAuthExample.csproj" -c $BUILD_CONFIGURATION -o /app/build +# This stage is used to publish the service project to be copied in the final stage FROM build AS publish -RUN dotnet publish "AspNetAuthExample.csproj" -c Release -o /app/publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./AspNetAuthExample.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false -# Final stage/image +# This phase is used in the production environment or when running from Visual Studio in normal mode (the default when not using the debug configuration) FROM base AS final WORKDIR /app COPY --from=publish /app/publish . diff --git a/AspNetAuthExample/Hub/WeatherHub.cs b/AspNetAuthExample/Hub/WeatherHub.cs deleted file mode 100644 index 75f3bae..0000000 --- a/AspNetAuthExample/Hub/WeatherHub.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.SignalR; -using System.Threading.Tasks; - -using AspNetAuthExample.Controllers; - -namespace AspNetAuthExample -{ - // Define a SignalR Hub for weather updates - public class WeatherHub : Hub - { - // Method to send a message to all connected clients - public async Task SendMessage(string user, string message) - { - await Clients.All.SendAsync("ReceiveMessage", user, message); - } - - // Method to send a message to a specific group - public async Task SendMessageToGroup(string groupName, string user, string message) - { - await Clients.Group(groupName).SendAsync("ReceiveMessage", user, message); - } - - // Method to add a client to a group - public async Task AddToGroup(string groupName) - { - await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - await Clients.Group(groupName).SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} has joined the group {groupName}."); - } - - // Method to remove a client from a group - public async Task RemoveFromGroup(string groupName) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - await Clients.Group(groupName).SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} has left the group {groupName}."); - } - - // Method to send the weather forecast to all clients - public async Task SendWeatherForecast(string forecast) - { - await Clients.All.SendAsync("ReceiveWeatherForecast", forecast); - } - } -} diff --git a/AspNetAuthExample/Hubs/WeatherHub.cs b/AspNetAuthExample/Hubs/WeatherHub.cs new file mode 100644 index 0000000..1c46edf --- /dev/null +++ b/AspNetAuthExample/Hubs/WeatherHub.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.SignalR; + +namespace AspNetAuthExample.Hubs; + +// Define a SignalR Hub for weather updates +public class WeatherHub : Hub +{ + // Method to send a message to all connected clients + public async Task SendMessage(string user, string message) + { + await Clients.All.SendAsync("ReceiveMessage", user, message); + } + + // Method to send a message to a specific group + public async Task SendMessageToGroup(string groupName, string user, string message) + { + await Clients.Group(groupName).SendAsync("ReceiveMessage", user, message); + } + + // Method to add a client to a group + public async Task AddToGroup(string groupName) + { + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + await Clients.Group(groupName).SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} has joined the group {groupName}."); + } + + // Method to remove a client from a group + public async Task RemoveFromGroup(string groupName) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + await Clients.Group(groupName).SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} has left the group {groupName}."); + } + + // Method to send the weather forecast to all clients + public async Task SendWeatherForecast(string forecast) + { + await Clients.All.SendAsync("ReceiveWeatherForecast", forecast); + } + + /// + /// Trigger client result by invokeAsync method + /// + /// + /// + public async Task TriggerResultRequired(string user, string message) + { + var response = await Clients.Client(Context.ConnectionId).InvokeAsync("ResultRequired", "Reply this message", CancellationToken.None); + if (response == "Reply message") + await Clients.Client(Context.ConnectionId).SendAsync("SuccessReceivedMessage", user, message); + } +} diff --git a/AspNetAuthExample/Program.cs b/AspNetAuthExample/Program.cs index 4cc56d5..54ec01d 100644 --- a/AspNetAuthExample/Program.cs +++ b/AspNetAuthExample/Program.cs @@ -1,24 +1,60 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using AspNetAuthExample.Hubs; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; -namespace AspNetAuthExample -{ - // Main entry point of the application - public class Program +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +builder.Services.AddSignalR(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => { - public static void Main(string[] args) + options.TokenValidationParameters = new TokenValidationParameters { - // Build and run the host - CreateHostBuilder(args).Build().Run(); - } - - // Method to create and configure the host builder - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey("yoursecretkeyheretoSignalRserver"u8.ToArray()) + }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && + path.StartsWithSegments("/weatherHub")) { - // Specify the startup class to be used by the web host - webBuilder.UseStartup(); - }); - } + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); } + +app.UseHttpsRedirection(); + +app.UseRouting(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); +app.MapHub("/weatherHub"); + +app.Run(); diff --git a/AspNetAuthExample/Properties/launchSettings.json b/AspNetAuthExample/Properties/launchSettings.json new file mode 100644 index 0000000..63d5b9f --- /dev/null +++ b/AspNetAuthExample/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5124" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTP_PORTS": "80" + }, + "publishAllPorts": true, + "useSSL": false + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} diff --git a/AspNetAuthExample/Startup.cs b/AspNetAuthExample/Startup.cs deleted file mode 100644 index 7a49667..0000000 --- a/AspNetAuthExample/Startup.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.IdentityModel.Tokens; -using System.Text; -using System.Threading.Tasks; - -namespace AspNetAuthExample -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // Method to configure the services for the application - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - services.AddSignalR(); - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = false, - ValidateAudience = false, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("yoursecretkeyheretoSignalRserver")) - }; - options.Events = new JwtBearerEvents - { - OnMessageReceived = context => - { - var accessToken = context.Request.Query["access_token"]; - - var path = context.HttpContext.Request.Path; - if (!string.IsNullOrEmpty(accessToken) && - path.StartsWithSegments("/weatherHub")) - { - context.Token = accessToken; - } - return Task.CompletedTask; - } - }; - }); - - services.AddAuthorization(); - } - - // Method to configure the HTTP request pipeline - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseHttpsRedirection(); - - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub("/weatherHub"); - }); - } - } -} diff --git a/AspNetAuthExample/appsettings.Development.json b/AspNetAuthExample/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/AspNetAuthExample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AspNetAuthExample/appsettings.json b/AspNetAuthExample/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/AspNetAuthExample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/README.md b/README.md index 0897afe..63b83eb 100644 --- a/README.md +++ b/README.md @@ -41,23 +41,27 @@ from typing import List from pysignalr.client import SignalRClient from pysignalr.messages import CompletionMessage - async def on_open() -> None: print('Connected to the server') - async def on_close() -> None: print('Disconnected from the server') - async def on_message(message: List[Dict[str, Any]]) -> None: print(f'Received message: {message}') +async def on_client_result(message: list[dict[str, Any]]) -> str: + """ + The server can request a result from a client. + This requires the server to use ISingleClientProxy.InvokeAsync and the client to return a result from its .On handler. + https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-9.0#client-results + """ + print(f'Received message: {message}') + return 'reply' async def on_error(message: CompletionMessage) -> None: print(f'Received error: {message.error}') - async def main() -> None: client = SignalRClient('https://api.tzkt.io/v1/ws') @@ -65,6 +69,7 @@ async def main() -> None: client.on_close(on_close) client.on_error(on_error) client.on('operations', on_message) + client.on('client_result', on_client_result) await asyncio.gather( client.run(), @@ -96,6 +101,15 @@ async def on_close() -> None: async def on_message(message: List[Dict[str, Any]]) -> None: print(f'Received message: {message}') + +async def on_client_result(message: list[dict[str, Any]]) -> str: + """ + The server can request a result from a client. + This requires the server to use ISingleClientProxy.InvokeAsync and the client to return a result from its .On handler. + https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-9.0#client-results + """ + print(f'Received message: {message}') + return 'reply' async def on_error(message: CompletionMessage) -> None: print(f'Received error: {message.error}') @@ -115,6 +129,7 @@ async def main() -> None: client.on_close(on_close) client.on_error(on_error) client.on('operations', on_message) + client.on('client_result', on_client_result) await asyncio.gather( client.run(), @@ -140,7 +155,7 @@ with suppress(KeyboardInterrupt, asyncio.CancelledError): - `on_open(callback: Callable[[], Awaitable[None]])`: Set the callback for connection open event. - `on_close(callback: Callable[[], Awaitable[None]])`: Set the callback for connection close event. - `on_error(callback: Callable[[CompletionMessage], Awaitable[None]])`: Set the callback for error events. -- `on(event: str, callback: Callable[[List[Dict[str, Any]]], Awaitable[None]])`: Set the callback for a specific event. +- `on(event: str, callback: Callable[[List[Dict[str, Any]]], Awaitable[Any | None]])`: Set the callback for a specific event. - `send(method: str, args: List[Any])`: Send a message to the server. ### `CompletionMessage` diff --git a/example.py b/example.py index cae370b..932cb35 100644 --- a/example.py +++ b/example.py @@ -22,6 +22,14 @@ async def on_close() -> None: async def on_message(message: list[dict[str, Any]]) -> None: print(f'Received message: {message}') +async def on_client_result(message: list[dict[str, Any]]) -> str: + """ + The server can request a result from a client. + This requires the server to use ISingleClientProxy.InvokeAsync and the client to return a result from its .On handler. + https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-9.0#client-results + """ + print(f'Received message: {message}') + return 'reply' async def on_error(message: CompletionMessage) -> None: print(f'Received error: {message.error}') @@ -34,6 +42,7 @@ async def main() -> None: client.on_close(on_close) client.on_error(on_error) client.on('operations', on_message) + client.on('client_result', on_client_result) await asyncio.gather( client.run(), diff --git a/example_with_token.py b/example_with_token.py index f9842fb..8f0c0fe 100644 --- a/example_with_token.py +++ b/example_with_token.py @@ -22,6 +22,14 @@ async def on_close() -> None: async def on_message(message: list[dict[str, Any]]) -> None: print(f'Received message: {message}') +async def on_client_result(message: list[dict[str, Any]]) -> str: + """ + The server can request a result from a client. + This requires the server to use ISingleClientProxy.InvokeAsync and the client to return a result from its .On handler. + https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-9.0#client-results + """ + print(f'Received message: {message}') + return 'reply' async def on_error(message: CompletionMessage) -> None: print(f'Received error: {message.error}') @@ -43,6 +51,7 @@ async def main() -> None: client.on_close(on_close) client.on_error(on_error) client.on('operations', on_message) + client.on('client_result', on_client_result) await asyncio.gather( client.run(), diff --git a/src/pysignalr/client.py b/src/pysignalr/client.py index 73aa589..88636c1 100644 --- a/src/pysignalr/client.py +++ b/src/pysignalr/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +import logging from collections import defaultdict from collections.abc import AsyncIterator from collections.abc import Awaitable @@ -38,10 +39,11 @@ EmptyCallback = Callable[[], Awaitable[None]] -AnyCallback = Callable[[Any], Awaitable[None]] +AnyCallback = Callable[[Any], Awaitable[Any | None]] MessageCallback = Callable[[Message], Awaitable[None]] CompletionMessageCallback = Callable[[CompletionMessage], Awaitable[None]] +_logger = logging.getLogger('pysignalr.client') class ClientStream: """ @@ -283,9 +285,45 @@ async def _on_invocation_message(self, message: InvocationMessage) -> None: Args: message (InvocationMessage): The invocation message. """ - for callback in self._message_handlers[message.target]: - if callback: - await callback(message.arguments) + expects_response = message.invocation_id is not None + callbacks = [callback for callback in self._message_handlers[message.target] if callback] + + if not callbacks: + # There are no callbacks for the message.target + _logger.warning(f"No client method with the name '{message.target}' found.") + if expects_response: + _logger.error(f"No result given for '{message.target}' method and invocation ID '{message.invocation_id}'.") + await self._transport.send( + CompletionMessage(invocation_id=message.invocation_id, error="Client didn't provide a result.") + ) + return None + + if expects_response and len(callbacks) > 1: + # There are multiple callbacks, so multiple results for the message.target + _logger.error(f"Multiple results provided for '{message.target}'. Sending error to server.") + await self._transport.send( + CompletionMessage(invocation_id=message.invocation_id, error='Client provided multiple results.') + ) + return None + + for callback in callbacks: + try: + res = await callback(message.arguments) + if res: + if expects_response: + await self._transport.send(CompletionMessage(invocation_id=message.invocation_id, result=res)) + else: + _logger.warning(f"Result given for '{message.target}' method but server is not expecting a result.") + elif expects_response: + _logger.error(f"No result given for '{message.target}' method and invocation ID '{message.invocation_id}'.") + await self._transport.send(CompletionMessage(invocation_id=message.invocation_id, + error="Client didn't provide a result.")) + except Exception as exc: + _logger.error(f"A callback for the method '{message.target}' threw error '{exc}'.") + if not expects_response: + raise exc + await self._transport.send(CompletionMessage(invocation_id=message.invocation_id, error=str(exc))) + async def _on_completion_message(self, message: CompletionMessage) -> None: """ diff --git a/src/pysignalr/messages.py b/src/pysignalr/messages.py index 477e96f..1b5e8af 100644 --- a/src/pysignalr/messages.py +++ b/src/pysignalr/messages.py @@ -181,6 +181,22 @@ class CompletionMessage(Message, type_=MessageType.completion): error: str | None = None headers: dict[str, Any] | None = None + def dump(self) -> dict[str, Any]: + data = super().dump() + + result = data.pop('result', None) + error = data.pop('error', None) + headers = data.pop('headers', None) + + if result is not None: + data['result'] = result + if error is not None: + data['error'] = error + if headers is not None: + data['headers'] = headers + + return data + @dataclass class InvocationMessage(Message, type_=MessageType.invocation): diff --git a/tests/test_pysignalr/test_pysignalr.py b/tests/test_pysignalr/test_pysignalr.py index a05ef70..888a417 100644 --- a/tests/test_pysignalr/test_pysignalr.py +++ b/tests/test_pysignalr/test_pysignalr.py @@ -245,3 +245,75 @@ async def _on_open() -> None: # Log detailed messages received for user, message in received_messages: logging.info('Detailed Log: Message from %s - %s', user, message) + + async def test_result_from_client(self, aspnet_server: str) -> None: + """ + Tests send result from client when SignalR server use InvokeAsync method. + """ + login_url = f'http://{aspnet_server}/api/auth/login' + logging.info('Attempting to log in at %s', login_url) + login_data = {'username': 'test', 'password': 'password'} + response = requests.post(login_url, json=login_data, timeout=10) + token = response.json().get('token') + if not token: + logging.error('Failed to obtain token from login response') + raise AssertionError('Failed to obtain token from login response') + logging.info('Obtained token: %s', token) + + url = f'http://{aspnet_server}/weatherHub' + logging.info('Testing reply when receive InvokeAsync message with token to %s', url) + + def token_factory() -> str: + return cast(str, token) + + client = SignalRClient( + url=url, + access_token_factory=token_factory, + headers={'mycustomheader': 'mycustomheadervalue'}, + ) + + received_messages = [] + async def on_result_require(arguments: Any) -> str: + argument = arguments[0] + logging.info('Message to reply received: %s', argument) + return "Reply message" + + async def on_message_received(arguments: Any) -> None: + user, message = arguments + logging.info('Server received the reply and now send a message from %s: %s', user, message) + received_messages.append((user, message)) + if len(received_messages) >= 1: + task.cancel() + + client.on('ResultRequired', on_result_require) + client.on('SuccessReceivedMessage', on_message_received) + + task = asyncio.create_task(client.run()) + + async def _on_open() -> None: + logging.info('Connection with token opened, sending message to trigger invoke async method') + await client.send('TriggerResultRequired', ['testuser', 'Hello, World!']) # type: ignore[list-item] + + client.on_open(_on_open) + + try: + with suppress(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=30) # Set a timeout for the task + except ServerError as e: + logging.error('Server error: %s', e) + raise + except asyncio.TimeoutError: + logging.error('Test timed out') + task.cancel() + await task + + # Verify if the message was received correctly + assert received_messages, 'No messages were received' + assert received_messages[0] == ( + 'testuser', + 'Hello, World!', + ), f'Unexpected message received: {received_messages[0]}' + + # Log detailed messages received + for user, message in received_messages: + logging.info('Detailed Log: Message from %s - %s', user, message)