Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DatabaseExplorer/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using DatabaseExplorer.DataProviders.PostgreSql;
using DatabaseExplorer.DataProviders.Sqlite;
using DatabaseExplorer.DataProviders.SqlServer;
using DatabaseExplorer.DataProviders.Supabase;
using DatabaseExplorer.Services;
using DatabaseExplorer.ViewModels;
using DatabaseExplorer.Views;
Expand Down Expand Up @@ -45,6 +46,7 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec
services.AddSingleton<IDatabaseProvider, SqlServerDatabaseProvider>();
services.AddSingleton<IDatabaseProvider, PostgreSqlDatabaseProvider>();
services.AddSingleton<IDatabaseProvider, SqliteDatabaseProvider>();
services.AddSingleton<IDatabaseProvider, SupabaseDatabaseProvider>();
services.AddSingleton<IDatabaseProviderFactory, DatabaseProviderFactory>();

services.AddSingleton<IThemeService, ThemeService>();
Expand Down
3 changes: 2 additions & 1 deletion DatabaseExplorer/Core/Models/DatabaseProviderType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum DatabaseProviderType
{
SqlServer,
PostgreSql,
Sqlite
Sqlite,
Supabase
}
105 changes: 105 additions & 0 deletions DatabaseExplorer/DataProviders/Supabase/SupabaseConnectionInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
namespace DatabaseExplorer.DataProviders.Supabase;

public sealed record SupabaseConnectionInfo(string Url, string ApiKey)
{
public static bool TryParse(string connectionString, out SupabaseConnectionInfo? info, out string? error)
{
info = null;
error = null;

if (string.IsNullOrWhiteSpace(connectionString))
{
error = "Connection string cannot be empty.";
return false;
}

string? url = null;
string? apiKey = null;

foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var idx = segment.IndexOf('=');
if (idx <= 0)
{
continue;
}

var key = segment[..idx].Trim().ToLowerInvariant();
var value = segment[(idx + 1)..].Trim();

switch (key)
{
case "url":
case "project":
case "projecturl":
url = value;
break;
case "apikey":
case "key":
case "anonkey":
apiKey = value;
break;
}
}

if (string.IsNullOrWhiteSpace(url))
{
error = "Missing 'Url' (e.g. Url=https://your-project.supabase.co;).";
return false;
}

if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "https" && uri.Scheme != "http"))
{
error = "The Supabase Url is not a valid http(s) address.";
return false;
}

if (string.IsNullOrWhiteSpace(apiKey))
{
error = "Missing 'ApiKey' (your Supabase publishable or secret key).";
return false;
}

info = new SupabaseConnectionInfo(url.TrimEnd('/'), apiKey);
return true;
}

public static (string Url, string ApiKey) ParseLenient(string connectionString)
{
var url = string.Empty;
var apiKey = string.Empty;

if (string.IsNullOrWhiteSpace(connectionString))
{
return (url, apiKey);
}

foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var idx = segment.IndexOf('=');
if (idx <= 0)
{
continue;
}

var key = segment[..idx].Trim().ToLowerInvariant();
var value = segment[(idx + 1)..].Trim();

switch (key)
{
case "url":
case "project":
case "projecturl":
url = value;
break;
case "apikey":
case "key":
case "anonkey":
apiKey = value;
break;
}
}

return (url, apiKey);
}
}
148 changes: 148 additions & 0 deletions DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseConnection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using DatabaseExplorer.Core.Exceptions;
using DatabaseExplorer.Core.Interfaces;
using DatabaseExplorer.Core.Models;
using System.Data;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;

namespace DatabaseExplorer.DataProviders.Supabase;

public sealed class SupabaseDatabaseConnection : IDatabaseConnection
{
private readonly SupabaseConnectionInfo _info;
private HttpClient? _httpClient;
private ConnectionState _state = ConnectionState.Closed;
private bool _disposed;

internal JsonNode? OpenApiSpec { get; private set; }

internal HttpClient HttpClient =>
_httpClient ?? throw new InvalidOperationException("Not connected.");

public SupabaseDatabaseConnection(string connectionString)
{
if (!SupabaseConnectionInfo.TryParse(connectionString, out var info, out var error))
{
throw new DatabaseConnectionException(
error ?? "Invalid Supabase connection string.",
DatabaseConnectionFailureReason.InvalidConnectionString);
}

_info = info!;
QueryService = new SupabaseQueryService(this);
}

public DatabaseProviderType ProviderType => DatabaseProviderType.Supabase;

public ConnectionState State => _state;

public string? DatabaseName => _state == ConnectionState.Open ? "public" : null;

public string? ServerName => _state == ConnectionState.Open ? new Uri(_info.Url).Host : null;

public IDatabaseQueryService QueryService { get; }

public async Task OpenAsync(CancellationToken cancellationToken = default)
{
var client = new HttpClient
{
BaseAddress = new Uri(_info.Url + "/"),
Timeout = TimeSpan.FromSeconds(20)
};
client.DefaultRequestHeaders.Add("apikey", _info.ApiKey);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _info.ApiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

try
{
using var response = await client.GetAsync("rest/v1/", cancellationToken).ConfigureAwait(false);

if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
{
client.Dispose();
throw new DatabaseConnectionException(
"The API key was rejected. Check your Supabase publishable/secret key.",
DatabaseConnectionFailureReason.AuthenticationFailed);
}

if (!response.IsSuccessStatusCode)
{
client.Dispose();
throw new DatabaseConnectionException(
$"Supabase responded with HTTP {(int)response.StatusCode} {response.ReasonPhrase}.",
DatabaseConnectionFailureReason.Unknown);
}

var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
OpenApiSpec = JsonNode.Parse(body);

_httpClient = client;
_state = ConnectionState.Open;
}
catch (OperationCanceledException)
{
client.Dispose();
throw;
}
catch (DatabaseConnectionException)
{
throw;
}
catch (HttpRequestException ex)
{
client.Dispose();
throw new DatabaseConnectionException(
$"Could not reach '{_info.Url}': {ex.Message}",
DatabaseConnectionFailureReason.ServerNotFound,
ex);
}
catch (Exception ex)
{
client.Dispose();
throw new DatabaseConnectionException(
$"Could not connect to Supabase: {ex.Message}",
DatabaseConnectionFailureReason.Unknown,
ex);
}
}

public Task CloseAsync()
{
_httpClient?.Dispose();
_httpClient = null;
OpenApiSpec = null;
_state = ConnectionState.Closed;
return Task.CompletedTask;
}

public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
client.DefaultRequestHeaders.Add("apikey", _info.ApiKey);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _info.ApiKey);

using var response = await client.GetAsync($"{_info.Url}/rest/v1/", cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}

public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}

_disposed = true;
await CloseAsync().ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using DatabaseExplorer.Core.Interfaces;
using DatabaseExplorer.Core.Models;

namespace DatabaseExplorer.DataProviders.Supabase;

public sealed class SupabaseDatabaseProvider : IDatabaseProvider
{
public DatabaseProviderType ProviderType => DatabaseProviderType.Supabase;

public string DisplayName => "Supabase";

public string ConnectionStringPlaceholder =>
"Url=https://YOUR_PROJECT.supabase.co;ApiKey=YOUR_SECRET_KEY;";

public IDatabaseConnection CreateConnection(string connectionString) =>
new SupabaseDatabaseConnection(connectionString);

public string? ValidateConnectionString(string connectionString) =>
SupabaseConnectionInfo.TryParse(connectionString, out _, out var error) ? null : error;
}
Loading
Loading