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
132 changes: 132 additions & 0 deletions lib/src/solid/api/http_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/// A shared, connection-pooling HTTP client for all POD requests.
///
/// Copyright (C) 2026, Software Innovation Institute, ANU.
///
/// Licensed under the MIT License (the "License").
///
/// License: https://choosealicense.com/licenses/mit/.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
///
/// Authors: Tony Chen

library;

import 'dart:async';

import 'package:flutter/foundation.dart' show debugPrint;

import 'package:http/http.dart' as http;

/// The process-wide client used for every request to a POD server.
///
/// The top-level helpers in `package:http` (`http.get()`, `http.put()`, ...)
/// build a brand new [http.Client] for each call and close it again as soon as
/// the response arrives. On `dart:io` platforms that means a new `HttpClient`
/// with its own, immediately discarded, connection pool: every request pays a
/// fresh TCP handshake plus a full TLS handshake, so a single write to a POD
/// (which issues several requests) costs several extra round trips. The cost
/// scales with the network distance to the POD server and with how fast the
/// platform's TLS stack is, which is why the same operation can feel instant
/// against a nearby server and painfully slow against a distant one.
///
/// Sharing one client keeps the underlying connections alive (the `Connection:
/// keep-alive` header the API already sends finally means something), so only
/// the first request to a host pays for the handshakes.
///
/// On the web `http.Client()` is a `BrowserClient` and connection reuse is the
/// browser's business; sharing the instance is still correct and avoids
/// allocating a client per request.

http.Client get podHttpClient =>
_podHttpClient ??= _RetryIdleConnectionClient(http.Client());

http.Client? _podHttpClient;

/// Close and drop the shared client.
///
/// Call on logout so no authenticated connection is kept open. The next
/// request transparently creates a new client.

void closePodHttpClient() {
_podHttpClient?.close();
_podHttpClient = null;
}

/// Retries a request once when the connection dies before any response
/// arrives.
///
/// This is the cost of keeping connections alive: a server (or an intervening
/// proxy) may close an idle connection at the very moment the client picks it
/// up for the next request, which surfaces as a [http.ClientException] such as
/// "Connection closed before full header was received". Nothing was served, so
/// re-sending on a fresh connection is safe and invisible to the caller.
///
/// Only methods that can be repeated without changing the outcome are retried.
/// POST and PATCH are left alone: a POST that did reach the server would
/// create a second resource, and a retry of a partly applied PATCH is not
/// equivalent to the original.

class _RetryIdleConnectionClient extends http.BaseClient {
_RetryIdleConnectionClient(this._inner);

final http.Client _inner;

static const _retryableMethods = {'GET', 'HEAD', 'PUT', 'DELETE'};

@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// A request can only be sent once, so a retry needs a fresh copy. Only
// in-memory requests can be copied; a streamed body cannot be replayed.

final copy = _retryableMethods.contains(request.method.toUpperCase())
? _copyOf(request)
: null;

try {
return await _inner.send(request);
} on http.ClientException catch (e) {
if (copy == null) rethrow;

debugPrint(
'Retrying ${request.method} ${request.url} after connection error: '
'${e.message}',
);

return await _inner.send(copy);
}
}

/// A resendable duplicate of [request], or null if its body cannot be
/// replayed.

static http.BaseRequest? _copyOf(http.BaseRequest request) {
if (request is! http.Request) return null;

return http.Request(request.method, request.url)
..headers.addAll(request.headers)
..followRedirects = request.followRedirects
..maxRedirects = request.maxRedirects
..persistentConnection = request.persistentConnection
..bodyBytes = request.bodyBytes;
}

@override
void close() => _inner.close();
}
119 changes: 87 additions & 32 deletions lib/src/solid/api/rest_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ import 'dart:typed_data' show Uint8List;

import 'package:flutter/foundation.dart' show debugPrint;

import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:mime/mime.dart' as mime;
import 'package:rdf/rdf.dart';

