diff --git a/CHANGELOG.md b/CHANGELOG.md index cf07879e..2856e60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added - [SPARQL 1.1 Update](https://www.w3.org/TR/sparql11-update/) support for Rust, Python and JavaScript. All store-like classes now provide an `update` method. - [SPARQL 1.1 Query Results CSV and TSV Formats](https://www.w3.org/TR/sparql11-results-csv-tsv/) serializers and TSV format parser. +- [SPARQL 1.1 Graph Store HTTP Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/) partial support in `oxigraph_server`. This protocol is accessible under the `/server` path. - The SPARQL Query and Update algebra is now public. - The stores are now "graph aware" i.e. it is possible to create and keep empty named graphs. - A simple built-in HTTP client. In the Rust library, is disabled by default behind the `http_client` feature. It powers SPARQL federation and SPARQL UPDATE `LOAD` operations. @@ -15,12 +16,17 @@ - `(Memory|RocksDB|Sled)Store::prepare_query` methods. It is possible to cache SPARQL query parsing using the `Query::parse` function and give the parsed query to the `query` method. ### Changed +- Loading data into `oxigraph_server` is now possible using `/store` and not anymore using `/`. + For example, you should use now `curl -f -X POST -H 'Content-Type:application/n-quads' --data-binary "@MY_FILE.nq" http://localhost:7878/store` to add the N-Quads file MY_FILE.nt to the server dataset. - Fixes evaluation of `MONTH()` and `DAY()` functions on the `xsd:date` values. - `Variable::new` now validates the variable name. - `(Memory|RocksDB|Sled)Store::query` does not have an option parameter anymore. There is now a new `query_opt` method that allows giving options. - `xsd:boolean` SPARQL function now properly follows XPath specification. - Fixes SPARQL `DESCRIBE` evaluation. +### Disk data format + +The disk data format has been changed between Oxigraph 0.1 (version 0) and Oxigraph 0.2 (version 1). Data is automatically migrated from the version 0 format to the version 1 format when opened with Oxigraph 0.2. ## [0.1.1] - 2020-08-14 diff --git a/README.md b/README.md index 816f0205..01c82ebe 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,11 @@ It is split into multiple parts: * The `python` directory contains Pyoxigraph. Pyoxigraph allows using Oxigraph in Python. See the [Pyoxigraph website](https://oxigraph.org/pyoxigraph/) for Pyoxigraph documentation. [![PyPI](https://img.shields.io/pypi/v/pyoxigraph)](https://pypi.org/project/pyoxigraph/) * The `js` directory contains bindings to use Oxigraph in JavaScript with the help of WebAssembly. See [its README](https://github.com/oxigraph/oxigraph/blob/master/js/README.md) for the JS bindings documentation. [![npm](https://img.shields.io/npm/v/oxigraph)](https://www.npmjs.com/package/oxigraph) -* The `server` directory contains a stand-alone binary of a web server implementing the [SPARQL 1.1 Protocol](https://www.w3.org/TR/sparql11-protocol/). It uses the [RocksDB](https://rocksdb.org/) key-value store. +* The `server` directory contains a stand-alone binary of a web server implementing the [SPARQL 1.1 Protocol](https://www.w3.org/TR/sparql11-protocol/) and the [SPARQL 1.1 Graph Store Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/). It uses the [RocksDB](https://rocksdb.org/) key-value store. [![Latest Version](https://img.shields.io/crates/v/oxigraph_server.svg)](https://crates.io/crates/oxigraph_server) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/oxigraph/oxigraph?sort=semver)](https://hub.docker.com/repository/docker/oxigraph/oxigraph) * The `wikibase` directory contains a stand-alone binary of a web server able to synchronize with a [Wikibase instance](https://wikiba.se/). -[![Latest Version](https://img.shields.io/crates/v/oxigraph_wikibase.svg)](https://crates.io/crates/oxigraph_wikibase) +[![Latest Version](https://img.shields.io/crates/v/oxigraph_wikibase.svg)](https://crates.io/crates/oxigraph_wikibase) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/oxigraph/oxigraph-wikibase?sort=semver)](https://hub.docker.com/repository/docker/oxigraph/oxigraph-wikibase) Oxigraph implements the following specifications: @@ -56,14 +56,16 @@ Run `oxigraph_server -f my_data_storage_directory` to start the server where `my The server provides an HTML UI with a form to execute SPARQL requests. It provides the following REST actions: -* `/` allows to `POST` data to the server. - For example `curl -f -X POST -H 'Content-Type:application/n-triples' --data-binary "@MY_FILE.nt" http://localhost:7878/` - will add the N-Triples file MY_FILE.nt to the server repository. [Turtle](https://www.w3.org/TR/turtle/), [TriG](https://www.w3.org/TR/trig/), [N-Triples](https://www.w3.org/TR/n-triples/), [N-Quads](https://www.w3.org/TR/n-quads/) and [RDF XML](https://www.w3.org/TR/rdf-syntax-grammar/) are supported. * `/query` allows to evaluate SPARQL queries against the server repository following the [SPARQL 1.1 Protocol](https://www.w3.org/TR/sparql11-protocol/#query-operation). For example `curl -X POST -H 'Content-Type:application/sparql-query' --data 'SELECT * WHERE { ?s ?p ?o } LIMIT 10' http://localhost:7878/query`. This action supports content negotiation and could return [Turtle](https://www.w3.org/TR/turtle/), [N-Triples](https://www.w3.org/TR/n-triples/), [RDF XML](https://www.w3.org/TR/rdf-syntax-grammar/), [SPARQL Query Results XML Format](http://www.w3.org/TR/rdf-sparql-XMLres/) and [SPARQL Query Results JSON Format](https://www.w3.org/TR/sparql11-results-json/). * `/update` allows to execute SPARQL updates against the server repository following the [SPARQL 1.1 Protocol](https://www.w3.org/TR/sparql11-protocol/#update-operation). For example `curl -X POST -H 'Content-Type: application/sparql-update' --data 'DELETE WHERE { ?p ?o }' http://localhost:7878/update`. +* `/server` allows to retrieve and change the server content using the [SPARQL 1.1 Graph Store HTTP Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/). + For example `curl -f -X POST -H 'Content-Type:application/n-triples' --data-binary "@MY_FILE.nt" http://localhost:7878/store?graph=http://example.com/g` will add the N-Triples file MY_FILE.nt to the server dataset inside of the `http://example.com/g` named graph. + [Turtle](https://www.w3.org/TR/turtle/), [N-Triples](https://www.w3.org/TR/n-triples/) and [RDF XML](https://www.w3.org/TR/rdf-syntax-grammar/) are supported. + It is also possible to `POST`, `PUT` and `GET` the complete RDF dataset on the server using RDF dataset formats ([TriG](https://www.w3.org/TR/trig/) and [N-Quads](https://www.w3.org/TR/n-quads/)) against the `/server` endpoint. + For example `curl -f -X POST -H 'Content-Type:application/n-quads' --data-binary "@MY_FILE.nq" http://localhost:7878/store` will add the N-Quads file MY_FILE.nt to the server dataset. Use `oxigraph_server --help` to see the possible options when starting the server. @@ -86,7 +88,7 @@ You can then access it from your machine on port `7878`: firefox http://localhost:7878 # Post some data -curl http://localhost:7878 -H 'Content-Type: application/x-turtle' -d@./data.ttl +curl http://localhost:7878/store?default -H 'Content-Type: text/turtle' -d@./data.ttl # Make a query curl -X POST -H 'Accept: application/sparql-results+json' -H 'Content-Type: application/sparql-query' --data 'SELECT * WHERE { ?s ?p ?o } LIMIT 10' http://localhost:7878/query diff --git a/server/Cargo.toml b/server/Cargo.toml index b4955ca7..b37f9694 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,4 +21,8 @@ async-std = { version = "1", features = ["attributes"] } async-h1 = "2" http-types = "2" oxigraph = { version = "0.1", path="../lib", features = ["http_client"] } +rand = "0.8" url = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/server/src/main.rs b/server/src/main.rs index 9d869cac..460ec2c9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -19,16 +19,17 @@ use http_types::{ bail_status, headers, Error, Method, Mime, Request, Response, Result, StatusCode, }; use oxigraph::io::{DatasetFormat, GraphFormat}; -use oxigraph::model::{GraphName, NamedNode, NamedOrBlankNode}; +use oxigraph::model::{GraphName, GraphNameRef, NamedNode, NamedOrBlankNode}; use oxigraph::sparql::algebra::GraphUpdateOperation; use oxigraph::sparql::{Query, QueryResults, QueryResultsFormat, Update}; #[cfg(feature = "rocksdb")] use oxigraph::RocksDbStore as Store; #[cfg(all(feature = "sled", not(feature = "rocksdb")))] use oxigraph::SledStore as Store; +use rand::random; use std::io::BufReader; use std::str::FromStr; -use url::form_urlencoded; +use url::{form_urlencoded, Url}; const MAX_SPARQL_BODY_SIZE: u64 = 1_048_576; const HTML_ROOT_PAGE: &str = include_str!("../templates/query.html"); @@ -73,31 +74,6 @@ async fn handle_request(request: Request, store: Store) -> Result { response.set_body(LOGO); response } - ("/", Method::Post) => { - if let Some(content_type) = request.content_type() { - if let Some(format) = GraphFormat::from_media_type(content_type.essence()) { - store.load_graph( - BufReader::new(SyncAsyncReader::from(request)), - format, - &GraphName::DefaultGraph, - None, - ) - } else if let Some(format) = DatasetFormat::from_media_type(content_type.essence()) - { - store.load_dataset(BufReader::new(SyncAsyncReader::from(request)), format, None) - } else { - bail_status!( - 415, - "No supported content Content-Type given: {}", - content_type - ) - } - .map_err(bad_request)?; - Response::new(StatusCode::NoContent) - } else { - bail_status!(400, "No Content-Type given") - } - } ("/query", Method::Get) => { configure_and_evaluate_sparql_query(store, url_query(&request), None, request)? } @@ -165,15 +141,215 @@ async fn handle_request(request: Request, store: Store) -> Result { bail_status!(400, "No Content-Type given") } } - _ => Response::new(StatusCode::NotFound), + (path, Method::Get) if path.starts_with("/store") => { + //TODO: stream + let mut body = Vec::default(); + let format = if let Some(target) = store_target(&request)? { + if !match &target { + GraphName::DefaultGraph => true, + GraphName::NamedNode(target) => store.contains_named_graph(target)?, + GraphName::BlankNode(target) => store.contains_named_graph(target)?, + } { + bail_status!(404, "The graph {} does not exists", target) + } + let format = graph_content_negotiation(request)?; + store.dump_graph(&mut body, format, &target)?; + format.media_type() + } else { + let format = dataset_content_negotiation(request)?; + store.dump_dataset(&mut body, format)?; + format.media_type() + }; + let mut response = Response::from(body); + response.insert_header(headers::CONTENT_TYPE, format); + response + } + (path, Method::Put) if path.starts_with("/store") => { + if let Some(content_type) = request.content_type() { + if let Some(target) = store_target(&request)? { + if let Some(format) = GraphFormat::from_media_type(content_type.essence()) { + let new = !match &target { + GraphName::NamedNode(target) => { + if store.contains_named_graph(target)? { + store.clear_graph(target)?; + true + } else { + store.insert_named_graph(target)?; + false + } + } + GraphName::BlankNode(target) => { + if store.contains_named_graph(target)? { + store.clear_graph(target)?; + true + } else { + store.insert_named_graph(target)?; + false + } + } + GraphName::DefaultGraph => { + store.clear_graph(&target)?; + true + } + }; + store + .load_graph( + BufReader::new(SyncAsyncReader::from(request)), + format, + &target, + None, + ) + .map_err(bad_request)?; + Response::new(if new { + StatusCode::Created + } else { + StatusCode::NoContent + }) + } else { + bail_status!( + 415, + "No supported content Content-Type given: {}", + content_type + ) + } + } else if let Some(format) = DatasetFormat::from_media_type(content_type.essence()) + { + store.clear()?; + store + .load_dataset(BufReader::new(SyncAsyncReader::from(request)), format, None) + .map_err(bad_request)?; + Response::new(StatusCode::NoContent) + } else { + bail_status!( + 415, + "No supported content Content-Type given: {}", + content_type + ) + } + } else { + bail_status!(400, "No Content-Type given") + } + } + (path, Method::Delete) if path.starts_with("/store") => { + if let Some(target) = store_target(&request)? { + match target { + GraphName::DefaultGraph => store.clear_graph(GraphNameRef::DefaultGraph)?, + GraphName::NamedNode(target) => { + if store.contains_named_graph(&target)? { + store.remove_named_graph(&target)?; + } else { + bail_status!(404, "The graph {} does not exists", target) + } + } + GraphName::BlankNode(target) => { + if store.contains_named_graph(&target)? { + store.remove_named_graph(&target)?; + } else { + bail_status!(404, "The graph {} does not exists", target) + } + } + } + } else { + store.clear()?; + } + Response::new(StatusCode::NoContent) + } + (path, Method::Post) if path.starts_with("/store") => { + if let Some(content_type) = request.content_type() { + if let Some(target) = store_target(&request)? { + if let Some(format) = GraphFormat::from_media_type(content_type.essence()) { + let new = !match &target { + GraphName::NamedNode(target) => store.contains_named_graph(target)?, + GraphName::BlankNode(target) => store.contains_named_graph(target)?, + GraphName::DefaultGraph => true, + }; + store + .load_graph( + BufReader::new(SyncAsyncReader::from(request)), + format, + &target, + None, + ) + .map_err(bad_request)?; + Response::new(if new { + StatusCode::Created + } else { + StatusCode::NoContent + }) + } else { + bail_status!( + 415, + "No supported content Content-Type given: {}", + content_type + ) + } + } else if let Some(format) = DatasetFormat::from_media_type(content_type.essence()) + { + store + .load_dataset(BufReader::new(SyncAsyncReader::from(request)), format, None) + .map_err(bad_request)?; + Response::new(StatusCode::NoContent) + } else if let Some(format) = GraphFormat::from_media_type(content_type.essence()) { + let graph = NamedNode::new( + base_url(&request)? + .join(&format!("/store/{:x}", random::()))? + .into_string(), + )?; + store + .load_graph( + BufReader::new(SyncAsyncReader::from(request)), + format, + &graph, + None, + ) + .map_err(bad_request)?; + let mut response = Response::new(StatusCode::Created); + response.insert_header(headers::LOCATION, graph.into_string()); + response + } else { + bail_status!( + 415, + "No supported content Content-Type given: {}", + content_type + ) + } + } else { + bail_status!(400, "No Content-Type given") + } + } + (path, Method::Head) if path.starts_with("/store") => { + if let Some(target) = store_target(&request)? { + if !match &target { + GraphName::DefaultGraph => true, + GraphName::NamedNode(target) => store.contains_named_graph(target)?, + GraphName::BlankNode(target) => store.contains_named_graph(target)?, + } { + bail_status!(404, "The graph {} does not exists", target) + } + Response::new(StatusCode::Ok) + } else { + Response::new(StatusCode::Ok) + } + } + _ => bail_status!( + 404, + "{} {} is not supported by this server", + request.method(), + request.url().path() + ), }; response.append_header(headers::SERVER, SERVER); Ok(response) } -fn base_url(request: &Request) -> &str { - let url = request.url().as_str(); - url.split('?').next().unwrap_or(url) +fn base_url(request: &Request) -> Result { + let mut url = request.url().clone(); + if let Some(host) = request.host() { + url.set_host(Some(host)).map_err(bad_request)?; + } + url.set_query(None); + url.set_fragment(None); + Ok(url) } fn url_query(request: &Request) -> Vec { @@ -215,7 +391,8 @@ fn evaluate_sparql_query( named_graph_uris: Vec, request: Request, ) -> Result { - let mut query = Query::parse(&query, Some(base_url(&request))).map_err(bad_request)?; + let mut query = + Query::parse(&query, Some(base_url(&request)?.as_str())).map_err(bad_request)?; let default_graph_uris = default_graph_uris .into_iter() .map(|e| Ok(NamedNode::new(e)?.into())) @@ -297,7 +474,8 @@ fn evaluate_sparql_update( named_graph_uris: Vec, request: Request, ) -> Result { - let mut update = Update::parse(&update, Some(base_url(&request))).map_err(bad_request)?; + let mut update = + Update::parse(&update, Some(base_url(&request)?.as_str())).map_err(bad_request)?; let default_graph_uris = default_graph_uris .into_iter() .map(|e| Ok(NamedNode::new(e)?.into())) @@ -325,6 +503,47 @@ fn evaluate_sparql_update( Ok(Response::new(StatusCode::NoContent)) } +fn store_target(request: &Request) -> Result> { + if request.url().path() == "/store" { + let mut graph = None; + let mut default = false; + for (k, v) in form_urlencoded::parse(request.url().query().unwrap_or("").as_bytes()) { + match k.as_ref() { + "graph" => graph = Some(v.into_owned()), + "default" => default = true, + _ => bail_status!(400, "Unexpected parameter: {}", k), + } + } + Ok(if let Some(graph) = graph { + if default { + bail_status!( + 400, + "Both graph and default parameters should not be set at the same time", + ) + } else { + Some( + NamedNode::new( + base_url(request)? + .join(&graph) + .map_err(bad_request)? + .into_string(), + ) + .map_err(bad_request)? + .into(), + ) + } + } else if default { + Some(GraphName::DefaultGraph) + } else { + None + }) + } else { + Ok(Some( + NamedNode::new(base_url(request)?.into_string())?.into(), + )) + } +} + async fn http_server< F: Clone + Send + Sync + 'static + Fn(Request) -> Fut, Fut: Send + Future>, @@ -378,6 +597,17 @@ fn graph_content_negotiation(request: Request) -> Result { ) } +fn dataset_content_negotiation(request: Request) -> Result { + content_negotiation( + request, + &[ + DatasetFormat::NQuads.media_type(), + DatasetFormat::TriG.media_type(), + ], + DatasetFormat::from_media_type, + ) +} + fn content_negotiation( request: Request, supported: &[&str], @@ -448,49 +678,45 @@ impl std::io::Read for SyncAsyncReader { #[cfg(test)] mod tests { - use super::Store; + use super::*; use crate::handle_request; use async_std::task::block_on; - use http_types::{Method, Request, StatusCode, Url}; - use std::collections::hash_map::DefaultHasher; - use std::env::temp_dir; - use std::fs::remove_dir_all; - use std::hash::{Hash, Hasher}; + use tempfile::{tempdir, TempDir}; #[test] fn get_ui() { - exec( + ServerTest::new().test_status( Request::new(Method::Get, Url::parse("http://localhost/").unwrap()), StatusCode::Ok, ) } #[test] - fn post_file() { - let mut request = Request::new(Method::Post, Url::parse("http://localhost/").unwrap()); - request.insert_header("Content-Type", "text/turtle"); + fn post_dataset_file() { + let mut request = Request::new(Method::Post, Url::parse("http://localhost/store").unwrap()); + request.insert_header("Content-Type", "application/trig"); request.set_body(" ."); - exec(request, StatusCode::NoContent) + ServerTest::new().test_status(request, StatusCode::NoContent) } #[test] fn post_wrong_file() { - let mut request = Request::new(Method::Post, Url::parse("http://localhost/").unwrap()); - request.insert_header("Content-Type", "text/turtle"); + let mut request = Request::new(Method::Post, Url::parse("http://localhost/store").unwrap()); + request.insert_header("Content-Type", "application/trig"); request.set_body(""); - exec(request, StatusCode::BadRequest) + ServerTest::new().test_status(request, StatusCode::BadRequest) } #[test] fn post_unsupported_file() { - let mut request = Request::new(Method::Post, Url::parse("http://localhost/").unwrap()); + let mut request = Request::new(Method::Post, Url::parse("http://localhost/store").unwrap()); request.insert_header("Content-Type", "text/foo"); - exec(request, StatusCode::UnsupportedMediaType) + ServerTest::new().test_status(request, StatusCode::UnsupportedMediaType) } #[test] fn get_query() { - exec( + ServerTest::new().test_status( Request::new( Method::Get, Url::parse( @@ -504,7 +730,7 @@ mod tests { #[test] fn get_bad_query() { - exec( + ServerTest::new().test_status( Request::new( Method::Get, Url::parse("http://localhost/query?query=SELECT").unwrap(), @@ -515,7 +741,7 @@ mod tests { #[test] fn get_without_query() { - exec( + ServerTest::new().test_status( Request::new(Method::Get, Url::parse("http://localhost/query").unwrap()), StatusCode::BadRequest, ); @@ -526,7 +752,7 @@ mod tests { let mut request = Request::new(Method::Post, Url::parse("http://localhost/query").unwrap()); request.insert_header("Content-Type", "application/sparql-query"); request.set_body("SELECT * WHERE { ?s ?p ?o }"); - exec(request, StatusCode::Ok) + ServerTest::new().test_status(request, StatusCode::Ok) } #[test] @@ -534,7 +760,7 @@ mod tests { let mut request = Request::new(Method::Post, Url::parse("http://localhost/query").unwrap()); request.insert_header("Content-Type", "application/sparql-query"); request.set_body("SELECT"); - exec(request, StatusCode::BadRequest) + ServerTest::new().test_status(request, StatusCode::BadRequest) } #[test] @@ -542,7 +768,7 @@ mod tests { let mut request = Request::new(Method::Post, Url::parse("http://localhost/query").unwrap()); request.insert_header("Content-Type", "application/sparql-todo"); request.set_body("SELECT"); - exec(request, StatusCode::UnsupportedMediaType) + ServerTest::new().test_status(request, StatusCode::UnsupportedMediaType) } #[test] @@ -550,7 +776,7 @@ mod tests { let mut request = Request::new(Method::Post, Url::parse("http://localhost/query").unwrap()); request.insert_header("Content-Type", "application/sparql-query"); request.set_body("SELECT * WHERE { SERVICE { ?p ?o } }"); - exec(request, StatusCode::Ok) + ServerTest::new().test_status(request, StatusCode::Ok) } #[test] @@ -561,7 +787,7 @@ mod tests { request.set_body( "INSERT DATA { }", ); - exec(request, StatusCode::NoContent) + ServerTest::new().test_status(request, StatusCode::NoContent) } #[test] @@ -570,22 +796,274 @@ mod tests { Request::new(Method::Post, Url::parse("http://localhost/update").unwrap()); request.insert_header("Content-Type", "application/sparql-update"); request.set_body("INSERT"); - exec(request, StatusCode::BadRequest) + ServerTest::new().test_status(request, StatusCode::BadRequest) } - fn exec(request: Request, expected_status: StatusCode) { - let mut path = temp_dir(); - path.push("temp-oxigraph-server-test"); - let mut s = DefaultHasher::new(); - format!("{:?}", request).hash(&mut s); - path.push(&s.finish().to_string()); - - let store = Store::open(&path).unwrap(); - let (code, message) = match block_on(handle_request(request, store)) { - Ok(r) => (r.status(), "".to_string()), - Err(e) => (e.status(), e.to_string()), - }; - assert_eq!(code, expected_status, "Error message: {}", message); - remove_dir_all(&path).unwrap() + #[test] + fn graph_store_protocol() { + // Tests from https://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/ + + let server = ServerTest::new(); + + // PUT - Initial state + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store/person/1.ttl").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + request.set_body( + " +@prefix foaf: . +@prefix v: . + + a foaf:Person; + foaf:businessCard [ + a v:VCard; + v:fn \"John Doe\" + ]. +", + ); + server.test_status(request, StatusCode::Created); + + // GET of PUT - Initial state + let mut request = Request::new( + Method::Get, + Url::parse("http://localhost/store?graph=/store/person/1.ttl").unwrap(), + ); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + + // HEAD on an existing graph + server.test_status( + Request::new( + Method::Head, + Url::parse("http://localhost/store/person/1.ttl").unwrap(), + ), + StatusCode::Ok, + ); + + // HEAD on a non-existing graph + server.test_status( + Request::new( + Method::Head, + Url::parse("http://localhost/store/person/4.ttl").unwrap(), + ), + StatusCode::NotFound, + ); + + // PUT - graph already in store + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store/person/1.ttl").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + request.set_body( + " +@prefix foaf: . +@prefix v: . + + a foaf:Person; + foaf:businessCard [ + a v:VCard; + v:fn \"Jane Doe\" + ]. +", + ); + server.test_status(request, StatusCode::NoContent); + + // GET of PUT - graph already in store + let mut request = Request::new( + Method::Get, + Url::parse("http://localhost/store/person/1.ttl").unwrap(), + ); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + + // PUT - default graph + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store?default").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + request.set_body( + " +@prefix foaf: . +@prefix v: . + +[] a foaf:Person; + foaf:businessCard [ + a v:VCard; + v:given-name \"Alice\" + ] . +", + ); + server.test_status(request, StatusCode::NoContent); // The default graph always exists in Oxigraph + + // GET of PUT - default graph + let mut request = Request::new( + Method::Get, + Url::parse("http://localhost/store?default").unwrap(), + ); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + + // PUT - mismatched payload + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store/person/1.ttl").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + request.set_body("@prefix fo"); + server.test_status(request, StatusCode::BadRequest); + + // PUT - empty graph + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + server.test_status(request, StatusCode::Created); + + // GET of PUT - empty graph + let mut request = Request::new( + Method::Get, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + + // PUT - replace empty graph + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + request.set_body( + " +@prefix foaf: . +@prefix v: . + +[] a foaf:Person; + foaf:businessCard [ + a v:VCard; + v:given-name \"Alice\" + ] . +", + ); + server.test_status(request, StatusCode::NoContent); + + // GET of replacement for empty graph + let mut request = Request::new( + Method::Get, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + + // DELETE - existing graph + server.test_status( + Request::new( + Method::Delete, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ), + StatusCode::NoContent, + ); + + // GET of DELETE - existing graph + server.test_status( + Request::new( + Method::Get, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ), + StatusCode::NotFound, + ); + + // DELETE - non-existent graph + server.test_status( + Request::new( + Method::Delete, + Url::parse("http://localhost/store/person/2.ttl").unwrap(), + ), + StatusCode::NotFound, + ); + + // POST - existing graph + let mut request = Request::new( + Method::Put, + Url::parse("http://localhost/store/person/1.ttl").unwrap(), + ); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + server.test_status(request, StatusCode::NoContent); + + // TODO: POST - multipart/form-data + // TODO: GET of POST - multipart/form-data + + // POST - create new graph + let mut request = Request::new(Method::Post, Url::parse("http://localhost/store").unwrap()); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + request.set_body( + " +@prefix foaf: . +@prefix v: . + +[] a foaf:Person; + foaf:businessCard [ + a v:VCard; + v:given-name \"Alice\" + ] . +", + ); + let response = server.exec(request); + assert_eq!(response.status(), StatusCode::Created); + let location = response.header("Location").unwrap().as_str(); + + // GET of POST - create new graph + let mut request = Request::new(Method::Get, Url::parse(location).unwrap()); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + + // POST - empty graph to existing graph + let mut request = Request::new(Method::Put, Url::parse(location).unwrap()); + request.insert_header("Content-Type", "text/turtle; charset=utf-8"); + server.test_status(request, StatusCode::NoContent); + + // GET of POST - after noop + let mut request = Request::new(Method::Get, Url::parse(location).unwrap()); + request.insert_header("Accept", "text/turtle"); + server.test_status(request, StatusCode::Ok); + } + + struct ServerTest { + store: Store, + _path: TempDir, + } + + impl ServerTest { + fn new() -> ServerTest { + let path = tempdir().unwrap(); + let store = Store::open(path.path()).unwrap(); + ServerTest { _path: path, store } + } + + fn exec(&self, request: Request) -> Response { + match block_on(handle_request(request, self.store.clone())) { + Ok(response) => response, + Err(e) => { + let mut response = Response::new(e.status()); + response.set_body(e.to_string()); + response + } + } + } + + fn test_status(&self, request: Request, expected_status: StatusCode) { + let mut response = self.exec(request); + assert_eq!( + response.status(), + expected_status, + "Error message: {}", + block_on(response.body_string()).unwrap() + ); + } } }