Merge branch 'main' into versions/next-major

This commit is contained in:
Joachim Van Herwegen 2023-10-05 14:28:06 +02:00
commit c95198285c
58 changed files with 1061 additions and 215 deletions

View File

@ -25,6 +25,7 @@
"NotificationChannelType",
"PermissionMap",
"Promise",
"Readable",
"Readonly",
"RegExp",
"Server",
@ -32,6 +33,8 @@
"Shorthand",
"Template",
"TemplateEngine",
"Transform",
"TransformOptions",
"ValuePreferencesArg",
"VariableBindings",
"UnionHandler",

View File

@ -10,6 +10,7 @@ module.exports = {
ignorePatterns: [ '*.js' ],
globals: {
AsyncIterable: 'readonly',
BufferEncoding: 'readonly',
NodeJS: 'readonly',
RequestInit: 'readonly',
},
@ -73,11 +74,15 @@ module.exports = {
// Already checked by @typescript-eslint/no-unused-vars
'no-unused-vars': 'off',
'padding-line-between-statements': 'off',
// Forcing destructuring on existing variables causes clunky code
'prefer-destructuring': 'off',
'prefer-named-capture-group': 'off',
// Already generated by TypeScript
strict: 'off',
'tsdoc/syntax': 'error',
'unicorn/catch-error-name': 'off',
// Can cause some clunky situations if it forces us to assign to an existing variable
'unicorn/consistent-destructuring': 'off',
'unicorn/import-index': 'off',
'unicorn/import-style': 'off',
// The next 2 would prevent some functional programming paradigms

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ❓ Question
url: https://github.com/CommunitySolidServer/CommunitySolidServer/discussions/new/choose
about: A question or discussion about the server

View File

@ -1,13 +0,0 @@
---
name: "❓ Question"
about: A general question
title: ''
labels: ''
assignees: ''
---
#### Question
<!-- Questions are usually better suited for discussions, which can be found at https://github.com/CommunitySolidServer/CommunitySolidServer/discussions -->
<!-- In case you think this would better as an issue, please provide a clear and concisely formulated question.-->

View File

@ -42,7 +42,7 @@ jobs:
with:
node-version: 16.x
- name: Check out the project
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
with:
ref: ${{ inputs.branch || github.ref }}
- name: Install dependencies and run build scripts

View File

@ -21,11 +21,11 @@ jobs:
tags: ${{ steps.meta-main.outputs.tags || steps.meta-version.outputs.tags }}
steps:
- name: Checkout
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
- if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main')
name: Docker meta edge and version tag
id: meta-main
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
solidproject/community-server
@ -39,7 +39,7 @@ jobs:
- if: startsWith(github.ref, 'refs/heads/versions/')
name: Docker meta next
id: meta-version
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
solidproject/community-server
@ -55,18 +55,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and export to docker
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
load: true
@ -85,10 +85,10 @@ jobs:
done <<< "${{ needs.docker-meta.outputs.tags }}";
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm/v8
platforms: linux/amd64,linux/arm/v7,linux/arm/v8,linux/arm64/v8
tags: ${{ needs.docker-meta.outputs.tags }}
labels: ${{ needs.docker-meta.outputs.labels }}

View File

@ -21,7 +21,7 @@ jobs:
outputs:
major: ${{ steps.tagged_version.outputs.major || steps.current_version.outputs.major }}
steps:
- uses: actions/checkout@v3.5.3
- uses: actions/checkout@v4.1.0
- uses: actions/setup-node@v3
with:
node-version: '16.x'
@ -43,7 +43,7 @@ jobs:
runs-on: ubuntu-latest
needs: mkdocs-prep
steps:
- uses: actions/checkout@v3.5.3
- uses: actions/checkout@v4.1.0
- uses: actions/setup-python@v4
with:
python-version: 3.x
@ -63,7 +63,7 @@ jobs:
needs: [mkdocs-prep, mkdocs]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.3
- uses: actions/checkout@v4.1.0
- uses: actions/setup-node@v3
with:
node-version: '16.x'

View File

@ -7,7 +7,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.3
- uses: actions/checkout@v4.1.0
- uses: actions/setup-node@v3
with:
node-version: '16.x'
@ -38,7 +38,7 @@ jobs:
- name: Ensure line endings are consistent
run: git config --global core.autocrlf input
- name: Check out repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
- name: Install dependencies and run build scripts
run: npm ci
- name: Type-check tests
@ -81,7 +81,7 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Check out repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
- name: Install dependencies and run build scripts
run: npm ci
- name: Run integration tests
@ -105,7 +105,7 @@ jobs:
- name: Ensure line endings are consistent
run: git config --global core.autocrlf input
- name: Check out repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
- name: Install dependencies and run build scripts
run: npm ci
- name: Run integration tests
@ -127,7 +127,7 @@ jobs:
with:
node-version: '16.x'
- name: Check out repository
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.0
- name: Install dependencies and run build scripts
run: npm ci
- name: Run deploy tests

View File

@ -3,6 +3,42 @@
All notable changes to this project will be documented in this file.
## [6.1.0](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v6.0.2...v6.1.0) (2023-10-05)
### Features
* Track binary size of resources when possible ([71e5569](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/71e55690f3418be3d08e35d2cd3aeae5a0634654))
* Add support for range headers ([3e9adef](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/3e9adef4cf00d0776c0d371f835a31511db7427b))
### Fixes
* Prevent error when creating a root pod([da46bec](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/da46becf7a087118e7d682a193d00a3ca6c32eab))
* Remove URL encoding from base64 strings before decoding ([d31393f](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/d31393f4751dd3f023110ead4e47a01ac15da2af))
### Documentation
* Simplify README by pointing to our docs. ([d618f97](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/d618f9781af80b1697d5fe23f50e3f186954792b))
* Add starting guide. ([e424b84](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/e424b8488261bc8942d82a7fe2d92a94650e93b9))
* Add quick start to README. ([1fa6d24](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/1fa6d248a2e500c025794f4e3ed6cc504ed77f10))
## [6.0.2](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v6.0.1...v6.0.2) (2023-08-30)
### Fixes
* Have FixedContentTypeMapper ignore .meta ([9e682f5](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/9e682f5c4f8ecd222ae633137ca455b1b9c5ce16))
* Ignore invalid header parts ([9c2c5ed](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/9c2c5edaf514fe84594024710c03ab3b7b0fbed1))
* Do not show PUT in Allow header for existing containers ([6f6784a](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/6f6784a28873c1a8a71bc8a6a37b634677109f02))
* Store activity streams context locally ([a47cc8a](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/a47cc8a5eef4d0dd963f85d0ad0e4746ada48e19))
### Testing
* Clear test data folder before running tests ([6fc3f2c](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/6fc3f2cf4f23ffde40ba88305c4c67bf39b73e10))
* Enable file locker in notification tests ([f419f2f](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/f419f2f28d664fad6d6e16cf89a6ebbd7d0f0052))
### Chores
* Name HTTP handlers ([937c41f](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/937c41fd17d553ddfc0d8f140867c252ec113ccb))
## [6.0.1](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v6.0.0...v6.0.1) (2023-06-15)
### Fixes

141
README.md
View File

@ -23,156 +23,43 @@ the Community Solid Server is a great companion:
- 🧑🏽 **for people** who want to try out having their own Pod
- 👨🏿‍💻 **for developers** who want to create and test Solid apps
- 👨🏿‍💻 **for developers** who want to quickly create and test Solid apps
- 👩🏻‍🔬 **for researchers** who want to design new features for Solid
And, of course, for many others who like to experience Solid.
You can install the software locally or on your server
and get started with Solid immediately.
## ⚡ Running the Community Solid Server
## ⚡ Running the server
To run the server, you will need [Node.js](https://nodejs.org/en/).
We support versions 14.14 and up.
If you do not use Node.js,
you can run a [Docker](https://www.docker.com/) version instead.
### 💻 Installing and running locally
After installing Node.js,
install the latest server version
from the [npm package repository](https://www.npmjs.com/):
Use [Node.js](https://nodejs.org/en/) 14.14 or up and execute:
```shell
npm install -g @solid/community-server
npx @solid/community-server
```
To run the server with in-memory storage, use:
Now visit your brand new server at [http://localhost:3000/](http://localhost:3000/)!
To persist your pod's contents between restarts, use:
```shell
community-solid-server # add parameters if needed
npx @solid/community-server -c @css:config/file.json -f data/
```
To run the server with your current folder as storage, use:
Find more ways to run the server in the [documentation](https://communitysolidserver.github.io/CommunitySolidServer/6.x/usage/starting-server/).
```shell
community-solid-server -c @css:config/file.json
```
## 🔧 Configure your server
### 📃 Installing and running from source
If you rather prefer to run the latest source code version,
or if you want to try a specific [branch](https://www.npmjs.com/) of the code,
you can use:
```shell
git clone https://github.com/CommunitySolidServer/CommunitySolidServer.git
cd CommunitySolidServer
npm ci
npm start -- # add parameters if needed
```
### 📦 Running via Docker
Docker allows you to run the server without having Node.js installed. Images are built on each tagged version and hosted
on [Docker Hub](https://hub.docker.com/r/solidproject/community-server).
```shell
# Clone the repo to get access to the configs
git clone https://github.com/CommunitySolidServer/CommunitySolidServer.git
cd CommunitySolidServer
# Run the image, serving your `~/Solid` directory on `http://localhost:3000`
docker run --rm -v ~/Solid:/data -p 3000:3000 -it solidproject/community-server:latest
# Or use one of the built-in configurations
docker run --rm -p 3000:3000 -it solidproject/community-server -c config/default.json
# Or use your own configuration mapped to the right directory
docker run --rm -v ~/solid-config:/config -p 3000:3000 -it solidproject/community-server -c /config/my-config.json
# Or use environment variables to configure your css instance
docker run --rm -v ~/Solid:/data -p 3000:3000 -it -e CSS_CONFIG=config/file-no-setup.json -e CSS_LOGGING_LEVEL=debug solidproject/community-server
```
### 🗃️ Helm Chart
The official [Helm](https://helm.sh/) Chart for Kubernetes deployment is maintained at
[CommunitySolidServer/css-helm-chart](https://github.com/CommunitySolidServer/css-helm-chart) and published on
[ArtifactHUB](https://artifacthub.io/packages/helm/community-solid-server/community-solid-server).
There you will find complete installation instructions.
```shell
# Summary
helm repo add community-solid-server https://communitysolidserver.github.io/css-helm-chart/charts/
helm install my-css community-solid-server/community-solid-server
```
## 🔧 Configuring the server
The Community Solid Server is designed to be flexible
such that people can easily run different configurations.
This is useful for customizing the server with plugins,
testing applications in different setups,
or developing new parts for the server
without needing to change its base code.
### ⏱ Parameters
An easy way to customize the server is
by passing parameters to the server command.
These parameters give you direct access
to some commonly used settings:
| parameter name | default value | description |
|-------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| `--port, -p` | `3000` | The TCP port on which the server should listen. |
| `--baseUrl, -b` | `http://localhost:$PORT/` | The base URL used internally to generate URLs. Change this if your server does not run on `http://localhost:$PORT/`. |
| `--socket` | | The Unix Domain Socket on which the server should listen. `--baseUrl` must be set if this option is provided |
| `--loggingLevel, -l` | `info` | The detail level of logging; useful for debugging problems. Use `debug` for full information. |
| `--config, -c` | `@css:config/default.json` | The configuration(s) for the server. The default only stores data in memory; to persist to your filesystem, use `@css:config/file.json` |
| `--rootFilePath, -f` | `./` | Root folder where the server stores data, when using a file-based configuration. |
| `--sparqlEndpoint, -s` | | URL of the SPARQL endpoint, when using a quadstore-based configuration. |
| `--showStackTrace, -t` | false | Enables detailed logging on error output. |
| `--podConfigJson` | `./pod-config.json` | Path to the file that keeps track of dynamic Pod configurations. Only relevant when using `@css:config/dynamic.json`. |
| `--seededPodConfigJson` | | Path to the file that keeps track of seeded Pod configurations. |
| `--mainModulePath, -m` | | Path from where Components.js will start its lookup when initializing configurations. |
| `--workers, -w` | `1` | Run in multithreaded mode using workers. Special values are `-1` (scale to `num_cores-1`), `0` (scale to `num_cores`) and 1 (singlethreaded). |
### 🔀 Multithreading
The Community Solid Server can be started in multithreaded mode with any config. The config must only contain components
that are threadsafe though. If a non-threadsafe component is used in multithreaded mode, the server will describe with
an error which class is the culprit.
```shell
# Running multithreaded with autoscaling to number of logical cores minus 1
npm start -- -c config/file.json -w -1
```
### 🖥️ Environment variables
Parameters can also be passed through environment variables.
They are prefixed with `CSS_` and converted from `camelCase` to `CAMEL_CASE`
> eg. `--showStackTrace` => `CSS_SHOW_STACK_TRACE`
**Note: command-line arguments will always override environment variables!**
### 🧶 Custom configurations
More substantial changes to server behavior can be achieved
by writing new configuration files in JSON-LD.
Substantial changes to server behavior can be achieved via JSON configuration files.
The Community Solid Server uses [Components.js](https://componentsjs.readthedocs.io/en/latest/)
to specify how modules and components need to be wired together at runtime.
Examples and guidance on configurations
Recipes for configuring the server can be found at [CommunitySolidServer/recipes](https://github.com/CommunitySolidServer/recipes).
Examples and guidance on custom configurations
are available in the [`config` folder](https://github.com/CommunitySolidServer/CommunitySolidServer/tree/main/config),
and the [configurations tutorial](https://github.com/CommunitySolidServer/tutorials/blob/main/custom-configurations.md).
There is also a [configuration generator](https://communitysolidserver.github.io/configuration-generator/).
Recipes for configuring the server can be found at [CommunitySolidServer/recipes](https://github.com/CommunitySolidServer/recipes).
## 👩🏽‍💻 Developing server code
The server allows writing and plugging in custom modules

View File

@ -41,6 +41,12 @@ These changes are relevant if you wrote custom modules for the server that depen
and its functionality split up over `Base64EncodingStorage` and `ContainerPathStorage`.
`HashEncodingPathStorage` has similarly been replaced by introducing `HashEncodingStorage`.
## v6.1.0
### New features
- Added support for HTTP Range headers.
## v6.0.0
### New features

View File

@ -21,6 +21,7 @@
"Accept-Post",
"Accept-Put",
"Allow",
"Content-Range",
"ETag",
"Last-Modified",
"Link",

View File

@ -3,7 +3,11 @@
"@graph": [
{
"@id": "urn:solid-server:default:PreferenceParser",
"@type": "AcceptPreferenceParser"
"@type": "UnionPreferenceParser",
"parsers": [
{ "@type": "AcceptPreferenceParser" },
{ "@type": "RangePreferenceParser" }
]
}
]
}

View File

@ -7,6 +7,7 @@
"css:config/ldp/metadata-writer/writers/link-rel-metadata.json",
"css:config/ldp/metadata-writer/writers/mapped.json",
"css:config/ldp/metadata-writer/writers/modified.json",
"css:config/ldp/metadata-writer/writers/range.json",
"css:config/ldp/metadata-writer/writers/storage-description.json",
"css:config/ldp/metadata-writer/writers/www-auth.json"
],
@ -22,6 +23,7 @@
{ "@id": "urn:solid-server:default:MetadataWriter_LinkRelMetadata" },
{ "@id": "urn:solid-server:default:MetadataWriter_Mapped" },
{ "@id": "urn:solid-server:default:MetadataWriter_Modified" },
{ "@id": "urn:solid-server:default:MetadataWriter_Range" },
{ "@id": "urn:solid-server:default:MetadataWriter_StorageDescription" },
{ "@id": "urn:solid-server:default:MetadataWriter_WwwAuth" }
]

View File

@ -0,0 +1,10 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Adds the Content-Range header if necessary.",
"@id": "urn:solid-server:default:MetadataWriter_Range",
"@type": "RangeMetadataWriter"
}
]
}

View File

@ -16,6 +16,12 @@
"comment": "Sets up a stack of utility stores used by most instances.",
"@id": "urn:solid-server:default:ResourceStore",
"@type": "MonitoringStore",
"source": { "@id": "urn:solid-server:default:ResourceStore_BinarySlice" }
},
{
"comment": "Slices part of binary streams based on the range preferences.",
"@id": "urn:solid-server:default:ResourceStore_BinarySlice",
"@type": "BinarySliceResourceStore",
"source": { "@id": "urn:solid-server:default:ResourceStore_Index" }
},
{

View File

@ -29,6 +29,7 @@ the [changelog](https://github.com/CommunitySolidServer/CommunitySolidServer/blo
## Using the server
* [Quickly starting the server](usage/starting-server.md)
* [Basic example HTTP requests](usage/example-requests.md)
* [Editing the metadata of a resource](usage/metadata.md)
* [How to use the Identity Provider](usage/identity-provider.md)

View File

@ -0,0 +1,121 @@
# Starting the Community Solid Server
## Quickly spinning up a server
Use [Node.js](https://nodejs.org/en/) 14.14 or up and execute:
```shell
npx @solid/community-server
```
Now visit your brand new server at [http://localhost:3000/](http://localhost:3000/)!
To persist your pod's contents between restarts, use:
```shell
npx @solid/community-server -c @css:config/file.json -f data/
```
## Local installation
Install the npm package globally with:
```shell
npm install -g @solid/community-server
```
To run the server with in-memory storage, use:
```shell
community-solid-server # add parameters if needed
```
To run the server with your current folder as storage, use:
```shell
community-solid-server -c @css:config/file.json -f data/
```
## Configuring the server
The Community Solid Server is designed to be flexible
such that people can easily run different configurations.
This is useful for customizing the server with plugins,
testing applications in different setups,
or developing new parts for the server
without needing to change its base code.
An easy way to customize the server is
by passing parameters to the server command.
These parameters give you direct access
to some commonly used settings:
| parameter name | default value | description |
|-------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| `--port, -p` | `3000` | The TCP port on which the server should listen. |
| `--baseUrl, -b` | `http://localhost:$PORT/` | The base URL used internally to generate URLs. Change this if your server does not run on `http://localhost:$PORT/`. |
| `--socket` | | The Unix Domain Socket on which the server should listen. `--baseUrl` must be set if this option is provided |
| `--loggingLevel, -l` | `info` | The detail level of logging; useful for debugging problems. Use `debug` for full information. |
| `--config, -c` | `@css:config/default.json` | The configuration(s) for the server. The default only stores data in memory; to persist to your filesystem, use `@css:config/file.json` |
| `--rootFilePath, -f` | `./` | Root folder where the server stores data, when using a file-based configuration. |
| `--sparqlEndpoint, -s` | | URL of the SPARQL endpoint, when using a quadstore-based configuration. |
| `--showStackTrace, -t` | false | Enables detailed logging on error output. |
| `--podConfigJson` | `./pod-config.json` | Path to the file that keeps track of dynamic Pod configurations. Only relevant when using `@css:config/dynamic.json`. |
| `--seededPodConfigJson` | | Path to the file that keeps track of seeded Pod configurations. |
| `--mainModulePath, -m` | | Path from where Components.js will start its lookup when initializing configurations. |
| `--workers, -w` | `1` | Run in multithreaded mode using workers. Special values are `-1` (scale to `num_cores-1`), `0` (scale to `num_cores`) and 1 (singlethreaded). |
Parameters can also be passed through environment variables.
They are prefixed with `CSS_` and converted from `camelCase` to `CAMEL_CASE`
> eg. `--showStackTrace` => `CSS_SHOW_STACK_TRACE`
Command-line arguments will always override environment variables.
## Alternative ways to run the server
### From source
If you rather prefer to run the latest source code version,
or if you want to try a specific [branch](https://www.npmjs.com/) of the code,
you can use:
```shell
git clone https://github.com/CommunitySolidServer/CommunitySolidServer.git
cd CommunitySolidServer
npm ci
npm start -- # add parameters if needed
```
### Via Docker
Docker allows you to run the server without having Node.js installed. Images are built on each tagged version and hosted
on [Docker Hub](https://hub.docker.com/r/solidproject/community-server).
```shell
# Clone the repo to get access to the configs
git clone https://github.com/CommunitySolidServer/CommunitySolidServer.git
cd CommunitySolidServer
# Run the image, serving your `~/Solid` directory on `http://localhost:3000`
docker run --rm -v ~/Solid:/data -p 3000:3000 -it solidproject/community-server:latest
# Or use one of the built-in configurations
docker run --rm -p 3000:3000 -it solidproject/community-server -c config/default.json
# Or use your own configuration mapped to the right directory
docker run --rm -v ~/solid-config:/config -p 3000:3000 -it solidproject/community-server -c /config/my-config.json
# Or use environment variables to configure your css instance
docker run --rm -v ~/Solid:/data -p 3000:3000 -it -e CSS_CONFIG=config/file-no-setup.json -e CSS_LOGGING_LEVEL=debug solidproject/community-server
```
### Using a Helm Chart
The official [Helm](https://helm.sh/) Chart for Kubernetes deployment is maintained at
[CommunitySolidServer/css-helm-chart](https://github.com/CommunitySolidServer/css-helm-chart) and published on
[ArtifactHUB](https://artifacthub.io/packages/helm/community-solid-server/community-solid-server).
There you will find complete installation instructions.
```shell
# Summary
helm repo add community-solid-server https://communitysolidserver.github.io/css-helm-chart/charts/
helm install my-css community-solid-server/community-solid-server
```

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@solid/community-server",
"version": "6.0.1",
"version": "6.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@solid/community-server",
"version": "6.0.1",
"version": "6.1.0",
"license": "MIT",
"dependencies": {
"@comunica/context-entries": "^2.6.8",

View File

@ -1,6 +1,6 @@
{
"name": "@solid/community-server",
"version": "6.0.1",
"version": "6.1.0",
"description": "Community Solid Server: an open and modular implementation of the Solid specifications",
"keywords": [
"solid",

View File

@ -11,7 +11,7 @@ import type { RepresentationPreferences } from '../../representation/Representat
import { PreferenceParser } from './PreferenceParser';
const parsers: {
name: keyof RepresentationPreferences;
name: Exclude<keyof RepresentationPreferences, 'range'>;
header: string;
parse: (value: string) => AcceptHeader[];
}[] = [

View File

@ -0,0 +1,48 @@
import type { HttpRequest } from '../../../server/HttpRequest';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
import { PreferenceParser } from './PreferenceParser';
/**
* Parses the range header into range preferences.
* If the range corresponds to a suffix-length range, it will be stored in `start` as a negative value.
*/
export class RangePreferenceParser extends PreferenceParser {
public async handle({ request: { headers: { range }}}: { request: HttpRequest }): Promise<RepresentationPreferences> {
if (!range) {
return {};
}
const [ unit, rangeTail ] = range.split('=').map((entry): string => entry.trim());
if (unit.length === 0) {
throw new BadRequestHttpError(`Missing unit value from range header ${range}`);
}
if (!rangeTail) {
throw new BadRequestHttpError(`Invalid range header format ${range}`);
}
const ranges = rangeTail.split(',').map((entry): string => entry.trim());
const parts: { start: number; end?: number }[] = [];
for (const rangeEntry of ranges) {
const [ start, end ] = rangeEntry.split('-').map((entry): string => entry.trim());
// This can actually be undefined if the split results in less than 2 elements
if (typeof end !== 'string') {
throw new BadRequestHttpError(`Invalid range header format ${range}`);
}
if (start.length === 0) {
if (end.length === 0) {
throw new BadRequestHttpError(`Invalid range header format ${range}`);
}
parts.push({ start: -Number.parseInt(end, 10) });
} else {
const part: typeof parts[number] = { start: Number.parseInt(start, 10) };
if (end.length > 0) {
part.end = Number.parseInt(end, 10);
}
parts.push(part);
}
}
return { range: { unit, parts }};
}
}

View File

@ -0,0 +1,32 @@
import { InternalServerError } from '../../../util/errors/InternalServerError';
import { UnionHandler } from '../../../util/handlers/UnionHandler';
import type { RepresentationPreferences } from '../../representation/RepresentationPreferences';
import type { PreferenceParser } from './PreferenceParser';
/**
* Combines the results of multiple {@link PreferenceParser}s.
* Will throw an error if multiple parsers return a range as these can't logically be combined.
*/
export class UnionPreferenceParser extends UnionHandler<PreferenceParser> {
public constructor(parsers: PreferenceParser[]) {
super(parsers, false, false);
}
protected async combine(results: RepresentationPreferences[]): Promise<RepresentationPreferences> {
const rangeCount = results.filter((result): boolean => Boolean(result.range)).length;
if (rangeCount > 1) {
throw new InternalServerError('Found multiple range values. This implies a misconfiguration.');
}
return results.reduce<RepresentationPreferences>((acc, val): RepresentationPreferences => {
for (const key of Object.keys(val) as (keyof RepresentationPreferences)[]) {
if (key === 'range') {
acc[key] = val[key];
} else {
acc[key] = { ...acc[key], ...val[key] };
}
}
return acc;
}, {});
}
}

View File

@ -0,0 +1,50 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpResponse } from '../../../server/HttpResponse';
import { addHeader } from '../../../util/HeaderUtil';
import { termToInt } from '../../../util/QuadUtil';
import { POSIX, SOLID_HTTP } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataWriter } from './MetadataWriter';
/**
* Generates the necessary `content-range` header if there is range metadata.
* If the start or end is unknown, a `*` will be used instead.
* According to the RFC, this is incorrect,
* but is all we can do as long as we don't know the full length of the representation in advance.
* For the same reason, the total length of the representation will always be `*`.
*
* This class also adds the content-length header.
* This will contain either the full size for standard requests,
* or the size of the slice for range requests.
*/
export class RangeMetadataWriter extends MetadataWriter {
protected readonly logger = getLoggerFor(this);
public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
const size = termToInt(input.metadata.get(POSIX.terms.size));
const unit = input.metadata.get(SOLID_HTTP.terms.unit)?.value;
if (!unit) {
if (typeof size === 'number') {
addHeader(input.response, 'Content-Length', `${size}`);
}
return;
}
let start = termToInt(input.metadata.get(SOLID_HTTP.terms.start));
if (typeof start === 'number' && start < 0 && typeof size === 'number') {
start = size + start;
}
let end = termToInt(input.metadata.get(SOLID_HTTP.terms.end));
if (typeof end !== 'number' && typeof size === 'number') {
end = size - 1;
}
const rangeHeader = `${unit} ${start ?? '*'}-${end ?? '*'}/${size ?? '*'}`;
addHeader(input.response, 'Content-Range', rangeHeader);
if (typeof start === 'number' && typeof end === 'number') {
addHeader(input.response, 'Content-Length', `${end - start + 1}`);
} else {
this.logger.warn(`Generating invalid content-range header due to missing size information: ${rangeHeader}`);
}
}
}

View File

@ -1,10 +1,12 @@
import type { Readable } from 'stream';
import type { Guarded } from '../../../util/GuardedStream';
import { SOLID_HTTP } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { ResponseDescription } from './ResponseDescription';
/**
* Corresponds to a 200 response, containing relevant metadata and potentially data.
* Corresponds to a 200 or 206 response, containing relevant metadata and potentially data.
* A 206 will be returned if range metadata is found in the metadata object.
*/
export class OkResponseDescription extends ResponseDescription {
/**
@ -12,6 +14,6 @@ export class OkResponseDescription extends ResponseDescription {
* @param data - Potential data. @ignored
*/
public constructor(metadata: RepresentationMetadata, data?: Guarded<Readable>) {
super(200, metadata, data);
super(metadata.has(SOLID_HTTP.terms.unit) ? 206 : 200, metadata, data);
}
}

View File

@ -31,4 +31,6 @@ export interface RepresentationPreferences {
datetime?: ValuePreferences;
encoding?: ValuePreferences;
language?: ValuePreferences;
// `start` can be negative and implies the last X of a stream
range?: { unit: string; parts: { start: number; end?: number }[] };
}

View File

@ -77,6 +77,8 @@ export * from './http/input/metadata/SlugParser';
// HTTP/Input/Preferences
export * from './http/input/preferences/AcceptPreferenceParser';
export * from './http/input/preferences/PreferenceParser';
export * from './http/input/preferences/RangePreferenceParser';
export * from './http/input/preferences/UnionPreferenceParser';
// HTTP/Input
export * from './http/input/BasicRequestParser';
@ -106,6 +108,7 @@ export * from './http/output/metadata/LinkRelMetadataWriter';
export * from './http/output/metadata/MappedMetadataWriter';
export * from './http/output/metadata/MetadataWriter';
export * from './http/output/metadata/ModifiedMetadataWriter';
export * from './http/output/metadata/RangeMetadataWriter';
export * from './http/output/metadata/StorageDescriptionAdvertiser';
export * from './http/output/metadata/WacAllowMetadataWriter';
export * from './http/output/metadata/WwwAuthMetadataWriter';
@ -452,6 +455,7 @@ export * from './storage/validators/QuotaValidator';
// Storage
export * from './storage/AtomicResourceStore';
export * from './storage/BaseResourceStore';
export * from './storage/BinarySliceResourceStore';
export * from './storage/CachedResourceSet';
export * from './storage/DataAccessorBasedStore';
export * from './storage/IndexRepresentationStore';
@ -480,6 +484,7 @@ export * from './util/errors/NotFoundHttpError';
export * from './util/errors/NotImplementedHttpError';
export * from './util/errors/OAuthHttpError';
export * from './util/errors/PreconditionFailedHttpError';
export * from './util/errors/RangeNotSatisfiedHttpError';
export * from './util/errors/RedirectHttpError';
export * from './util/errors/SystemError';
export * from './util/errors/UnauthorizedHttpError';
@ -550,6 +555,7 @@ export * from './util/PromiseUtil';
export * from './util/QuadUtil';
export * from './util/RecordObject';
export * from './util/ResourceUtil';
export * from './util/SliceStream';
export * from './util/StreamUtil';
export * from './util/StringUtil';
export * from './util/TermUtil';

View File

@ -0,0 +1,68 @@
import type { Representation } from '../http/representation/Representation';
import type { RepresentationPreferences } from '../http/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../logging/LogUtil';
import { InternalServerError } from '../util/errors/InternalServerError';
import { RangeNotSatisfiedHttpError } from '../util/errors/RangeNotSatisfiedHttpError';
import { guardStream } from '../util/GuardedStream';
import { termToInt } from '../util/QuadUtil';
import { SliceStream } from '../util/SliceStream';
import { toLiteral } from '../util/TermUtil';
import { POSIX, SOLID_HTTP, XSD } from '../util/Vocabularies';
import type { Conditions } from './conditions/Conditions';
import { PassthroughStore } from './PassthroughStore';
import type { ResourceStore } from './ResourceStore';
/**
* Resource store that slices the data stream if there are range preferences.
* Only works for `bytes` range preferences on binary data streams.
* Does not support multipart range requests.
*
* If the slice happens, unit/start/end values will be written to the metadata to indicate such.
* The values are dependent on the preferences we got as an input,
* as we don't know the actual size of the data stream.
*/
export class BinarySliceResourceStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
protected readonly logger = getLoggerFor(this);
public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
conditions?: Conditions): Promise<Representation> {
const result = await this.source.getRepresentation(identifier, preferences, conditions);
if (!preferences.range || preferences.range.unit !== 'bytes' || preferences.range.parts.length === 0) {
return result;
}
if (result.metadata.has(SOLID_HTTP.unit)) {
this.logger.debug('Not slicing stream that has already been sliced.');
return result;
}
if (!result.binary) {
throw new InternalServerError('Trying to slice a non-binary stream.');
}
if (preferences.range.parts.length > 1) {
throw new RangeNotSatisfiedHttpError('Multipart range requests are not supported.');
}
const [{ start, end }] = preferences.range.parts;
result.metadata.set(SOLID_HTTP.terms.unit, preferences.range.unit);
result.metadata.set(SOLID_HTTP.terms.start, toLiteral(start, XSD.terms.integer));
if (typeof end === 'number') {
result.metadata.set(SOLID_HTTP.terms.end, toLiteral(end, XSD.terms.integer));
}
try {
const size = termToInt(result.metadata.get(POSIX.terms.size));
// The reason we don't determine the object mode based on the object mode of the parent stream
// is that `guardedStreamFrom` does not create object streams when inputting streams/buffers.
// Something to potentially update in the future.
result.data = guardStream(new SliceStream(result.data, { start, end, size, objectMode: false }));
} catch (error: unknown) {
// Creating the slice stream can throw an error if some of the parameters are unacceptable.
// Need to make sure the stream is closed in that case.
result.data.destroy();
throw error;
}
return result;
}
}

View File

@ -32,6 +32,9 @@ export interface DataAccessor {
/**
* Returns the metadata corresponding to the identifier.
* If possible, it is suggested to add a `posix:size` triple to the metadata indicating the binary size.
* This is necessary for range requests.
*
* @param identifier - Identifier for which the metadata is requested.
*/
getMetadata: (identifier: ResourceIdentifier) => Promise<RepresentationMetadata>;

View File

@ -8,6 +8,8 @@ import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
import type { Guarded } from '../../util/GuardedStream';
import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy';
import { guardedStreamFrom } from '../../util/StreamUtil';
import { POSIX } from '../../util/Vocabularies';
import { isInternalContentType } from '../conversion/ConversionUtil';
import type { DataAccessor } from './DataAccessor';
interface DataEntry {
@ -59,9 +61,17 @@ export class InMemoryDataAccessor implements DataAccessor, SingleThreaded {
public async writeDocument(identifier: ResourceIdentifier, data: Guarded<Readable>, metadata: RepresentationMetadata):
Promise<void> {
const parent = this.getParentEntry(identifier);
// Drain original stream and create copy
const dataArray = await arrayifyStream(data);
// Only add the size for binary streams, which are all streams that do not have an internal type.
if (metadata.contentType && !isInternalContentType(metadata.contentType)) {
const size = dataArray.reduce<number>((total, chunk: Buffer): number => total + chunk.length, 0);
metadata.set(POSIX.terms.size, `${size}`);
}
parent.entries[identifier.path] = {
// Drain original stream and create copy
data: await arrayifyStream(data),
data: dataArray,
metadata,
};
}

View File

@ -5,6 +5,7 @@ import type { ValuePreferences } from '../../http/representation/RepresentationP
import { getLoggerFor } from '../../logging/LogUtil';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { POSIX } from '../../util/Vocabularies';
import { cleanPreferences, getBestPreference, getTypeWeight, preferencesToString } from './ConversionUtil';
import type { RepresentationConverterArgs } from './RepresentationConverter';
import { RepresentationConverter } from './RepresentationConverter';
@ -100,6 +101,13 @@ export class ChainedConverter extends RepresentationConverter {
args.preferences = { type: { [outTypes[i]]: 1 }};
args.representation = await match.converters[i].handle(args);
}
// For now, we assume any kind of conversion invalidates the stored byte length.
// In the future, we could let converters handle this individually, as some might know the size of the result.
if (match.converters.length > 0) {
args.representation.metadata.removeAll(POSIX.terms.size);
}
return args.representation;
}

View File

@ -15,6 +15,14 @@ export class Base64EncodingStorage<T> extends PassthroughKeyValueStorage<T> {
}
protected toOriginalKey(key: string): string {
return Buffer.from(key, 'base64').toString('utf-8');
// While the main part of a base64 encoded string is same from any changes from encoding or decoding URL parts,
// the `=` symbol that is used for padding is not.
// This can cause incorrect results when calling these function,
// where the original path contains `YXBwbGU%3D` instead of `YXBwbGU=`.
// This does not create any issues when the source store does not encode the string, so is safe to always call.
// For consistency, we might want to also always encode when creating the path in `keyToPath()`,
// but that would potentially break existing implementations that do not do encoding,
// and is not really necessary to solve any issues.
return Buffer.from(decodeURIComponent(key), 'base64').toString('utf-8');
}
}

View File

@ -24,6 +24,8 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
protected readonly rootFilepath: string;
// Extension to use as a fallback when the media type is not supported (could be made configurable).
protected readonly unknownMediaTypeExtension = 'unknown';
// Path suffix for metadata
private readonly metadataSuffix = '.meta';
public constructor(base: string, rootFilepath: string) {
this.baseRequestURI = trimTrailingSlashes(base);
@ -44,7 +46,7 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
Promise<ResourceLink> {
let path = this.getRelativePath(identifier);
if (isMetadata) {
path += '.meta';
path += this.metadataSuffix;
}
this.validateRelativePath(path, identifier);
@ -125,7 +127,7 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
}
const isMetadata = this.isMetadataPath(filePath);
if (isMetadata) {
url = url.slice(0, -'.meta'.length);
url = url.slice(0, -this.metadataSuffix.length);
}
return { identifier: { path: url }, filePath, contentType, isMetadata };
}
@ -213,6 +215,6 @@ export class BaseFileIdentifierMapper implements FileIdentifierMapper {
* Checks if the given path is a metadata path.
*/
protected isMetadataPath(path: string): boolean {
return path.endsWith('.meta');
return path.endsWith(this.metadataSuffix);
}
}

View File

@ -64,8 +64,8 @@ export class FixedContentTypeMapper extends BaseFileIdentifierMapper {
}
protected async getDocumentUrl(relative: string): Promise<string> {
// Handle path suffix
if (this.pathSuffix) {
// Handle path suffix, but ignore metadata files
if (this.pathSuffix && !this.isMetadataPath(relative)) {
if (relative.endsWith(this.pathSuffix)) {
relative = relative.slice(0, -this.pathSuffix.length);
} else {

View File

@ -3,7 +3,7 @@ import type { NamedNode } from '@rdfjs/types';
import arrayifyStream from 'arrayify-stream';
import type { ParserOptions } from 'n3';
import { StreamParser, StreamWriter } from 'n3';
import type { Quad } from 'rdf-js';
import type { Quad, Term } from 'rdf-js';
import type { Guarded } from './GuardedStream';
import { guardedStreamFrom, pipeSafely } from './StreamUtil';
import { toNamedTerm } from './TermUtil';
@ -45,6 +45,18 @@ export function uniqueQuads(quads: Quad[]): Quad[] {
}, []);
}
/**
* Converts a term to a number. Returns undefined if the term was undefined.
*
* @param term - Term to parse.
* @param radix - Radix to use when parsing. Default is 10.
*/
export function termToInt(term?: Term, radix = 10): number | undefined {
if (term) {
return Number.parseInt(term.value, radix);
}
}
/**
* Represents a triple pattern to be used as a filter.
*/

107
src/util/SliceStream.ts Normal file
View File

@ -0,0 +1,107 @@
import type { Readable, TransformCallback, TransformOptions } from 'stream';
import { Transform } from 'stream';
import { RangeNotSatisfiedHttpError } from './errors/RangeNotSatisfiedHttpError';
import { pipeSafely } from './StreamUtil';
export interface SliceStreamOptions extends TransformOptions {
start: number;
end?: number;
size?: number;
}
/**
* A stream that slices a part out of another stream.
* `start` and `end` are inclusive.
* If `end` is not defined it is until the end of the stream.
*
* Negative `start` values can be used to instead slice that many streams off the end of the stream.
* This requires the `size` field to be defined.
*
* Both object and non-object streams are supported.
* This needs to be explicitly specified,
* as the class makes no assumptions based on the object mode of the source stream.
*/
export class SliceStream extends Transform {
protected readonly source: Readable;
protected remainingSkip: number;
protected remainingRead: number;
public constructor(source: Readable, options: SliceStreamOptions) {
super(options);
let start = options.start;
const end = options.end ?? Number.POSITIVE_INFINITY;
if (options.start < 0) {
if (typeof options.size !== 'number') {
throw new RangeNotSatisfiedHttpError('Slicing data at the end of a stream requires a known size.');
} else {
// `start` is a negative number here so need to add
start = options.size + start;
}
}
if (start >= end) {
throw new RangeNotSatisfiedHttpError('Range start should be less than end.');
}
// Not using `end` variable as that could be infinity
if (typeof options.end === 'number' && typeof options.size === 'number' && options.end >= options.size) {
throw new RangeNotSatisfiedHttpError('Range end should be less than the total size.');
}
this.remainingSkip = start;
// End value is inclusive
this.remainingRead = end - options.start + 1;
this.source = source;
pipeSafely(source, this);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
public _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {
this.source.pause();
if (this.writableObjectMode) {
this.objectSlice(chunk);
} else {
this.binarySlice(chunk);
}
// eslint-disable-next-line callback-return
callback();
this.source.resume();
}
protected binarySlice(chunk: Buffer): void {
let length = chunk.length;
if (this.remainingSkip > 0) {
chunk = chunk.slice(this.remainingSkip);
this.remainingSkip -= length - chunk.length;
length = chunk.length;
}
if (length > 0 && this.remainingSkip <= 0) {
chunk = chunk.slice(0, this.remainingRead);
this.push(chunk);
this.remainingRead -= length;
this.checkEnd();
}
}
protected objectSlice(chunk: unknown): void {
if (this.remainingSkip > 0) {
this.remainingSkip -= 1;
} else {
this.remainingRead -= 1;
this.push(chunk);
this.checkEnd();
}
}
/**
* Stop piping the source stream and close everything once the slice is finished.
*/
protected checkEnd(): void {
if (this.remainingRead <= 0) {
this.source.unpipe();
this.end();
this.source.destroy();
}
}
}

View File

@ -266,8 +266,12 @@ export const SOLID_ERROR_TERM = createVocabulary('urn:npm:solid:community-server
);
export const SOLID_HTTP = createVocabulary('urn:npm:solid:community-server:http:',
// Unit, start, and end are used for range headers
'end',
'location',
'start',
'slug',
'unit',
);
export const SOLID_META = createVocabulary('urn:npm:solid:community-server:meta:',

View File

@ -0,0 +1,19 @@
import type { HttpErrorOptions } from './HttpError';
import { generateHttpErrorClass } from './HttpError';
// eslint-disable-next-line @typescript-eslint/naming-convention
const BaseHttpError = generateHttpErrorClass(416, 'RangeNotSatisfiedHttpError');
/**
* An error thrown when the requested range is not supported.
*/
export class RangeNotSatisfiedHttpError extends BaseHttpError {
/**
* Default message is 'The requested range is not supported.'.
* @param message - Optional, more specific, message.
* @param options - Optional error options.
*/
public constructor(message?: string, options?: HttpErrorOptions) {
super(message ?? 'The requested range is not supported.', options);
}
}

View File

@ -59,7 +59,9 @@
Create a new Pod with my WebID as owner<% if (locals.allowRootPod) { %> in the root<% } %>.
</label>
<ol id="createPodForm">
<% if (!locals.allowRootPod) { %>
<% if (locals.allowRootPod) { %>
<input type="hidden" id="rootPod" name="rootPod" value="true">
<% } else { %>
<li id="podNameForm">
<label for="podName">Pod name:</label>
<input id="podName" type="text" name="podName" value="<%= prefilled.podName || '' %>">

View File

@ -2,7 +2,7 @@ import { createReadStream } from 'fs';
import fetch from 'cross-fetch';
import type { Quad } from 'n3';
import { DataFactory, Parser, Store } from 'n3';
import { joinFilePath, PIM, RDF } from '../../src/';
import { joinFilePath, joinUrl, PIM, RDF } from '../../src/';
import type { App } from '../../src/';
import { LDP } from '../../src/util/Vocabularies';
import {
@ -726,4 +726,30 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC
// DELETE
await deleteResource(resourceUrl);
});
it('supports range requests.', async(): Promise<void> => {
const resourceUrl = joinUrl(baseUrl, 'range');
await putResource(resourceUrl, { contentType: 'text/plain', body: '0123456789' });
let response = await fetch(resourceUrl, { headers: { range: 'bytes=0-5' }});
expect(response.status).toBe(206);
expect(response.headers.get('content-range')).toBe('bytes 0-5/10');
expect(response.headers.get('content-length')).toBe('6');
await expect(response.text()).resolves.toBe('012345');
response = await fetch(resourceUrl, { headers: { range: 'bytes=5-' }});
expect(response.status).toBe(206);
expect(response.headers.get('content-range')).toBe('bytes 5-9/10');
expect(response.headers.get('content-length')).toBe('5');
await expect(response.text()).resolves.toBe('56789');
response = await fetch(resourceUrl, { headers: { range: 'bytes=-4' }});
expect(response.status).toBe(206);
expect(response.headers.get('content-range')).toBe('bytes 6-9/10');
expect(response.headers.get('content-length')).toBe('4');
await expect(response.text()).resolves.toBe('6789');
response = await fetch(resourceUrl, { headers: { range: 'bytes=5-15' }});
expect(response.status).toBe(416);
});
});

View File

@ -1,9 +1,8 @@
import { promises as fsPromises } from 'fs';
import type { Stats } from 'fs';
import fetch from 'cross-fetch';
import type { Response } from 'cross-fetch';
import { ensureDir, pathExists } from 'fs-extra';
import { joinFilePath, joinUrl } from '../../src';
import { ensureDir, pathExists, stat } from 'fs-extra';
import { joinUrl } from '../../src';
import type { App } from '../../src';
import { getPort } from '../util/Util';
import { getDefaultVariables, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config';
@ -45,23 +44,6 @@ async function registerTestPods(baseUrl: string, pods: string[]): Promise<void>
}
}
/* We just want a container with the correct metadata, everything else can be removed */
async function clearInitialFiles(rootFilePath: string, pods: string[]): Promise<void> {
for (const pod of pods) {
const fileList = await fsPromises.readdir(joinFilePath(rootFilePath, pod));
for (const file of fileList) {
if (file !== '.meta') {
const path = joinFilePath(rootFilePath, pod, file);
if ((await fsPromises.stat(path)).isDirectory()) {
await fsPromises.rm(path, { recursive: true });
} else {
await fsPromises.unlink(path);
}
}
}
}
}
describe('A quota server', (): void => {
// The allowed quota depends on what filesystem/OS you are using.
// For example: an empty folder is reported as
@ -74,7 +56,7 @@ describe('A quota server', (): void => {
// We want to use an empty folder as on APFS/Mac folder sizes vary a lot
const tempFolder = getTestFolder('quota-temp');
await ensureDir(tempFolder);
folderSizeTest = await fsPromises.stat(tempFolder);
folderSizeTest = await stat(tempFolder);
await removeFolder(tempFolder);
});
const podName1 = 'arthur';
@ -108,12 +90,11 @@ describe('A quota server', (): void => {
// Initialize 2 pods
await registerTestPods(baseUrl, [ podName1, podName2 ]);
await clearInitialFiles(rootFilePath, [ podName1, podName2 ]);
});
afterAll(async(): Promise<void> => {
await app.stop();
await removeFolder(rootFilePath);
await app.stop();
});
// Test quota in the first pod
@ -194,12 +175,11 @@ describe('A quota server', (): void => {
// Initialize 2 pods
await registerTestPods(baseUrl, [ podName1, podName2 ]);
await clearInitialFiles(rootFilePath, [ podName1, podName2 ]);
});
afterAll(async(): Promise<void> => {
await app.stop();
await removeFolder(rootFilePath);
await app.stop();
});
it('should return 413 when global quota is exceeded.', async(): Promise<void> => {

View File

@ -53,11 +53,8 @@ void => {
});
afterAll(async(): Promise<void> => {
// Stop the server
await app.stop();
// Execute the configured teardown
await teardown();
await app.stop();
});
it('should not be affected by dangling locks.', async(): Promise<void> => {

View File

@ -45,8 +45,8 @@ describe('A server with seeded pods', (): void => {
});
afterAll(async(): Promise<void> => {
await app.stop();
await removeFolder(rootFilePath);
await app.stop();
});
it('has created the requested pods.', async(): Promise<void> => {

View File

@ -74,8 +74,8 @@ describe.each(stores)('A server supporting WebHookChannel2023 using %s', (name,
afterAll(async(): Promise<void> => {
clientServer.close();
await app.stop();
await teardown();
await app.stop();
});
it('links to the storage description.', async(): Promise<void> => {

View File

@ -54,6 +54,15 @@
"@type": "FileSizeReporter",
"ignoreFolders": [ "^/\\.internal$" ]
},
{
"comment": "Use an empty pod for quota tests",
"@type": "Override",
"overrideInstance": { "@id": "urn:solid-server:default:PodResourcesGenerator" },
"overrideParameters": {
"@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/empty"
}
},
{
"@id": "urn:solid-server:test:Instances",
"@type": "RecordObject",

View File

@ -49,6 +49,15 @@
},
"limit_unit": "bytes"
},
{
"comment": "Use an empty pod for quota tests",
"@type": "Override",
"overrideInstance": { "@id": "urn:solid-server:default:PodResourcesGenerator" },
"overrideParameters": {
"@type": "StaticFolderGenerator",
"templateFolder": "@css:templates/root/empty"
}
},
{
"@id": "urn:solid-server:test:Instances",
"@type": "RecordObject",

View File

@ -0,0 +1,40 @@
import { RangePreferenceParser } from '../../../../../src/http/input/preferences/RangePreferenceParser';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
describe('A RangePreferenceParser', (): void => {
const parser = new RangePreferenceParser();
it('parses range headers.', async(): Promise<void> => {
await expect(parser.handle({ request: { headers: { range: 'bytes=5-10' }}} as any))
.resolves.toEqual({ range: { unit: 'bytes', parts: [{ start: 5, end: 10 }]}});
await expect(parser.handle({ request: { headers: { range: 'bytes=5-' }}} as any))
.resolves.toEqual({ range: { unit: 'bytes', parts: [{ start: 5 }]}});
await expect(parser.handle({ request: { headers: { range: 'bytes=-5' }}} as any))
.resolves.toEqual({ range: { unit: 'bytes', parts: [{ start: -5 }]}});
await expect(parser.handle({ request: { headers: { range: 'bytes=5-10, 11-20, 21-99' }}} as any))
.resolves.toEqual({ range: { unit: 'bytes',
parts: [{ start: 5, end: 10 }, { start: 11, end: 20 }, { start: 21, end: 99 }]}});
});
it('returns an empty object if there is no header.', async(): Promise<void> => {
await expect(parser.handle({ request: { headers: {}}} as any)).resolves.toEqual({});
});
it('rejects invalid range headers.', async(): Promise<void> => {
await expect(parser.handle({ request: { headers: { range: '=5-10' }}} as any))
.rejects.toThrow(BadRequestHttpError);
await expect(parser.handle({ request: { headers: { range: 'bytes' }}} as any))
.rejects.toThrow(BadRequestHttpError);
await expect(parser.handle({ request: { headers: { range: 'bytes=' }}} as any))
.rejects.toThrow(BadRequestHttpError);
await expect(parser.handle({ request: { headers: { range: 'bytes=-' }}} as any))
.rejects.toThrow(BadRequestHttpError);
await expect(parser.handle({ request: { headers: { range: 'bytes=5' }}} as any))
.rejects.toThrow(BadRequestHttpError);
await expect(parser.handle({ request: { headers: { range: 'bytes=5-10, 99' }}} as any))
.rejects.toThrow(BadRequestHttpError);
});
});

View File

@ -0,0 +1,54 @@
import { PreferenceParser } from '../../../../../src/http/input/preferences/PreferenceParser';
import { UnionPreferenceParser } from '../../../../../src/http/input/preferences/UnionPreferenceParser';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
describe('A UnionPreferenceParser', (): void => {
let parsers: jest.Mocked<PreferenceParser>[];
let parser: UnionPreferenceParser;
beforeEach(async(): Promise<void> => {
parsers = [
{
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue({}),
} satisfies Partial<PreferenceParser> as any,
{
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue({}),
} satisfies Partial<PreferenceParser> as any,
];
parser = new UnionPreferenceParser(parsers);
});
it('combines the outputs.', async(): Promise<void> => {
parsers[0].handle.mockResolvedValue({
type: { 'text/turtle': 1 },
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
});
parsers[1].handle.mockResolvedValue({
type: { 'text/plain': 0.9 },
language: { nl: 0.8 },
});
await expect(parser.handle({} as any)).resolves.toEqual({
type: { 'text/turtle': 1, 'text/plain': 0.9 },
language: { nl: 0.8 },
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
});
});
it('throws an error if multiple parsers return a range.', async(): Promise<void> => {
parsers[0].handle.mockResolvedValue({
type: { 'text/turtle': 1 },
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
});
parsers[1].handle.mockResolvedValue({
type: { 'text/plain': 0.9 },
language: { nl: 0.8 },
range: { unit: 'bytes', parts: [{ start: 3, end: 5 }]},
});
await expect(parser.handle({} as any)).rejects.toThrow(InternalServerError);
});
});

View File

@ -10,7 +10,7 @@ import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
import { updateModifiedDate } from '../../../../src/util/ResourceUtil';
import { CONTENT_TYPE, HH } from '../../../../src/util/Vocabularies';
import { CONTENT_TYPE, HH, SOLID_HTTP } from '../../../../src/util/Vocabularies';
describe('A GetOperationHandler', (): void => {
let operation: Operation;
@ -63,6 +63,18 @@ describe('A GetOperationHandler', (): void => {
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
});
it('returns 206 if the result is a partial stream.', async(): Promise<void> => {
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
metadata.set(SOLID_HTTP.terms.start, '5');
metadata.set(SOLID_HTTP.terms.end, '7');
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(206);
expect(result.metadata).toBe(metadata);
expect(result.data).toBe(data);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
});
it('returns a 304 if the conditions do not match.', async(): Promise<void> => {
conditions.matchesMetadata.mockReturnValue(false);
let error: unknown;

View File

@ -0,0 +1,72 @@
import { createResponse } from 'node-mocks-http';
import { RangeMetadataWriter } from '../../../../../src/http/output/metadata/RangeMetadataWriter';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
import { POSIX, SOLID_HTTP } from '../../../../../src/util/Vocabularies';
describe('RangeMetadataWriter', (): void => {
let metadata: RepresentationMetadata;
let response: HttpResponse;
let writer: RangeMetadataWriter;
beforeEach(async(): Promise<void> => {
metadata = new RepresentationMetadata();
response = createResponse();
writer = new RangeMetadataWriter();
});
it('adds the content-range and content-length header.', async(): Promise<void> => {
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
metadata.set(SOLID_HTTP.terms.start, '1');
metadata.set(SOLID_HTTP.terms.end, '5');
metadata.set(POSIX.terms.size, '10');
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({
'content-range': 'bytes 1-5/10',
'content-length': '5',
});
});
it('uses * if a value is unknown.', async(): Promise<void> => {
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({
'content-range': 'bytes *-*/*',
});
});
it('does nothing if there is no range metadata.', async(): Promise<void> => {
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({ });
});
it('adds a content-length header if the size is known.', async(): Promise<void> => {
metadata.set(POSIX.terms.size, '10');
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({
'content-length': '10',
});
});
it('correctly deduces end values if the size is known.', async(): Promise<void> => {
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
metadata.set(SOLID_HTTP.terms.start, '4');
metadata.set(POSIX.terms.size, '10');
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({
'content-range': 'bytes 4-9/10',
'content-length': '6',
});
});
it('correctly handles negative start values.', async(): Promise<void> => {
metadata.set(SOLID_HTTP.terms.unit, 'bytes');
metadata.set(SOLID_HTTP.terms.start, '-4');
metadata.set(POSIX.terms.size, '10');
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({
'content-range': 'bytes 6-9/10',
'content-length': '4',
});
});
});

View File

@ -0,0 +1,88 @@
import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../src/http/representation/Representation';
import { BinarySliceResourceStore } from '../../../src/storage/BinarySliceResourceStore';
import { ResourceStore } from '../../../src/storage/ResourceStore';
import { InternalServerError } from '../../../src/util/errors/InternalServerError';
import { RangeNotSatisfiedHttpError } from '../../../src/util/errors/RangeNotSatisfiedHttpError';
import { readableToString } from '../../../src/util/StreamUtil';
import { POSIX, SOLID_HTTP } from '../../../src/util/Vocabularies';
describe('A BinarySliceResourceStore', (): void => {
const identifier = { path: 'path' };
let representation: Representation;
let source: jest.Mocked<ResourceStore>;
let store: BinarySliceResourceStore;
beforeEach(async(): Promise<void> => {
representation = new BasicRepresentation('0123456789', 'text/plain');
source = {
getRepresentation: jest.fn().mockResolvedValue(representation),
} satisfies Partial<ResourceStore> as any;
store = new BinarySliceResourceStore(source);
});
it('slices the data stream and stores the metadata.', async(): Promise<void> => {
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 1, end: 4 }]}});
await expect(readableToString(result.data)).resolves.toBe('1234');
expect(result.metadata.get(SOLID_HTTP.terms.unit)?.value).toBe('bytes');
expect(result.metadata.get(SOLID_HTTP.terms.start)?.value).toBe('1');
expect(result.metadata.get(SOLID_HTTP.terms.end)?.value).toBe('4');
});
it('uses the stream size when slicing if available.', async(): Promise<void> => {
representation.metadata.set(POSIX.terms.size, '10');
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: -4 }]}});
await expect(readableToString(result.data)).resolves.toBe('6789');
expect(result.metadata.get(SOLID_HTTP.terms.unit)?.value).toBe('bytes');
expect(result.metadata.get(SOLID_HTTP.terms.start)?.value).toBe('-4');
});
it('does not add end metadata if there is none.', async(): Promise<void> => {
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 5 }]}});
await expect(readableToString(result.data)).resolves.toBe('56789');
expect(result.metadata.get(SOLID_HTTP.terms.unit)?.value).toBe('bytes');
expect(result.metadata.get(SOLID_HTTP.terms.start)?.value).toBe('5');
expect(result.metadata.get(SOLID_HTTP.terms.end)).toBeUndefined();
});
it('returns the original data if there is no valid range request.', async(): Promise<void> => {
let result = await store.getRepresentation(identifier, {});
await expect(readableToString(result.data)).resolves.toBe('0123456789');
source.getRepresentation.mockResolvedValue(new BasicRepresentation('0123456789', 'text/plain'));
result = await store.getRepresentation(identifier, { range: { unit: 'triples', parts: []}});
await expect(readableToString(result.data)).resolves.toBe('0123456789');
source.getRepresentation.mockResolvedValue(new BasicRepresentation('0123456789', 'text/plain'));
result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: []}});
await expect(readableToString(result.data)).resolves.toBe('0123456789');
});
it('returns the original data if there already is slice metadata.', async(): Promise<void> => {
representation.metadata.set(SOLID_HTTP.terms.unit, 'triples');
const result = await store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 5 }]}});
await expect(readableToString(result.data)).resolves.toBe('0123456789');
});
it('only supports binary streams.', async(): Promise<void> => {
representation.binary = false;
await expect(store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: 5 }]}}))
.rejects.toThrow(InternalServerError);
});
it('does not support multipart ranges.', async(): Promise<void> => {
await expect(store.getRepresentation(identifier,
{ range: { unit: 'bytes', parts: [{ start: 5, end: 6 }, { start: 7, end: 8 }]}}))
.rejects.toThrow(RangeNotSatisfiedHttpError);
});
it('closes the source stream if there was an error creating the SliceStream.', async(): Promise<void> => {
representation.data.destroy = jest.fn();
await expect(store.getRepresentation(identifier, { range: { unit: 'bytes', parts: [{ start: -5 }]}}))
.rejects.toThrow(RangeNotSatisfiedHttpError);
expect(representation.data.destroy).toHaveBeenCalledTimes(1);
});
});

View File

@ -9,7 +9,7 @@ import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError
import type { Guarded } from '../../../../src/util/GuardedStream';
import { BaseIdentifierStrategy } from '../../../../src/util/identifiers/BaseIdentifierStrategy';
import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil';
import { LDP, RDF } from '../../../../src/util/Vocabularies';
import { CONTENT_TYPE, LDP, POSIX, RDF } from '../../../../src/util/Vocabularies';
const { namedNode } = DataFactory;
class DummyStrategy extends BaseIdentifierStrategy {
@ -104,13 +104,18 @@ describe('An InMemoryDataAccessor', (): void => {
it('adds stored metadata when requesting document metadata.', async(): Promise<void> => {
const identifier = { path: `${base}resource` };
const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: LDP.terms.Resource });
const inputMetadata = new RepresentationMetadata(identifier, {
[RDF.type]: LDP.terms.Resource,
[CONTENT_TYPE]: 'text/turtle',
});
await expect(accessor.writeDocument(identifier, data, inputMetadata)).resolves.toBeUndefined();
metadata = await accessor.getMetadata(identifier);
expect(metadata.identifier.value).toBe(`${base}resource`);
const quads = metadata.quads();
expect(quads).toHaveLength(1);
expect(quads[0].object.value).toBe(LDP.Resource);
expect(quads).toHaveLength(3);
expect(metadata.get(RDF.terms.type)).toEqual(LDP.terms.Resource);
expect(metadata.contentType).toBe('text/turtle');
expect(metadata.get(POSIX.terms.size)?.value).toBe('4');
});
it('adds stored metadata when requesting container metadata.', async(): Promise<void> => {

View File

@ -8,7 +8,7 @@ import { BaseTypedRepresentationConverter } from '../../../../src/storage/conver
import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter';
import { matchesMediaType } from '../../../../src/storage/conversion/ConversionUtil';
import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter';
import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
import { CONTENT_TYPE, POSIX } from '../../../../src/util/Vocabularies';
class DummyConverter extends BaseTypedRepresentationConverter {
private readonly inTypes: ValuePreferences;
@ -47,6 +47,7 @@ describe('A ChainedConverter', (): void => {
beforeEach(async(): Promise<void> => {
const metadata = new RepresentationMetadata('a/a');
metadata.set(POSIX.terms.size, '500');
representation = { metadata } as Representation;
preferences = { type: { 'x/x': 1, 'x/*': 0.8 }};
args = { representation, preferences, identifier: { path: 'path' }};
@ -81,6 +82,7 @@ describe('A ChainedConverter', (): void => {
const result = await converter.handle(args);
expect(result.metadata.contentType).toBe('b/b');
expect(result.metadata.get(POSIX.terms.size)?.value).toBe('500');
});
it('converts input matching the output preferences if a better output can be found.', async(): Promise<void> => {
@ -91,6 +93,7 @@ describe('A ChainedConverter', (): void => {
const result = await converter.handle(args);
expect(result.metadata.contentType).toBe('x/x');
expect(result.metadata.get(POSIX.terms.size)).toBeUndefined();
});
it('interprets no preferences as */*.', async(): Promise<void> => {
@ -101,10 +104,12 @@ describe('A ChainedConverter', (): void => {
let result = await converter.handle(args);
expect(result.metadata.contentType).toBe('b/b');
expect(result.metadata.get(POSIX.terms.size)?.value).toBe('500');
args.preferences.type = { };
result = await converter.handle(args);
expect(result.metadata.contentType).toBe('b/b');
expect(result.metadata.get(POSIX.terms.size)?.value).toBe('500');
});
it('can find paths of length 1.', async(): Promise<void> => {
@ -113,6 +118,7 @@ describe('A ChainedConverter', (): void => {
const result = await converter.handle(args);
expect(result.metadata.contentType).toBe('x/x');
expect(result.metadata.get(POSIX.terms.size)).toBeUndefined();
});
it('can find longer paths.', async(): Promise<void> => {
@ -126,6 +132,7 @@ describe('A ChainedConverter', (): void => {
const result = await converter.handle(args);
expect(result.metadata.contentType).toBe('x/x');
expect(result.metadata.get(POSIX.terms.size)).toBeUndefined();
});
it('will use the shortest path among the best found.', async(): Promise<void> => {
@ -147,6 +154,7 @@ describe('A ChainedConverter', (): void => {
}
const result = await converter.handle(args);
expect(result.metadata.contentType).toBe('x/x');
expect(result.metadata.get(POSIX.terms.size)).toBeUndefined();
expect(converters[0].handle).toHaveBeenCalledTimes(0);
expect(converters[1].handle).toHaveBeenCalledTimes(0);
expect(converters[2].handle).toHaveBeenCalledTimes(1);

View File

@ -37,4 +37,19 @@ describe('A Base64EncodingStorage', (): void => {
expect(results).toHaveLength(1);
expect(results[0]).toEqual([ 'key', data ]);
});
it('correctly handles keys that have been encoded by the source storage.', async(): Promise<void> => {
// Base 64 encoding of 'apple'
const encodedKey = 'YXBwbGU=';
const data = 'data';
map.set(encodedKey, data);
const results = [];
for await (const entry of storage.entries()) {
results.push(entry);
}
expect(results).toHaveLength(1);
expect(results[0]).toEqual([ 'apple', data ]);
});
});

View File

@ -183,6 +183,15 @@ describe('An FixedContentTypeMapper', (): void => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test`, false)).rejects.toThrow(NotFoundHttpError);
await expect(mapper.mapFilePathToUrl(`${rootFilepath}test.txt`, false)).rejects.toThrow(NotFoundHttpError);
});
it('returns a generated file path for metadata regardless of the suffix.', async(): Promise<void> => {
await expect(mapper.mapFilePathToUrl(`${rootFilepath}.meta`, false)).resolves.toEqual({
identifier: { path: `${base}` },
filePath: `${rootFilepath}.meta`,
contentType: 'text/turtle',
isMetadata: true,
});
});
});
});

View File

@ -1,6 +1,6 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import { parseQuads, serializeQuads, uniqueQuads } from '../../../src/util/QuadUtil';
import { parseQuads, serializeQuads, termToInt, uniqueQuads } from '../../../src/util/QuadUtil';
import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
const { literal, namedNode, quad } = DataFactory;
@ -50,4 +50,15 @@ describe('QuadUtil', (): void => {
]);
});
});
describe('#termToInt', (): void => {
it('returns undefined if the input is undefined.', async(): Promise<void> => {
expect(termToInt()).toBeUndefined();
});
it('converts the term to a number.', async(): Promise<void> => {
expect(termToInt(namedNode('5'))).toBe(5);
expect(termToInt(namedNode('0xF'), 16)).toBe(15);
});
});
});

View File

@ -0,0 +1,52 @@
import { Readable } from 'stream';
import { RangeNotSatisfiedHttpError } from '../../../src/util/errors/RangeNotSatisfiedHttpError';
import { SliceStream } from '../../../src/util/SliceStream';
import { readableToString } from '../../../src/util/StreamUtil';
describe('A SliceStream', (): void => {
it('does not support suffix slicing if the size is unknown.', async(): Promise<void> => {
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: -5 }))
.toThrow(RangeNotSatisfiedHttpError);
});
it('requires the end to be more than the start.', async(): Promise<void> => {
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: 5, end: 4 }))
.toThrow(RangeNotSatisfiedHttpError);
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: 5, end: 5 }))
.toThrow(RangeNotSatisfiedHttpError);
});
it('requires the end to be less than the size.', async(): Promise<void> => {
expect((): unknown => new SliceStream(Readable.from('0123456789'), { start: 5, end: 6, size: 6 }))
.toThrow(RangeNotSatisfiedHttpError);
});
it('can slice binary streams.', async(): Promise<void> => {
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
{ start: 3, end: 7, objectMode: false }))).resolves.toBe('34567');
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
{ start: 3, objectMode: false }))).resolves.toBe('3456789');
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
{ start: 3, end: 20, objectMode: false }))).resolves.toBe('3456789');
await expect(readableToString(new SliceStream(Readable.from('0123456789', { objectMode: false }),
{ start: -3, size: 10, objectMode: false }))).resolves.toBe('789');
});
it('can slice object streams.', async(): Promise<void> => {
const arr = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ];
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
{ start: 3, end: 7, objectMode: true }))).resolves.toBe('34567');
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
{ start: 3, objectMode: true }))).resolves.toBe('3456789');
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
{ start: 3, end: 20, objectMode: true }))).resolves.toBe('3456789');
await expect(readableToString(new SliceStream(Readable.from(arr, { objectMode: true }),
{ start: -3, size: 10, objectMode: true }))).resolves.toBe('789');
});
});

View File

@ -12,6 +12,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError';
import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError';
import { RangeNotSatisfiedHttpError } from '../../../../src/util/errors/RangeNotSatisfiedHttpError';
import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
@ -27,6 +28,7 @@ describe('HttpError', (): void => {
[ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ],
[ 'PayloadHttpError', 413, PayloadHttpError ],
[ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ],
[ 'RangeNotSatisfiedHttpError', 416, RangeNotSatisfiedHttpError ],
[ 'UnprocessableEntityHttpError', 422, UnprocessableEntityHttpError ],
[ 'InternalServerError', 500, InternalServerError ],
[ 'NotImplementedHttpError', 501, NotImplementedHttpError ],