import 'package:solidpod/src/solid/api/http_client.dart';
import 'package:solidpod/src/solid/constants/common.dart';
import 'package:solidpod/src/solid/utils/authdata_manager.dart';
import 'package:solidpod/src/solid/utils/exceptions.dart';
Expand Down Expand Up @@ -213,7 +213,7 @@ Future<void> createResource(
// Use PUT request for creating and replacing a file if it already exists

final put = (isFile && replaceIfExist) ? true : false;
final httpMethod = put ? http.put : http.post;
final httpMethod = put ? podHttpClient.put : podHttpClient.post;

// Get the name and parent container URL of the resource to be created for
// POST request
Expand Down Expand Up @@ -289,7 +289,7 @@ Future<void> deleteResource(
'DELETE',
);

final response = await http.delete(
final response = await podHttpClient.delete(
Uri.parse(resourceUrl),
headers: <String, String>{
'Accept': '*/*',
Expand Down Expand Up @@ -320,55 +320,100 @@ Future<void> deleteResource(

/// Asynchronously checks whether a given resource exists on the server.
///
/// This function makes an HTTP GET request to the specified resource URL to determine if the resource exists.
/// This function makes an HTTP request to the specified resource URL to determine if the resource exists.
/// It handles both files and directories (containers) by setting appropriate headers based on the [isFile].
///
/// Set [useHead] when only the existence of the resource matters. A GET
/// downloads the whole resource just to look at its status code, which is
/// wasteful on the write path where the body is discarded. HEAD returns the
/// same status with no body. Servers that do not implement HEAD for a
/// resource (405/501, or anything else unexpected) fall back to the GET, so
/// enabling it never changes the answer — only how much is transferred.

Future<ResourceStatus> checkResourceStatus(
String resUrl, {
bool isFile = true,
bool useHead = false,
}) async {
if (!isFile) {
assert(resUrl.endsWith('/'));
} else {
assert(!resUrl.endsWith('/'));
}

final headers = <String, String>{
'Content-Type': isFile
? ResourceContentType.any.value
: ResourceContentType.directory.value,
'Link': isFile ? fileTypeLink : dirTypeLink,
...noHttpCacheHeaders,
};

if (useHead) {
final (:accessToken, :dPopToken) =
await getTokensForResource(resUrl, 'HEAD');
final headResponse = await podHttpClient.head(
Uri.parse(resUrl),
headers: <String, String>{
...headers,
'Authorization': 'DPoP $accessToken',
'DPoP': dPopToken,
},
);

final status = _statusFromCode(headResponse.statusCode);
if (status != null) {
return status;
}

// Unexpected code (e.g. a server without HEAD support): fall through to
// the GET below rather than reporting ResourceStatus.unknown.

debugPrint(
'HEAD $resUrl returned ${headResponse.statusCode}, retrying with GET.',
);
}

final (:accessToken, :dPopToken) = await getTokensForResource(resUrl, 'GET');
final response = await http.get(
final response = await podHttpClient.get(
Uri.parse(resUrl),
headers: <String, String>{
'Content-Type': isFile
? ResourceContentType.any.value
: ResourceContentType.directory.value,
...headers,
'Authorization': 'DPoP $accessToken',
'Link': isFile ? fileTypeLink : dirTypeLink,
'DPoP': dPopToken,
...noHttpCacheHeaders,
},
);

if (response.statusCode == 200 || response.statusCode == 204) {
return ResourceStatus.exist;
} else if (response.statusCode == 403) {
return ResourceStatus.forbidden;
} else if (response.statusCode == 404) {
return ResourceStatus.notExist;
} else {
debugPrint(
'Failed to check resource status.\n'
'URL: $resUrl\n'
'ERR: ${response.body}',
);
return ResourceStatus.unknown;
final status = _statusFromCode(response.statusCode);
if (status != null) {
return status;
}

debugPrint(
'Failed to check resource status.\n'
'URL: $resUrl\n'
'ERR: ${response.body}',
);
return ResourceStatus.unknown;
}

/// Map an HTTP status code onto a [ResourceStatus], or null when the code
/// says nothing about whether the resource exists.

ResourceStatus? _statusFromCode(int code) => switch (code) {
200 || 204 => ResourceStatus.exist,
403 => ResourceStatus.forbidden,
404 => ResourceStatus.notExist,
_ => null,
};

/// Asynchronously checks whether a given webId exists.
///
/// This function makes an HTTP GET request to a public resource URL to determine if the webId exists.

Future<ResourceStatus> checkWebIdExists(String webIdUrl) async {
try {
final response = await http.get(
final response = await podHttpClient.get(
Uri.parse(webIdUrl),
headers: <String, String>{
'Content-Type': ResourceContentType.any.value,
Expand Down Expand Up @@ -436,7 +481,7 @@ Future<WebIdStatus> checkWebIdProfile(String webIdUrl) async {
// keeps the request URL and any debug logs honest.
final uri = Uri.parse(webIdUrl).removeFragment();

final response = await http.get(
final response = await podHttpClient.get(
uri,
headers: const <String, String>{
// Solid servers content-negotiate on `Accept`. List the common RDF
Expand Down Expand Up @@ -513,7 +558,7 @@ Future<void> updateFileByQuery(String fileUrl, String query) async {
fileUrl,
'PATCH',
);
final editResponse = await http.patch(
final editResponse = await podHttpClient.patch(
Uri.parse(fileUrl),
headers: <String, String>{
'Accept': '*/*',
Expand Down Expand Up @@ -553,7 +598,7 @@ Future<void> initialProfileUpdate(String profBody) async {
final (:accessToken, :dPopToken) = await getTokensForResource(profUrl, 'PUT');

// The PUT request will create the acl item in the server
final updateResponse = await http.put(
final updateResponse = await podHttpClient.put(
Uri.parse(profUrl),
headers: <String, String>{
'Accept': '*/*',
Expand All @@ -576,13 +621,17 @@ Future<void> initialProfileUpdate(String profBody) async {
/// If [resourceUrl] ends with '/', i.e., a container / directory,
/// This function returns the bytes of a turtle string representing
/// the list of resources in the container / directory.
///
/// Throws [ResourceNotExistException] on 404 and [AccessForbiddenException]
/// on 403 so that callers can tell the two apart without first issuing a
/// separate existence check (which would download the resource twice).
Future<Uint8List> getResource(String resourceUrl) async {
final (:accessToken, :dPopToken) = await getTokensForResource(
resourceUrl,
'GET',
);

final response = await http.get(
final response = await podHttpClient.get(
Uri.parse(resourceUrl),
headers: <String, String>{
'Accept': '*/*',
Expand All @@ -595,8 +644,14 @@ Future<Uint8List> getResource(String resourceUrl) async {

if (response.statusCode == 200) {
return response.bodyBytes;
} else if (response.statusCode == 404) {
throw ResourceNotExistException('$resourceUrl does not exist');
} else if (response.statusCode == 403) {
throw AccessForbiddenException('Access to $resourceUrl is not allowed');
} else {
throw Exception('Failed to get resource $resourceUrl');
throw Exception(
'Failed to get resource $resourceUrl (HTTP ${response.statusCode})',
);
}
}

Expand All @@ -611,7 +666,7 @@ Future<({List<String> subDirs, List<String> files})> getResourcesInContainer(

final (:accessToken, :dPopToken) = await getTokensForResource(url, 'GET');

final profResponse = await http.get(
final profResponse = await podHttpClient.get(
Uri.parse(url),
headers: <String, String>{
'Accept': '*/*',
Expand Down Expand Up @@ -686,7 +741,7 @@ Future<ResourceMetadata> getResourceMetadata(String resourceUrl) async {
'HEAD',
);

final response = await http.head(
final response = await podHttpClient.head(
Uri.parse(resourceUrl),
headers: <String, String>{
'Accept': '*/*',
Expand Down Expand Up @@ -742,7 +797,7 @@ Future<String> updateAclFileContent(
);

// http request to update the acl file on the server
final editResponse = await http.put(
final editResponse = await podHttpClient.put(
Uri.parse(resourceAclUrl),
headers: <String, String>{
'Accept': '*/*',
Expand Down
11 changes: 10 additions & 1 deletion lib/src/solid/read_pod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,16 @@ Future<String> readPod(
pathType: pathType,
);

final fileStatus = await checkResourceStatus(fileUrl);
// Check the resource is there and readable before fetching it, so that a
// missing or forbidden resource is reported as such rather than as whatever
// the fetch happens to fail with.
//
// The probe uses HEAD, not GET: it only needs the status code, and a GET
// downloaded the entire resource a second time purely to discard it.
// checkResourceStatus() falls back to GET on any server that does not
// answer HEAD, so the outcome is unchanged.

final fileStatus = await checkResourceStatus(fileUrl, useHead: true);

if (fileStatus != ResourceStatus.exist) {
switch (fileStatus) {
Expand Down
Loading
Loading