mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
Merge branch 'main' into versions/next-major
This commit is contained in:
commit
c95198285c
@ -25,6 +25,7 @@
|
||||
"NotificationChannelType",
|
||||
"PermissionMap",
|
||||
"Promise",
|
||||
"Readable",
|
||||
"Readonly",
|
||||
"RegExp",
|
||||
"Server",
|
||||
@ -32,6 +33,8 @@
|
||||
"Shorthand",
|
||||
"Template",
|
||||
"TemplateEngine",
|
||||
"Transform",
|
||||
"TransformOptions",
|
||||
"ValuePreferencesArg",
|
||||
"VariableBindings",
|
||||
"UnionHandler",
|
||||
|
@ -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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
13
.github/ISSUE_TEMPLATE/question.md
vendored
13
.github/ISSUE_TEMPLATE/question.md
vendored
@ -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.-->
|
2
.github/workflows/cth-test.yml
vendored
2
.github/workflows/cth-test.yml
vendored
@ -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
|
||||
|
20
.github/workflows/docker.yml
vendored
20
.github/workflows/docker.yml
vendored
@ -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 }}
|
||||
|
6
.github/workflows/mkdocs.yml
vendored
6
.github/workflows/mkdocs.yml
vendored
@ -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'
|
||||
|
10
.github/workflows/npm-test.yml
vendored
10
.github/workflows/npm-test.yml
vendored
@ -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
|
||||
|
36
CHANGELOG.md
36
CHANGELOG.md
@ -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
141
README.md
@ -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
|
||||
|
@ -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
|
||||
|
@ -21,6 +21,7 @@
|
||||
"Accept-Post",
|
||||
"Accept-Put",
|
||||
"Allow",
|
||||
"Content-Range",
|
||||
"ETag",
|
||||
"Last-Modified",
|
||||
"Link",
|
||||
|
@ -3,7 +3,11 @@
|
||||
"@graph": [
|
||||
{
|
||||
"@id": "urn:solid-server:default:PreferenceParser",
|
||||
"@type": "AcceptPreferenceParser"
|
||||
"@type": "UnionPreferenceParser",
|
||||
"parsers": [
|
||||
{ "@type": "AcceptPreferenceParser" },
|
||||
{ "@type": "RangePreferenceParser" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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" }
|
||||
]
|
||||
|
10
config/ldp/metadata-writer/writers/range.json
Normal file
10
config/ldp/metadata-writer/writers/range.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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" }
|
||||
},
|
||||
{
|
||||
|
@ -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)
|
||||
|
121
documentation/markdown/usage/starting-server.md
Normal file
121
documentation/markdown/usage/starting-server.md
Normal 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
4
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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[];
|
||||
}[] = [
|
||||
|
48
src/http/input/preferences/RangePreferenceParser.ts
Normal file
48
src/http/input/preferences/RangePreferenceParser.ts
Normal 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 }};
|
||||
}
|
||||
}
|
32
src/http/input/preferences/UnionPreferenceParser.ts
Normal file
32
src/http/input/preferences/UnionPreferenceParser.ts
Normal 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;
|
||||
}, {});
|
||||
}
|
||||
}
|
50
src/http/output/metadata/RangeMetadataWriter.ts
Normal file
50
src/http/output/metadata/RangeMetadataWriter.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 }[] };
|
||||
}
|
||||
|
@ -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';
|
||||
|
68
src/storage/BinarySliceResourceStore.ts
Normal file
68
src/storage/BinarySliceResourceStore.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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>;
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
107
src/util/SliceStream.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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:',
|
||||
|
19
src/util/errors/RangeNotSatisfiedHttpError.ts
Normal file
19
src/util/errors/RangeNotSatisfiedHttpError.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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 || '' %>">
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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> => {
|
||||
|
@ -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> => {
|
||||
|
@ -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> => {
|
||||
|
@ -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> => {
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
72
test/unit/http/output/metadata/RangeMetadataWriter.test.ts
Normal file
72
test/unit/http/output/metadata/RangeMetadataWriter.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
88
test/unit/storage/BinarySliceResourceStore.test.ts
Normal file
88
test/unit/storage/BinarySliceResourceStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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> => {
|
||||
|
@ -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);
|
||||
|
@ -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 ]);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
52
test/unit/util/SliceStream.test.ts
Normal file
52
test/unit/util/SliceStream.test.ts
Normal 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');
|
||||
});
|
||||
});
|
@ -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 ],
|
||||
|
Loading…
x
Reference in New Issue
Block a user