diff --git a/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs b/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs
new file mode 100644
index 0000000..e4f86ad
--- /dev/null
+++ b/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs
@@ -0,0 +1,58 @@
+using OpenProject.Api.Data.Models.Create;
+
+namespace OpenProject.Api.Test.Tests;
+
+///
+/// needs no configuration, network or server.
+///
+public class ReadOnlyOptionTests
+{
+ private static bool IsRefused(Exception e)
+ => e is InvalidOperationException || e.InnerException is InvalidOperationException;
+
+ private static OpenProjectClient ClientFor(bool isReadOnly)
+ => new(new OpenProjectClientOptions
+ {
+ Uri = new Uri("http://127.0.0.1:1"),
+ ApiKey = "not-a-real-key",
+ IsReadOnly = isReadOnly
+ });
+
+ [Fact]
+ public void IsReadOnly_DefaultsToFalse()
+ => new OpenProjectClientOptions { Uri = new Uri("http://127.0.0.1:1"), ApiKey = "k" }
+ .IsReadOnly.Should().BeFalse();
+
+ [Fact]
+ public async Task ReadOnly_Create_ThrowsInvalidOperationBeforeSending()
+ {
+ using var client = ClientFor(isReadOnly: true);
+
+ var act = () => client.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None);
+
+ var thrown = await act.Should().ThrowAsync();
+ IsRefused(thrown.Which).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task NotReadOnly_Create_IsAttemptedAndFailsOnConnectionNotOnPolicy()
+ {
+ using var client = ClientFor(isReadOnly: false);
+
+ var act = () => client.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None);
+
+ var thrown = await act.Should().ThrowAsync();
+ IsRefused(thrown.Which).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task ReadOnly_Get_IsNotRefused()
+ {
+ using var client = ClientFor(isReadOnly: true);
+
+ var act = () => client.Projects.GetAllAsync(CancellationToken.None);
+
+ var thrown = await act.Should().ThrowAsync();
+ IsRefused(thrown.Which).Should().BeFalse();
+ }
+}
diff --git a/OpenProject.Api/AuthenticatedHttpClientHandler.cs b/OpenProject.Api/AuthenticatedHttpClientHandler.cs
index 51d6187..a87e5e7 100644
--- a/OpenProject.Api/AuthenticatedHttpClientHandler.cs
+++ b/OpenProject.Api/AuthenticatedHttpClientHandler.cs
@@ -34,6 +34,14 @@ protected override async Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
+ if (_options.IsReadOnly
+ && request.Method != HttpMethod.Get
+ && request.Method != HttpMethod.Head
+ && request.Method != HttpMethod.Options)
+ {
+ throw new InvalidOperationException($"The client is read-only; {request.Method} {request.RequestUri?.AbsolutePath} would change the server.");
+ }
+
// Set basic auth header
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"apikey:{_options.ApiKey}")));
diff --git a/OpenProject.Api/OpenProjectClientOptions.cs b/OpenProject.Api/OpenProjectClientOptions.cs
index f72a8cb..68d6f9e 100644
--- a/OpenProject.Api/OpenProjectClientOptions.cs
+++ b/OpenProject.Api/OpenProjectClientOptions.cs
@@ -25,6 +25,11 @@ public class OpenProjectClientOptions
///
public bool LogExceptionContent { get; init; }
+ ///
+ /// When true, the client refuses every request other than GET, HEAD and OPTIONS before sending it.
+ ///
+ public bool IsReadOnly { get; set; }
+
///
/// Validates that the required options are set.
///