diff --git a/ServiceNow.Api.Test/ReadOnlyOptionTests.cs b/ServiceNow.Api.Test/ReadOnlyOptionTests.cs
new file mode 100644
index 0000000..16df4e4
--- /dev/null
+++ b/ServiceNow.Api.Test/ReadOnlyOptionTests.cs
@@ -0,0 +1,107 @@
+using AwesomeAssertions;
+using Newtonsoft.Json.Linq;
+using System.Net;
+using Xunit;
+
+namespace ServiceNow.Api.Test;
+
+///
+/// Credential-free: a refused write throws before any request reaches the handler.
+///
+public class ReadOnlyOptionTests
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private sealed class RecordingHandler : HttpMessageHandler
+ {
+ public List Methods { get; } = [];
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Methods.Add(request.Method);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"result\":{\"sys_id\":\"abc\"}}")
+ });
+ }
+ }
+
+ private static ServiceNowClient CreateClient(RecordingHandler handler, bool isReadOnly)
+ => new(handler, new Options { IsReadOnly = isReadOnly });
+
+ [Fact]
+ public void IsReadOnly_DefaultsToFalse()
+ => new Options().IsReadOnly.Should().BeFalse();
+
+ [Fact]
+ public async Task Create_IsRefused_WhenIsReadOnly()
+ {
+ using var handler = new RecordingHandler();
+ using var client = CreateClient(handler, isReadOnly: true);
+
+ var act = () => client.CreateAsync("incident", new JObject { ["short_description"] = "x" }, Ct);
+
+ await act.Should().ThrowAsync();
+ handler.Methods.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Update_IsRefused_WhenIsReadOnly()
+ {
+ using var handler = new RecordingHandler();
+ using var client = CreateClient(handler, isReadOnly: true);
+
+ var act = () => client.UpdateAsync("incident", new JObject { ["sys_id"] = "abc" }, Ct);
+
+ await act.Should().ThrowAsync();
+ handler.Methods.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Patch_IsRefused_WhenIsReadOnly()
+ {
+ using var handler = new RecordingHandler();
+ using var client = CreateClient(handler, isReadOnly: true);
+
+ var act = () => client.PatchAsync("incident", new JObject { ["sys_id"] = "abc" }, Ct);
+
+ await act.Should().ThrowAsync();
+ handler.Methods.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Delete_IsRefused_WhenIsReadOnly()
+ {
+ using var handler = new RecordingHandler();
+ using var client = CreateClient(handler, isReadOnly: true);
+
+ var act = () => client.DeleteAsync("incident", "abc", Ct);
+
+ await act.Should().ThrowAsync();
+ handler.Methods.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Delete_IsAllowed_WhenNotReadOnly()
+ {
+ using var handler = new RecordingHandler();
+ using var client = CreateClient(handler, isReadOnly: false);
+
+ await client.DeleteAsync("incident", "abc", Ct);
+
+ handler.Methods.Should().ContainSingle().Which.Should().Be(HttpMethod.Delete);
+ }
+
+ [Fact]
+ public async Task Get_IsAllowed_WhenIsReadOnly()
+ {
+ using var handler = new RecordingHandler();
+ using var client = CreateClient(handler, isReadOnly: true);
+
+ var result = await client.GetByIdAsync("incident", "abc", Ct);
+
+ result.Should().NotBeNull();
+ handler.Methods.Should().ContainSingle().Which.Should().Be(HttpMethod.Get);
+ }
+}
diff --git a/ServiceNow.Api/Options.cs b/ServiceNow.Api/Options.cs
index 072d482..84f6f17 100644
--- a/ServiceNow.Api/Options.cs
+++ b/ServiceNow.Api/Options.cs
@@ -12,6 +12,11 @@ public class Options
///
public ILogger? Logger { get; set; }
+ ///
+ /// Whether the client refuses every request other than GET, HEAD and OPTIONS
+ ///
+ public bool IsReadOnly { get; set; }
+
///
/// The field that is used in the "GetAllByQueryInternalAsync" method in ServiceNowClient.
/// This can be set because it CAN happen that the table/view does NOT have this field,
diff --git a/ServiceNow.Api/ReadOnlyHandler.cs b/ServiceNow.Api/ReadOnlyHandler.cs
new file mode 100644
index 0000000..d776070
--- /dev/null
+++ b/ServiceNow.Api/ReadOnlyHandler.cs
@@ -0,0 +1,21 @@
+namespace ServiceNow.Api;
+
+///
+/// Refuses every request other than GET, HEAD or OPTIONS while is set.
+/// The option is read on every request.
+///
+internal sealed class ReadOnlyHandler(Options options, HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler)
+{
+ protected override 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 (IsReadOnly is true): {request.Method} {request.RequestUri?.AbsolutePath} is not allowed.");
+ }
+
+ return base.SendAsync(request, cancellationToken);
+ }
+}
diff --git a/ServiceNow.Api/ServiceNowClient.cs b/ServiceNow.Api/ServiceNowClient.cs
index 53b8c6f..1875e72 100644
--- a/ServiceNow.Api/ServiceNowClient.cs
+++ b/ServiceNow.Api/ServiceNowClient.cs
@@ -42,7 +42,7 @@ public ServiceNowClient(
? $"https://{account}.servicenowservices.com"
: $"https://{account}.service-now.com";
var httpClientHandler = new HttpClientHandler();
- _httpClient = new HttpClient(httpClientHandler)
+ _httpClient = new HttpClient(new ReadOnlyHandler(_options, httpClientHandler))
{
BaseAddress = new Uri(baseAddress),
DefaultRequestHeaders =
@@ -80,7 +80,7 @@ internal ServiceNowClient(
_logger = _options.Logger ?? new NullLogger();
AccountName = "test";
- _httpClient = new HttpClient(httpMessageHandler)
+ _httpClient = new HttpClient(new ReadOnlyHandler(_options, httpMessageHandler))
{
BaseAddress = new Uri("https://test.service-now.com"),
DefaultRequestHeaders =