├── src ├── tdm │ └── mod.rs ├── query │ ├── journals.rs │ ├── prefixes.rs │ ├── funders.rs │ ├── members.rs │ ├── facet.rs │ ├── types.rs │ ├── mod.rs │ └── works.rs ├── cn.rs ├── error.rs ├── crossref.rs ├── response │ ├── work.rs │ └── mod.rs └── lib.rs ├── .gitignore ├── .travis.yml ├── Cargo.toml ├── LICENSE-MIT ├── README.md └── LICENSE-APACHE /src/tdm/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | sudo: required 3 | dist: trusty 4 | rust: 5 | - stable 6 | - beta 7 | - nightly 8 | cache: cargo 9 | script: 10 | - cargo build --verbose --all 11 | - cargo test --verbose --all -------------------------------------------------------------------------------- /src/query/journals.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::query::works::{WorksCombiner, WorksFilter, WorksIdentQuery, WorksQuery}; 3 | use crate::query::{Component, CrossrefQuery, CrossrefRoute, ResourceComponent}; 4 | 5 | /// constructs the request payload for the `/journals` route 6 | #[derive(Debug, Clone)] 7 | pub enum Journals { 8 | /// target a specific journal at `/journals/{id}` 9 | Identifier(String), 10 | /// target a `Work` for a specific funder at `/journals/{id}/works?query..` 11 | Works(WorksIdentQuery), 12 | } 13 | 14 | impl CrossrefRoute for Journals { 15 | fn route(&self) -> Result { 16 | match self { 17 | Journals::Identifier(s) => Ok(format!("{}/{}", Component::Journals.route()?, s)), 18 | Journals::Works(combined) => Self::combined_route(combined), 19 | } 20 | } 21 | } 22 | 23 | impl CrossrefQuery for Journals { 24 | fn resource_component(self) -> ResourceComponent { 25 | ResourceComponent::Journals(self) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/query/prefixes.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::query::works::{WorksCombiner, WorksFilter, WorksIdentQuery, WorksQuery}; 3 | use crate::query::{Component, CrossrefQuery, CrossrefRoute, ResourceComponent}; 4 | 5 | /// constructs the request payload for the `/prefixes` route 6 | #[derive(Debug, Clone)] 7 | pub enum Prefixes { 8 | /// target a specific member at `/prefixes/{id}` 9 | Identifier(String), 10 | /// target a `Work` for a specific prefix at `/prefixes/{id}/works?query..` 11 | Works(WorksIdentQuery), 12 | } 13 | 14 | impl CrossrefRoute for Prefixes { 15 | fn route(&self) -> Result { 16 | match self { 17 | Prefixes::Identifier(s) => Ok(format!("{}/{}", Component::Prefixes.route()?, s)), 18 | Prefixes::Works(combined) => Self::combined_route(combined), 19 | } 20 | } 21 | } 22 | 23 | impl CrossrefQuery for Prefixes { 24 | fn resource_component(self) -> ResourceComponent { 25 | ResourceComponent::Prefixes(self) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crossref" 3 | version = "0.2.2" 4 | authors = ["matthiasseitz "] 5 | license = "MIT OR Apache-2.0" 6 | readme = "README.md" 7 | keywords = ["api", "web", "literature", "doi"] 8 | repository = "https://github.com/MattsSe/crossref-rs" 9 | description= "Implementation of the Crossref API" 10 | documentation = "https://docs.rs/crossref/" 11 | categories = ["api-bindings", "science"] 12 | edition = "2018" 13 | 14 | [badges] 15 | travis-ci = { repository = "MattsSe/crossref-rs", branch = "master" } 16 | 17 | [[bin]] 18 | name = "crossref" 19 | path = "src/crossref.rs" 20 | required-features =["cli"] 21 | 22 | [dependencies] 23 | reqwest = "0.9" 24 | serde = { version = "1.0", features = ["derive"] } 25 | failure = "0.1" 26 | serde_json = "1.0" 27 | chrono = { version = "0.4", features = ["serde"] } 28 | structopt = { version = "0.2", optional = true } 29 | url = "1.7" 30 | pretty_env_logger = { version = "0.3", optional = true } 31 | 32 | [features] 33 | cli = ["structopt", "pretty_env_logger"] 34 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthias Seitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cn.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize)] 4 | #[allow(missing_docs)] 5 | pub enum CnFormat { 6 | RdfXml, 7 | Turtle, 8 | CiteProcJson, 9 | CiteProcJsonIsh, 10 | Text, 11 | Ris, 12 | BibTex, 13 | CrossrefXml, 14 | DataciteXml, 15 | BibEntry, 16 | CrossrefTdm, 17 | } 18 | 19 | impl CnFormat { 20 | /// the mime identifier 21 | pub fn mime_type(&self) -> &str { 22 | match self { 23 | CnFormat::RdfXml => "text/xml", 24 | CnFormat::Turtle => "text/plain", 25 | CnFormat::CiteProcJson => "application/json", 26 | CnFormat::CiteProcJsonIsh => "application/json", 27 | CnFormat::Text => "text/plain", 28 | CnFormat::Ris => "text/plain", 29 | CnFormat::BibTex => "text/xml", 30 | CnFormat::CrossrefXml => "text/xml", 31 | CnFormat::DataciteXml => "", 32 | CnFormat::BibEntry => "text/plain", 33 | CnFormat::CrossrefTdm => "text/xml", 34 | } 35 | } 36 | /// the mime type's header 37 | pub fn header(&self) -> &str { 38 | match self { 39 | CnFormat::RdfXml => "application/rdf+xml", 40 | CnFormat::Turtle => "text/turtle", 41 | CnFormat::CiteProcJson | CnFormat::CiteProcJsonIsh => { 42 | "transform/application/vnd.citationstyles.csl+json" 43 | } 44 | CnFormat::Text => "text/x-bibliography", 45 | CnFormat::Ris => "application/x-research-info-systems", 46 | CnFormat::BibTex => "application/x-bibtex", 47 | CnFormat::CrossrefXml => "application/vnd.crossref.unixref+xml", 48 | CnFormat::DataciteXml => "application/vnd.datacite.datacite+xml", 49 | CnFormat::BibEntry => "application/x-bibtex", 50 | CnFormat::CrossrefTdm => "application/vnd.crossref.unixsd+xml", 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/query/funders.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::query::facet::FacetCount; 3 | use crate::query::works::{WorksCombiner, WorksFilter, WorksIdentQuery, WorksQuery}; 4 | use crate::query::*; 5 | use std::borrow::Cow; 6 | 7 | /// filters supported for the /funders route 8 | #[derive(Debug, Clone)] 9 | pub enum FundersFilter { 10 | /// funders located in specified country 11 | Location(String), 12 | } 13 | 14 | impl FundersFilter { 15 | /// the key name for the filter element 16 | pub fn name(&self) -> &str { 17 | match self { 18 | FundersFilter::Location(_) => "location", 19 | } 20 | } 21 | } 22 | 23 | impl ParamFragment for FundersFilter { 24 | fn key(&self) -> Cow { 25 | Cow::Borrowed(self.name()) 26 | } 27 | 28 | fn value(&self) -> Option> { 29 | match self { 30 | FundersFilter::Location(s) => Some(Cow::Borrowed(s.as_str())), 31 | } 32 | } 33 | } 34 | 35 | impl Filter for FundersFilter {} 36 | 37 | impl_common_query!(FundersQuery, FundersFilter); 38 | 39 | /// constructs the request payload for the `/funders` route 40 | #[derive(Debug, Clone)] 41 | pub enum Funders { 42 | /// target a specific funder at `/funder/{id}` 43 | Identifier(String), 44 | /// target all funders that match the query at `/funders?query...` 45 | Query(FundersQuery), 46 | /// target a `Work` for a specific funder at `/funders/{id}/works?query..` 47 | Works(WorksIdentQuery), 48 | } 49 | 50 | impl CrossrefRoute for Funders { 51 | fn route(&self) -> Result { 52 | match self { 53 | Funders::Identifier(s) => Ok(format!("{}/{}", Component::Funders.route()?, s)), 54 | Funders::Query(query) => { 55 | let query = query.route()?; 56 | if query.is_empty() { 57 | Component::Funders.route() 58 | } else { 59 | Ok(format!("{}?{}", Component::Funders.route()?, query)) 60 | } 61 | } 62 | Funders::Works(combined) => Self::combined_route(combined), 63 | } 64 | } 65 | } 66 | 67 | impl CrossrefQuery for Funders { 68 | fn resource_component(self) -> ResourceComponent { 69 | ResourceComponent::Funders(self) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::query::ResourceComponent; 2 | use crate::response::MessageType; 3 | use failure::{Backtrace, Compat, Context, Fail}; 4 | use serde::{de, ser}; 5 | use std::{fmt, result}; 6 | 7 | /// A type alias for handling errors throughout crossref. 8 | pub type Result = result::Result; 9 | 10 | /// An error that can occur while interacting with a crossref index. 11 | #[derive(Debug)] 12 | pub struct Error { 13 | ctx: Context, 14 | } 15 | 16 | impl Fail for Error { 17 | fn cause(&self) -> Option<&dyn Fail> { 18 | self.ctx.cause() 19 | } 20 | 21 | fn backtrace(&self) -> Option<&Backtrace> { 22 | self.ctx.backtrace() 23 | } 24 | } 25 | 26 | impl fmt::Display for Error { 27 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 28 | self.ctx.fmt(f) 29 | } 30 | } 31 | 32 | /// all different error types this crate uses 33 | #[derive(Debug, Fail)] 34 | pub enum ErrorKind { 35 | /// if an invalid type was requested 36 | #[fail(display = "invalid type name: {}", name)] 37 | InvalidTypeName { name: String }, 38 | 39 | /// if there is a mismatch between the expected return type of the crossref api and this rust client 40 | #[fail( 41 | display = "expected response item of type {} but got {}", 42 | expected, got 43 | )] 44 | UnexpectedItem { 45 | expected: MessageType, 46 | got: MessageType, 47 | }, 48 | /// a config error 49 | #[fail(display = "{}", msg)] 50 | Config { 51 | /// the notification 52 | msg: String, 53 | }, 54 | 55 | /// an error that occurred while operating with [reqwest] 56 | #[fail(display = "{}", reqwest)] 57 | ReqWest { 58 | /// the notification 59 | reqwest: reqwest::Error, 60 | }, 61 | /// When no message was found but expected 62 | #[fail( 63 | display = "No message found but expected message of type `{}`", 64 | expected 65 | )] 66 | MissingMessage { expected: MessageType }, 67 | /// When crossref could not find anything 68 | #[fail(display = "Nothing was found for resource `{}`", resource)] 69 | ResourceNotFound { resource: Box }, 70 | /// if a error in serde occurred 71 | #[fail(display = "invalid serde: {}", error)] 72 | Serde { error: serde_json::Error }, 73 | } 74 | 75 | impl From for Error { 76 | fn from(kind: ErrorKind) -> Error { 77 | Error::from(Context::new(kind)) 78 | } 79 | } 80 | 81 | impl From> for Error { 82 | fn from(ctx: Context) -> Error { 83 | Error { ctx } 84 | } 85 | } 86 | 87 | impl From for Error { 88 | fn from(error: serde_json::Error) -> Error { 89 | ErrorKind::Serde { error }.into() 90 | } 91 | } 92 | 93 | impl From for Error { 94 | fn from(reqwest: reqwest::Error) -> Error { 95 | ErrorKind::ReqWest { reqwest }.into() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/query/members.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::query::works::{WorksCombiner, WorksFilter, WorksIdentQuery, WorksQuery}; 3 | use crate::query::*; 4 | use std::borrow::Cow; 5 | 6 | /// filters supported for the `/members` route 7 | #[derive(Debug, Clone)] 8 | pub enum MembersFilter { 9 | /// Member has made their references public for one or more of their prefixes 10 | HasPublicReferences, 11 | /// metadata for works where references are either `open`, `limited` (to Metadata Plus subscribers) or `closed` 12 | ReferenceVisibility(Visibility), 13 | /// count of DOIs for material published more than two years ago 14 | BlackfileDoiCount(i32), 15 | /// count of DOIs for material published within last two years 16 | CurrentDoiCount(i32), 17 | } 18 | 19 | impl MembersFilter { 20 | /// the key name for the filter element 21 | pub fn name(&self) -> &str { 22 | match self { 23 | MembersFilter::HasPublicReferences => "has-public-references", 24 | MembersFilter::ReferenceVisibility(_) => "reference-visibility", 25 | MembersFilter::BlackfileDoiCount(_) => "blackfile-doi-count", 26 | MembersFilter::CurrentDoiCount(_) => "current-doi-count", 27 | } 28 | } 29 | } 30 | 31 | impl ParamFragment for MembersFilter { 32 | fn key(&self) -> Cow { 33 | Cow::Borrowed(self.name()) 34 | } 35 | 36 | fn value(&self) -> Option> { 37 | match self { 38 | MembersFilter::HasPublicReferences => None, 39 | MembersFilter::ReferenceVisibility(vis) => Some(Cow::Borrowed(vis.as_str())), 40 | MembersFilter::BlackfileDoiCount(num) => Some(Cow::Owned(num.to_string())), 41 | MembersFilter::CurrentDoiCount(num) => Some(Cow::Owned(num.to_string())), 42 | } 43 | } 44 | } 45 | 46 | impl Filter for MembersFilter {} 47 | 48 | impl_common_query!(MembersQuery, MembersFilter); 49 | 50 | /// constructs the request payload for the `/members` route 51 | #[derive(Debug, Clone)] 52 | pub enum Members { 53 | /// target a specific member at `/members/{id}` 54 | Identifier(String), 55 | /// target all members that match the query at `/members?query...` 56 | Query(MembersQuery), 57 | /// target a `Work` for a specific funder at `/members/{id}/works?query..` 58 | Works(WorksIdentQuery), 59 | } 60 | 61 | impl CrossrefRoute for Members { 62 | fn route(&self) -> Result { 63 | match self { 64 | Members::Identifier(s) => Ok(format!("{}/{}", Component::Members.route()?, s)), 65 | Members::Query(query) => { 66 | let query = query.route()?; 67 | if query.is_empty() { 68 | Component::Members.route() 69 | } else { 70 | Ok(format!("{}?{}", Component::Members.route()?, query)) 71 | } 72 | } 73 | Members::Works(combined) => Self::combined_route(combined), 74 | } 75 | } 76 | } 77 | 78 | impl CrossrefQuery for Members { 79 | fn resource_component(self) -> ResourceComponent { 80 | ResourceComponent::Members(self) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/query/facet.rs: -------------------------------------------------------------------------------- 1 | use crate::query::{CrossrefQueryParam, ParamFragment}; 2 | use std::borrow::Cow; 3 | 4 | /// all available facets that can be set as filter in a query 5 | #[derive(Debug, Clone)] 6 | pub enum Facet { 7 | /// Author affiliation 8 | Affiliation, 9 | /// Funder literal name as deposited alongside DOIs 10 | FunderName, 11 | /// Funder DOI 12 | FunderDoi, 13 | /// Contributor ORCID 14 | ORCID, 15 | /// Work container title, such as journal title, or book title 16 | ContainerTitle, 17 | /// Custom Crossmark assertion name 18 | Assertion, 19 | /// Archive location 20 | Archive, 21 | /// Significant update type 22 | UpdateType, 23 | /// Journal ISSN (any - print, electronic, link) 24 | ISSN, 25 | /// Earliest year of publication 26 | Published, 27 | /// Work type name, such as `journal-article` or `book-chapter` 28 | TypeName, 29 | /// License URI of work 30 | License, 31 | /// Category name of work 32 | CategoryName, 33 | /// Relation type described by work or described by another work with work as object 34 | RelationType, 35 | /// Custom Crossmark assertion group name 36 | AssertionGroup, 37 | /// Publisher name of work 38 | PublisherName, 39 | } 40 | 41 | impl Facet { 42 | /// the maximum numeric number some facets are not allowed to exceed 43 | pub const MAX_VAL_NUM: usize = 100; 44 | 45 | /// the maximum value the facets can hold 46 | /// some facets are unbounded `*`, some are limited to [MAX_VAL_NUM] 47 | fn max_value(&self) -> &str { 48 | match self { 49 | Facet::ORCID | Facet::ContainerTitle | Facet::ISSN => "100", 50 | _ => "*", 51 | } 52 | } 53 | 54 | /// the key name for the query fragment 55 | fn as_str(&self) -> &str { 56 | match self { 57 | Facet::Affiliation => "affiliation", 58 | Facet::FunderName => "funder-name", 59 | Facet::FunderDoi => "funder-doi", 60 | Facet::ORCID => "orcid", 61 | Facet::ContainerTitle => "container-title", 62 | Facet::Assertion => "assertion", 63 | Facet::Archive => "archive", 64 | Facet::UpdateType => "update-type", 65 | Facet::ISSN => "issn", 66 | Facet::Published => "published", 67 | Facet::TypeName => "type-name", 68 | Facet::License => "license", 69 | Facet::CategoryName => "category-name", 70 | Facet::RelationType => "relation-type", 71 | Facet::AssertionGroup => "assertion-group", 72 | Facet::PublisherName => "publisher-name", 73 | } 74 | } 75 | } 76 | 77 | #[derive(Debug, Clone)] 78 | #[allow(missing_docs)] 79 | pub struct FacetCount { 80 | /// the targeted facet 81 | pub facet: Facet, 82 | /// the maximum number 83 | pub count: Option, 84 | } 85 | 86 | impl FacetCount { 87 | fn value(&self) -> String { 88 | match &self.count { 89 | Some(count) => match self.facet { 90 | Facet::ORCID | Facet::ContainerTitle | Facet::ISSN => { 91 | if *count > Facet::MAX_VAL_NUM { 92 | Facet::MAX_VAL_NUM.to_string() 93 | } else { 94 | count.to_string() 95 | } 96 | } 97 | _ => count.to_string(), 98 | }, 99 | _ => self.facet.max_value().to_string(), 100 | } 101 | } 102 | } 103 | 104 | impl ParamFragment for FacetCount { 105 | fn key(&self) -> Cow { 106 | Cow::Borrowed(self.facet.as_str()) 107 | } 108 | fn value(&self) -> Option> { 109 | Some(Cow::Owned(self.value())) 110 | } 111 | } 112 | 113 | impl CrossrefQueryParam for Vec { 114 | fn param_key(&self) -> Cow { 115 | Cow::Borrowed("facet") 116 | } 117 | 118 | fn param_value(&self) -> Option> { 119 | Some(Cow::Owned( 120 | self.iter() 121 | .map(ParamFragment::fragment) 122 | .collect::>() 123 | .join(","), 124 | )) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/query/types.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, ErrorKind, Result}; 2 | use crate::query::works::{WorksCombiner, WorksFilter, WorksIdentQuery, WorksQuery}; 3 | use crate::query::{Component, CrossrefQuery, CrossrefRoute, ResourceComponent}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::str::FromStr; 6 | 7 | /// all possible types of a `Work` 8 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 9 | #[serde(tag = "id")] 10 | #[serde(rename_all = "kebab-case")] 11 | #[allow(missing_docs)] 12 | pub enum Type { 13 | BookSection, 14 | Monograph, 15 | Report, 16 | PeerReview, 17 | BookTrack, 18 | JournalArticle, 19 | BookPart, 20 | Other, 21 | Book, 22 | JournalVolume, 23 | BookSet, 24 | ReferenceEntry, 25 | ProceedingsArticle, 26 | Journal, 27 | Component, 28 | BookChapter, 29 | ProceedingsSeries, 30 | ReportSeries, 31 | Proceedings, 32 | Standard, 33 | ReferenceBook, 34 | PostedContent, 35 | JournalIssue, 36 | Dissertation, 37 | Dataset, 38 | BookSeries, 39 | EditedBook, 40 | StandardSeries, 41 | } 42 | 43 | impl Type { 44 | /// the display-friendly label for the type 45 | pub fn label(&self) -> &str { 46 | match self { 47 | Type::BookSection => "Book Section", 48 | Type::Monograph => "Monograph", 49 | Type::Report => "Report", 50 | Type::PeerReview => "Peer Review", 51 | Type::BookTrack => "Book Track", 52 | Type::JournalArticle => "Journal Article", 53 | Type::BookPart => "Book Part", 54 | Type::Other => "Other", 55 | Type::Book => "Book", 56 | Type::JournalVolume => "Journal Volume", 57 | Type::BookSet => "Book Set", 58 | Type::ReferenceEntry => "Reference Entry", 59 | Type::ProceedingsArticle => "Proceedings Article", 60 | Type::Journal => "Journal", 61 | Type::Component => "Component", 62 | Type::BookChapter => "Book Chapter", 63 | Type::ProceedingsSeries => "Proceedings Series", 64 | Type::ReportSeries => "Report Series", 65 | Type::Proceedings => "Proceedings", 66 | Type::Standard => "Standard", 67 | Type::ReferenceBook => "Reference Book", 68 | Type::PostedContent => "Posted Content", 69 | Type::JournalIssue => "Journal Issue", 70 | Type::Dissertation => "Dissertation", 71 | Type::Dataset => "Dataset", 72 | Type::BookSeries => "Book Series", 73 | Type::EditedBook => "Edited Book", 74 | Type::StandardSeries => "Standard Series", 75 | } 76 | } 77 | /// the string used to identify the type 78 | pub fn id(&self) -> &str { 79 | match self { 80 | Type::BookSection => "book-section", 81 | Type::Monograph => "monograph", 82 | Type::Report => "report", 83 | Type::PeerReview => "peer-review", 84 | Type::BookTrack => "book-track", 85 | Type::JournalArticle => "journal-article", 86 | Type::BookPart => "book-part", 87 | Type::Other => "other", 88 | Type::Book => "book", 89 | Type::JournalVolume => "journal-volume", 90 | Type::BookSet => "book-set", 91 | Type::ReferenceEntry => "reference-entry", 92 | Type::ProceedingsArticle => "proceedings-article", 93 | Type::Journal => "journal", 94 | Type::Component => "component", 95 | Type::BookChapter => "book-chapter", 96 | Type::ProceedingsSeries => "proceedings-series", 97 | Type::ReportSeries => "report-series", 98 | Type::Proceedings => "proceedings", 99 | Type::Standard => "standard", 100 | Type::ReferenceBook => "reference-book", 101 | Type::PostedContent => "posted-content", 102 | Type::JournalIssue => "journal-issue", 103 | Type::Dissertation => "dissertation", 104 | Type::Dataset => "dataset", 105 | Type::BookSeries => "book-series", 106 | Type::EditedBook => "edited-book", 107 | Type::StandardSeries => "standard-series", 108 | } 109 | } 110 | } 111 | 112 | impl FromStr for Type { 113 | type Err = Error; 114 | 115 | fn from_str(s: &str) -> Result { 116 | match s { 117 | "book-section" => Ok(Type::BookSection), 118 | "monograph" => Ok(Type::Monograph), 119 | "report" => Ok(Type::Report), 120 | "peer-review" => Ok(Type::PeerReview), 121 | "book-track" => Ok(Type::BookTrack), 122 | "journal-article" => Ok(Type::JournalArticle), 123 | "book-part" => Ok(Type::BookPart), 124 | "other" => Ok(Type::Other), 125 | "book" => Ok(Type::Book), 126 | "journal-volume" => Ok(Type::JournalVolume), 127 | "book-set" => Ok(Type::BookSet), 128 | "reference-entry" => Ok(Type::ReferenceEntry), 129 | "proceedings-article" => Ok(Type::ProceedingsArticle), 130 | "journal" => Ok(Type::Journal), 131 | "component" => Ok(Type::Component), 132 | "book-chapter" => Ok(Type::BookChapter), 133 | "proceedings-series" => Ok(Type::ProceedingsSeries), 134 | "report-series" => Ok(Type::ReportSeries), 135 | "proceedings" => Ok(Type::Proceedings), 136 | "standard" => Ok(Type::Standard), 137 | "reference-book" => Ok(Type::ReferenceBook), 138 | "posted-content" => Ok(Type::PostedContent), 139 | "journal-issue" => Ok(Type::JournalIssue), 140 | "dissertation" => Ok(Type::Dissertation), 141 | "dataset" => Ok(Type::Dataset), 142 | "book-series" => Ok(Type::BookSeries), 143 | "edited-book" => Ok(Type::EditedBook), 144 | "standard-series" => Ok(Type::StandardSeries), 145 | name => Err(Error::from(ErrorKind::InvalidTypeName { 146 | name: name.to_string(), 147 | })), 148 | } 149 | } 150 | } 151 | 152 | /// constructs the request payload for the `/types` route 153 | #[derive(Debug, Clone)] 154 | pub enum Types { 155 | /// every available type 156 | All, 157 | /// target a specific type at `/types/{id}` 158 | Identifier(String), 159 | /// target a `Work` for a specific type at `/types/{id}/works?query..` 160 | Works(WorksIdentQuery), 161 | } 162 | 163 | impl CrossrefRoute for Types { 164 | fn route(&self) -> Result { 165 | match self { 166 | Types::All => Component::Types.route(), 167 | Types::Identifier(s) => Ok(format!("{}/{}", Component::Types.route()?, s)), 168 | Types::Works(combined) => Self::combined_route(combined), 169 | } 170 | } 171 | } 172 | 173 | impl CrossrefQuery for Types { 174 | fn resource_component(self) -> ResourceComponent { 175 | ResourceComponent::Types(self) 176 | } 177 | } 178 | 179 | #[cfg(test)] 180 | mod tests { 181 | use super::*; 182 | use serde_json::*; 183 | 184 | // #[test] 185 | fn test_types() { 186 | let section = r#"{ 187 | "id": "book-section", 188 | "label": "Book Section" 189 | }"#; 190 | let ref_type: Type = serde_json::from_str(section).unwrap(); 191 | 192 | assert_eq!(Type::BookSection, ref_type); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Crossref-rs - A rust client for the Crossref-API 2 | ===================== 3 | [![Build Status](https://travis-ci.com/MattsSe/crossref-rs.svg?branch=master)](https://travis-ci.com/MattsSe/crossref-rs) 4 | [![Crates.io](https://img.shields.io/crates/v/crossref.svg)](https://crates.io/crates/crossref) 5 | [![Documentation](https://docs.rs/crossref/badge.svg)](https://docs.rs/crossref) 6 | 7 | 8 | [Crossref API docs](https://github.com/CrossRef/rest-api-doc) 9 | 10 | This client is inspired by [sckott/habanero](https://github.com/sckott/habanero/). 11 | 12 | 13 | `Crossref` - Crossref search API. The `Crossref` crate provides methods matching Crossref API routes: 14 | 15 | * `works` - `/works` route 16 | * `members` - `/members` route 17 | * `prefixes` - `/prefixes` route 18 | * `funders` - `/funders` route 19 | * `journals` - `/journals` route 20 | * `types` - `/types` route 21 | * `agency` - `/works/{doi}/agency` get DOI minting agency 22 | 23 | 24 | ## Usage 25 | 26 | ### Create a `Crossref` client: 27 | 28 | ```rust 29 | let client = Crossref::builder().build()?; 30 | ``` 31 | 32 | If you have an [Authorization token for Crossref's Plus service](https://github.com/CrossRef/rest-api-doc#authorization-token-for-plus-service): 33 | 34 | ```rust 35 | let client = Crossref::builder() 36 | .token("token") 37 | .build()?; 38 | ``` 39 | 40 | Encouraged to use the **The Polite Pool**: 41 | 42 | [Good manners = more reliable service](https://github.com/CrossRef/rest-api-doc#good-manners--more-reliable-service) 43 | 44 | To get into Crossref's polite pool include a email address 45 | 46 | ```rust 47 | let client = Crossref::builder() 48 | .polite("polite@example.com") 49 | .token("your token") 50 | .build()?; 51 | ``` 52 | 53 | ### Constructing Queries 54 | Not all components support queries and there are custom available parameters for each route that supports querying. 55 | For each resource components that supports querying there exist a Query struct: `WorksQuery`, `MembersQuery`, `FundersQuery`. The `WorksQuery` also differs from the others by supporting [deep paging with cursors](https://github.com/CrossRef/rest-api-doc#deep-paging-with-cursors) and [field queries](https://github.com/CrossRef/rest-api-doc#works-field-queries). 56 | 57 | otherwise creating queries works the same for all resource components: 58 | 59 | ```rust 60 | 61 | let query = WorksQuery::new("Machine Learning") 62 | // field queries supported for `Works` 63 | .field_query(FieldQuery::author("Some Author")) 64 | // filters are specific for each resource component 65 | .filter(WorksFilter::HasOrcid) 66 | .order(Order::Asc) 67 | .sort(Sort::Score); 68 | ``` 69 | 70 | 71 | ### Get Records 72 | 73 | See [this table](https://github.com/CrossRef/rest-api-doc#resource-components) for a detailed overview of the major components. 74 | 75 | There are 3 different targets: 76 | 77 | * **standalone resource components**: `/works`, `/members`, `funders`, `prefixes`, `types` that return a list list of the corresponding items and can be specified with queries 78 | * **Resource component with identifiers**: `/works/{doi}?`,`/members/{member_id}?`, etc. that returns a single item if found. 79 | * **combined with the `works` route**: The works component can be appended to other resources: `/members/{member_id}/works?` etc. that returns a list of matching `Work` items as `WorkList`. 80 | 81 | This resembles in the enums of the resource components, eg. for `Members`: 82 | 83 | ```rust 84 | pub enum Members { 85 | /// target a specific member at `/members/{id}` 86 | Identifier(String), 87 | /// target all members that match the query at `/members?query...` 88 | Query(MembersQuery), 89 | /// target a `Work` for a specific member at `/members/{id}/works?query..` 90 | Works(WorksIdentQuery), 91 | } 92 | ``` 93 | 94 | ### Examples 95 | 96 | All options are supported by the client: 97 | 98 | **Query Single Item by DOI or ID** 99 | 100 | Analogous methods exist for all resource components 101 | 102 | ```rust 103 | let work = client.work("10.1037/0003-066X.59.1.29")?; 104 | 105 | let agency = client.work_agency("10.1037/0003-066X.59.1.29")?; 106 | 107 | let funder = client.funder("funder_id")?; 108 | 109 | let member = client.member("member_id")?; 110 | ``` 111 | 112 | **Query** 113 | 114 | ```rust 115 | let query = WorksQuery::new("Machine Learning"); 116 | 117 | // one page of the matching results 118 | let works = client.works(query)?; 119 | ``` 120 | 121 | Alternatively insert a free form query term directly 122 | 123 | ```rust 124 | let works = client.works("Machine Learning")?; 125 | ``` 126 | 127 | **Combining Routes with the `Works` route** 128 | 129 | For each resource component other than `Works` there exist methods to append a `WorksQuery` with the ID option `/members/{member_id}/works??` 130 | 131 | ``` 132 | use crossref::*; 133 | fn run() -> Result<()> { 134 | let client = Crossref::builder().build()?; 135 | let works = client.member_works(WorksQuery::new("machine learning") 136 | .sort(Sort::Score).into_ident("member_id"))?; 137 | Ok(()) 138 | } 139 | ``` 140 | 141 | This would be the same as using the [`Crossref::works`] method by supplying the combined type 142 | 143 | ```rust 144 | use crossref::*; 145 | fn run() -> Result<()> { 146 | let client = Crossref::builder().build()?; 147 | let works = client.works(WorksQuery::new("machine learning") 148 | .sort(Sort::Score) 149 | .into_combined_query::("member_id"))?; 150 | Ok(()) 151 | } 152 | ``` 153 | 154 | ** Deep paging for `Works` ** 155 | [Deep paging results](https://github.com/CrossRef/rest-api-doc#deep-paging-with-cursors) 156 | Deep paging is supported for all queries, that return a list of `Work`, `WorkList`. 157 | This function returns a new iterator over pages of `Work`, which is returned as bulk of items as a `WorkList` by crossref. 158 | Usually a single page `WorkList` contains 20 items. 159 | 160 | Example 161 | 162 | Iterate over all `Works` linked to search term `Machine Learning` 163 | 164 | ```rust 165 | use crossref::{Crossref, WorksQuery, Work}; 166 | fn run() -> Result<(), crossref::Error> { 167 | let client = Crossref::builder().build()?; 168 | 169 | let all_works: Vec = client.deep_page(WorksQuery::new("Machine Learning")).flat_map(|x|x.items).collect(); 170 | 171 | Ok(()) 172 | } 173 | ``` 174 | 175 | Which can be simplified to 176 | ```rust 177 | use crossref::{Crossref, WorksQuery, Work}; 178 | fn run() -> Result<(), crossref::Error> { 179 | let client = Crossref::builder().build()?; 180 | 181 | let all_works: Vec = client.deep_page("Machine Learning").into_work_iter().collect(); 182 | 183 | Ok(()) 184 | } 185 | ``` 186 | 187 | 188 | Iterate over all the pages (`WorkList`) of the funder with id `funder id` by using a combined query. 189 | A single `WorkList` usually holds 20 `Work` items. 190 | 191 | ```rust 192 | use crossref::{Crossref, Funders, WorksQuery, Work, WorkList}; 193 | fn run() -> Result<(), crossref::Error> { 194 | let client = Crossref::builder().build()?; 195 | 196 | let all_funder_work_list: Vec = client.deep_page(WorksQuery::default() 197 | .into_combined_query::("funder id") 198 | ) 199 | .collect(); 200 | 201 | Ok(()) 202 | } 203 | ``` 204 | 205 | Iterate over all `Work` items of a specfic funder directly. 206 | 207 | ```rust 208 | use crossref::{Crossref, Funders, WorksQuery, Work, WorkList}; 209 | fn run() -> Result<(), crossref::Error> { 210 | let client = Crossref::builder().build()?; 211 | 212 | let all_works: Vec = client.deep_page(WorksQuery::default() 213 | .into_combined_query::("funder id")) 214 | .into_work_iter() 215 | .collect(); 216 | 217 | Ok(()) 218 | } 219 | ``` 220 | 221 | 222 | ## Command Line Application 223 | 224 | ### Installation 225 | ```shell 226 | cargo install crossref --features cli 227 | ``` 228 | 229 | ### Usage 230 | 231 | Top level subcommands 232 | ```text 233 | USAGE: 234 | crossref 235 | 236 | FLAGS: 237 | -h, --help Prints help information 238 | -V, --version Prints version information 239 | 240 | SUBCOMMANDS: 241 | funders Query crossref funders 242 | help Prints this message or the help of the given subcommand(s) 243 | journals Query crossref journals 244 | members Query crossref members 245 | prefixes Query crossref prefixes 246 | types Query crossref types 247 | works Query crossref works 248 | 249 | ``` 250 | 251 | Additional options for the component subcommands (querying, sorting, ordering and limiting is only supported for subcommands and is overridden by a present `--id` options) 252 | 253 | ```text 254 | USAGE: 255 | crossref works [FLAGS] [OPTIONS] [SUBCOMMAND] 256 | 257 | FLAGS: 258 | -a, --append if the output file already exists, append instead of overwriting the file 259 | -d, --deep-page Enable deep paging. If a limit is set, then the limit takes priority. 260 | -h, --help Prints help information 261 | -s, --silent do not print anything 262 | -V, --version Prints version information 263 | 264 | OPTIONS: 265 | -i, --id The id of component. 266 | -l, --limit limit the amount of results 267 | --offset Sets an offset where crossref begins to retrieve items. 268 | --order How to order the results: asc or desc 269 | -o output path where the results shall be stored 270 | --polite The email to use to get into crossref's polite pool 271 | -q, --query ... The free form terms for the query 272 | --sample Request randoms Elements. Overrides all other options. 273 | --sort How to sort the results, such as updated, indexed, published, issued 274 | --token The token to use for the crossref client 275 | --user-agent The user agent to use for the crossref client 276 | ``` 277 | 278 | ### Examples 279 | 280 | Retrieve a specific work by a doi 281 | 282 | ```shell 283 | crossref works --id "10.1037/0003-066X.59.1.29" 284 | ``` 285 | 286 | Save the results as json 287 | 288 | ```shell 289 | crossref works --id "10.1037/0003-066X.59.1.29" -o output.json 290 | ``` 291 | 292 | Retrieve any other components by their ids 293 | 294 | ```shell 295 | crossref --id "10.1037/0003-066X.59.1.29" -o output.json 296 | ``` 297 | 298 | Some components support additional filtering 299 | 300 | ``` 301 | crossref --query "A search term such as `Machine learning` for works" --limit 10 --offset 200 --order asc 302 | ``` 303 | 304 | Get `Works` of a specific component, such as a member with the id `98`: 305 | 306 | ``` 307 | crossref works member 98 308 | ``` 309 | 310 | ` { 7 | $query.queries = $opts.query_terms.clone(); 8 | $query.sort = $opts.sort.clone(); 9 | $query.order = $opts.order.clone(); 10 | if let Some(offset) = $opts.offset { 11 | if let Some(rows) = $opts.limit { 12 | $query.result_control = Some(ResultControl::RowsOffset { rows, offset }) 13 | } else { 14 | $query.result_control = Some(ResultControl::Offset(offset)) 15 | } 16 | } 17 | if let Some(limit) = $opts.limit { 18 | $query.result_control = Some(ResultControl::Rows(limit)) 19 | } 20 | if let Some(sample) = $opts.sample { 21 | $query.result_control = Some(ResultControl::Sample(sample)) 22 | } 23 | }; 24 | } 25 | 26 | #[derive(Debug, StructOpt)] 27 | #[structopt( 28 | name = "crossref", 29 | about = "Access the crossref API from the command line." 30 | )] 31 | #[structopt(raw(setting = "structopt::clap::AppSettings::ColoredHelp"))] 32 | enum App { 33 | #[structopt(name = "works", about = "Query crossref works")] 34 | Works { 35 | #[structopt( 36 | short = "d", 37 | long = "deep-page", 38 | help = "Enable deep paging. If a limit is set, then the limit takes priority." 39 | )] 40 | deep_page: bool, 41 | #[structopt(flatten)] 42 | opts: Opts, 43 | #[structopt(subcommand)] 44 | combined: Option, 45 | }, 46 | #[structopt(name = "funders", about = "Query crossref funders")] 47 | Funders { 48 | #[structopt(flatten)] 49 | opts: Opts, 50 | }, 51 | #[structopt(name = "members", about = "Query crossref members")] 52 | Members { 53 | #[structopt(flatten)] 54 | opts: Opts, 55 | }, 56 | #[structopt(name = "journals", about = "Query crossref journals")] 57 | Journals { 58 | #[structopt(long = "id", help = "The id of component.")] 59 | id: String, 60 | #[structopt(flatten)] 61 | client_opts: ClientOpts, 62 | #[structopt(flatten)] 63 | out: Out, 64 | }, 65 | #[structopt(name = "prefixes", about = "Query crossref prefixes")] 66 | Prefixes { 67 | #[structopt(long = "id", help = "The id of component.")] 68 | id: String, 69 | #[structopt(flatten)] 70 | client_opts: ClientOpts, 71 | #[structopt(flatten)] 72 | out: Out, 73 | }, 74 | #[structopt(name = "types", about = "Query crossref types")] 75 | Types { 76 | #[structopt(parse(try_from_str), long = "id", help = "The id of component.")] 77 | id: Option, 78 | #[structopt(flatten)] 79 | client_opts: ClientOpts, 80 | #[structopt(flatten)] 81 | out: Out, 82 | }, 83 | } 84 | 85 | impl App { 86 | pub fn client_opts(&self) -> &ClientOpts { 87 | match self { 88 | App::Works { opts, .. } | App::Funders { opts, .. } | App::Members { opts, .. } => { 89 | &opts.client_opts 90 | } 91 | 92 | App::Prefixes { client_opts, .. } 93 | | App::Types { client_opts, .. } 94 | | App::Journals { client_opts, .. } => client_opts, 95 | } 96 | } 97 | 98 | pub fn out(&self) -> &Out { 99 | match self { 100 | App::Works { opts, .. } | App::Funders { opts, .. } | App::Members { opts, .. } => { 101 | &opts.out 102 | } 103 | 104 | App::Prefixes { out, .. } | App::Types { out, .. } | App::Journals { out, .. } => out, 105 | } 106 | } 107 | 108 | pub fn get_value(&self, writer: W, client: &Crossref) -> crossref::Result<()> 109 | where 110 | W: std::io::Write, 111 | { 112 | match self { 113 | App::Types { id, .. } => { 114 | if let Some(id) = id { 115 | Ok(serde_json::to_writer_pretty(writer, &client.type_(id)?)?) 116 | } else { 117 | Ok(serde_json::to_writer_pretty(writer, &client.types()?)?) 118 | } 119 | } 120 | App::Prefixes { id, .. } => Ok(serde_json::to_writer_pretty( 121 | writer, 122 | &client.prefix(id.as_str())?, 123 | )?), 124 | App::Journals { id, .. } => Ok(serde_json::to_writer_pretty( 125 | writer, 126 | &client.journal(id.as_str())?, 127 | )?), 128 | App::Members { opts, .. } => { 129 | if let Some(id) = &opts.id { 130 | Ok(serde_json::to_writer_pretty( 131 | writer, 132 | &client.member(id.as_str())?, 133 | )?) 134 | } else { 135 | let mut query = MembersQuery::default(); 136 | query!(query, opts); 137 | Ok(serde_json::to_writer_pretty( 138 | writer, 139 | &client.members(query)?, 140 | )?) 141 | } 142 | } 143 | App::Funders { opts, .. } => { 144 | if let Some(id) = &opts.id { 145 | Ok(serde_json::to_writer_pretty( 146 | writer, 147 | &client.funder(id.as_str())?, 148 | )?) 149 | } else { 150 | let mut query = FundersQuery::default(); 151 | query!(query, opts); 152 | Ok(serde_json::to_writer_pretty( 153 | writer, 154 | &client.funders(query)?, 155 | )?) 156 | } 157 | } 158 | App::Works { 159 | opts, 160 | combined, 161 | deep_page, 162 | } => { 163 | if let Some(id) = &opts.id { 164 | Ok(serde_json::to_writer_pretty( 165 | writer, 166 | &client.work(id.as_str())?, 167 | )?) 168 | } else { 169 | let mut query = WorksQuery::default(); 170 | query.free_form_queries = opts.query_terms.clone(); 171 | query.sort = opts.sort.clone(); 172 | query.order = opts.order.clone(); 173 | if let Some(offset) = opts.offset { 174 | if let Some(rows) = opts.limit { 175 | query.result_control = 176 | Some(WorkResultControl::Standard(ResultControl::RowsOffset { 177 | rows, 178 | offset, 179 | })) 180 | } else { 181 | query.result_control = 182 | Some(WorkResultControl::Standard(ResultControl::Offset(offset))) 183 | } 184 | } 185 | if let Some(limit) = opts.limit { 186 | query.result_control = 187 | Some(WorkResultControl::Standard(ResultControl::Rows(limit))) 188 | } 189 | if let Some(sample) = opts.sample { 190 | query.result_control = 191 | Some(WorkResultControl::Standard(ResultControl::Sample(sample))) 192 | } 193 | 194 | if let Some(combined) = combined { 195 | let query = match combined { 196 | Combined::Journal { id, .. } => { 197 | query.into_combined_query::(id.as_str()) 198 | } 199 | Combined::Type { id, .. } => { 200 | query.into_combined_query::(id.as_str()) 201 | } 202 | Combined::Funder { id, .. } => { 203 | query.into_combined_query::(id.as_str()) 204 | } 205 | Combined::Member { id, .. } => { 206 | query.into_combined_query::(id.as_str()) 207 | } 208 | Combined::Prefix { id, .. } => { 209 | query.into_combined_query::(id.as_str()) 210 | } 211 | }; 212 | 213 | if *deep_page { 214 | Ok(serde_json::to_writer_pretty( 215 | writer, 216 | &client.deep_page(query).into_work_iter().collect::>(), 217 | )?) 218 | } else { 219 | Ok(serde_json::to_writer_pretty(writer, &client.works(query)?)?) 220 | } 221 | } else { 222 | Ok(serde_json::to_writer_pretty(writer, &client.works(query)?)?) 223 | } 224 | } 225 | } 226 | } 227 | } 228 | } 229 | 230 | #[derive(Debug, StructOpt)] 231 | enum Combined { 232 | #[structopt(name = "member", about = "Get Works of a specific Member")] 233 | Member { id: String }, 234 | #[structopt(name = "funder", about = "Get Works of a specific Funder")] 235 | Funder { id: String }, 236 | #[structopt(name = "journal", about = "Get Works of a specific Journal")] 237 | Journal { id: String }, 238 | #[structopt(name = "prefix", about = "Get Works of a specific Prefix")] 239 | Prefix { id: String }, 240 | #[structopt(name = "type", about = "Get Works of a specific Type")] 241 | Type { id: String }, 242 | } 243 | 244 | #[derive(Debug, StructOpt)] 245 | struct Out { 246 | #[structopt( 247 | short = "o", 248 | parse(from_os_str), 249 | help = "output path where the results shall be stored" 250 | )] 251 | output: Option, 252 | #[structopt( 253 | short = "a", 254 | long = "append", 255 | help = "if the output file already exists, append instead of overwriting the file" 256 | )] 257 | append: bool, 258 | #[structopt(short = "s", long = "silent", help = "do not print anything")] 259 | silent: bool, 260 | } 261 | 262 | #[derive(Debug, StructOpt)] 263 | struct ClientOpts { 264 | #[structopt( 265 | long = "user-agent", 266 | help = "The user agent to use for the crossref client" 267 | )] 268 | user_agent: Option, 269 | #[structopt(long = "token", help = "The token to use for the crossref client")] 270 | token: Option, 271 | #[structopt( 272 | long = "polite", 273 | help = "The email to use to get into crossref's polite pool" 274 | )] 275 | polite: Option, 276 | } 277 | 278 | impl ClientOpts { 279 | pub fn create_client(&self) -> Result { 280 | let mut builder = Crossref::builder(); 281 | 282 | if let Some(agent) = &self.user_agent { 283 | builder = builder.user_agent(agent.as_str()); 284 | } 285 | if let Some(token) = &self.token { 286 | builder = builder.token(token.as_str()); 287 | } 288 | if let Some(polite) = &self.polite { 289 | builder = builder.polite(polite.as_str()); 290 | } 291 | builder.build() 292 | } 293 | } 294 | 295 | #[derive(Debug, StructOpt)] 296 | struct Opts { 297 | #[structopt(flatten)] 298 | out: Out, 299 | #[structopt(short = "l", long = "limit", help = "limit the amount of results")] 300 | limit: Option, 301 | 302 | #[structopt(short = "i", long = "id", help = "The id of component.")] 303 | id: Option, 304 | 305 | #[structopt( 306 | short = "q", 307 | long = "query", 308 | help = "The free form terms for the query" 309 | )] 310 | query_terms: Vec, 311 | 312 | #[structopt( 313 | long = "sort", 314 | help = "How to sort the results, such as updated, indexed, published, issued" 315 | )] 316 | sort: Option, 317 | 318 | #[structopt(long = "order", help = "How to order the results: asc or desc")] 319 | order: Option, 320 | #[structopt( 321 | long = "sample", 322 | help = "Request randoms Elements. Overrides all other options." 323 | )] 324 | sample: Option, 325 | #[structopt( 326 | long = "offset", 327 | help = "Sets an offset where crossref begins to retrieve items." 328 | )] 329 | offset: Option, 330 | 331 | #[structopt(flatten)] 332 | client_opts: ClientOpts, 333 | } 334 | 335 | fn main() -> Result<(), failure::Error> { 336 | pretty_env_logger::try_init()?; 337 | let app = App::from_args(); 338 | 339 | let client = app.client_opts().create_client()?; 340 | 341 | let out = app.out(); 342 | if let Some(path) = &out.output { 343 | let file = if out.append && path.exists() { 344 | fs::OpenOptions::new().write(true).append(true).open(path)? 345 | } else { 346 | fs::File::create(path)? 347 | }; 348 | app.get_value(file, &client)? 349 | } else { 350 | app.get_value(std::io::stdout(), &client)? 351 | } 352 | Ok(()) 353 | } 354 | -------------------------------------------------------------------------------- /src/response/work.rs: -------------------------------------------------------------------------------- 1 | // see https://github.com/Crossref/rest-api-doc/blob/master/api_format.md 2 | 3 | use crate::error::Result; 4 | use crate::response::{FacetMap, QueryResponse}; 5 | use crate::{Crossref, WorkListQuery, WorksQuery}; 6 | use chrono::NaiveDate; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | 10 | /// A hashmap containing relation name, `Relation` pairs. 11 | /// [crossref rest-api-doc](https://github.com/CrossRef/rest-api-doc/blob/master/api_format.md#relations) 12 | /// However it seems, that the value of the relation name can also be an array. 13 | /// Therefor the `serde_json::Value` type is used instead to prevent an invalid length error 14 | pub type Relations = std::collections::HashMap; 15 | 16 | #[derive(Debug, Clone, Deserialize, Serialize)] 17 | #[serde(rename_all = "kebab-case")] 18 | #[allow(missing_docs)] 19 | pub struct WorkList { 20 | pub facets: FacetMap, 21 | /// the number of items that match the response 22 | pub total_results: usize, 23 | /// crossref responses for large number of items are divided in pages, number of elements to expect in `items` 24 | pub items_per_page: Option, 25 | /// if a query was set in the request, this will also be part in the response 26 | pub query: Option, 27 | /// all work items that are returned 28 | pub items: Vec, 29 | /// deep page through `/works` result sets 30 | pub next_cursor: Option, 31 | } 32 | 33 | /// the main return type of the crossref api 34 | /// represents a publication 35 | /// based on the [crossref rest-api-doc](https://github.com/CrossRef/rest-api-doc/blob/master/api_format.md#work) 36 | /// with minor adjustments 37 | #[derive(Debug, Clone, Deserialize, Serialize)] 38 | #[serde(rename_all = "kebab-case")] 39 | #[allow(missing_docs)] 40 | pub struct Work { 41 | /// Name of work's publisher 42 | pub publisher: String, 43 | /// Work titles, including translated titles 44 | pub title: Vec, 45 | /// Work titles in the work's original publication language 46 | pub original_title: Option>, 47 | /// the language of this work 48 | pub language: Option, 49 | /// Abstract as a JSON string or a JATS XML snippet encoded into a JSON string 50 | pub short_title: Option>, 51 | /// Abstract as a JSON string or a JATS XML snippet encoded into a JSON string 52 | #[serde(rename = "abstract")] 53 | pub abstract_: Option, 54 | /// Count of outbound references deposited with Crossref 55 | pub references_count: i32, 56 | /// Count of inbound references deposited with Crossref 57 | pub is_referenced_by_count: i32, 58 | /// Currently always `Crossref` 59 | pub source: String, 60 | pub journal_issue: Option, 61 | /// DOI prefix identifier of the form `http://id.crossref.org/prefix/DOI_PREFIX` 62 | pub prefix: String, 63 | /// DOI of the work 64 | #[serde(rename = "DOI")] 65 | pub doi: String, 66 | /// URL form of the work's DOI 67 | #[serde(rename = "URL")] 68 | pub url: String, 69 | /// Member identifier of the form `http://id.crossref.org/member/MEMBER_ID` 70 | pub member: String, 71 | /// Enumeration, one of the type ids from `https://api.crossref.org/v1/types` 72 | #[serde(rename = "type")] 73 | pub type_: String, 74 | /// the day this work entry was created 75 | pub created: Option, 76 | /// Date on which the DOI was first registered 77 | pub date: Option, 78 | /// Date on which the work metadata was most recently updated 79 | pub deposited: Option, 80 | /// the score of the publication if any 81 | /// not included in the crossrif api spec 82 | pub score: Option, 83 | /// Date on which the work metadata was most recently indexed. 84 | /// Re-indexing does not imply a metadata change, see `deposited` for the most recent metadata change date 85 | pub indexed: Date, 86 | /// Earliest of `published-print` and `published-online` 87 | pub issued: PartialDate, 88 | /// ate on which posted content was made available online 89 | pub posted: Option, 90 | /// Date on which a work was accepted, after being submitted, during a submission process 91 | pub accepted: Option, 92 | /// Work subtitles, including original language and translated 93 | pub subtitle: Option>, 94 | /// Full titles of the containing work (usually a book or journal) 95 | pub container_title: Option>, 96 | /// Abbreviated titles of the containing work 97 | pub short_container_title: Option>, 98 | /// Group title for posted content 99 | pub group_title: Option, 100 | /// Issue number of an article's journal 101 | pub issue: Option, 102 | /// Volume number of an article's journal 103 | pub volume: Option, 104 | /// Pages numbers of an article within its journal 105 | pub page: Option, 106 | /// the number of the corresponding article 107 | pub article_number: Option, 108 | /// Date on which the work was published in print 109 | pub published_print: Option, 110 | /// Date on which the work was published online 111 | pub published_online: Option, 112 | /// Subject category names, a controlled vocabulary from Sci-Val. 113 | /// Available for most journal articles 114 | pub subject: Option>, 115 | #[serde(rename = "ISSN")] 116 | pub issn: Option>, 117 | /// List of ISSNs with ISSN type information 118 | pub issn_type: Option>, 119 | #[serde(rename = "ISBN")] 120 | pub isbn: Option>, 121 | pub archive: Option>, 122 | pub license: Option>, 123 | pub funder: Option>, 124 | pub assertion: Option>, 125 | pub author: Option>, 126 | pub editor: Option>, 127 | pub chair: Option>, 128 | pub translator: Option>, 129 | pub update_to: Option>, 130 | /// Link to an update policy covering Crossmark updates for this work 131 | pub update_policy: Option, 132 | /// URLs to full-text locations 133 | pub link: Option>, 134 | pub clinical_trial_number: Option>, 135 | /// Other identifiers for the work provided by the depositing member 136 | pub alternative_id: Option>, 137 | /// List of references made by the work 138 | pub reference: Option>, 139 | /// Information on domains that support Crossmark for this work 140 | pub content_domain: Option, 141 | /// Relations to other works 142 | pub relation: Option, 143 | /// Peer review metadata 144 | pub review: Option, 145 | } 146 | 147 | /// Helper struct to represent dates in the cross ref api as nested arrays of numbers 148 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 149 | pub struct DateParts(pub Vec>>); 150 | 151 | impl DateParts { 152 | /// converts the nested array of numbers into the corresponding [DateField] 153 | /// standalone years are allowed. 154 | /// if an array is empty, [None] will be returned 155 | pub fn as_date(&self) -> Option { 156 | /// converts an array of numbers into chrono [NaiveDate] if it contains at least a single value 157 | fn naive(v: &[Option]) -> Option { 158 | match v.len() { 159 | 0 => None, 160 | 1 => Some(NaiveDate::from_ymd(v[0]? as i32, 0, 0)), 161 | 2 => Some(NaiveDate::from_ymd(v[0]? as i32, v[1]?, 0)), 162 | 3 => Some(NaiveDate::from_ymd(v[0]? as i32, v[1]?, v[2]?)), 163 | _ => None, 164 | } 165 | } 166 | 167 | match self.0.len() { 168 | 0 => None, 169 | 1 => Some(DateField::Single(naive(&self.0[0])?)), 170 | 2 => Some(DateField::Range { 171 | from: naive(&self.0[0])?, 172 | to: naive(&self.0[1])?, 173 | }), 174 | _ => Some(DateField::Multi( 175 | self.0 176 | .iter() 177 | .map(|x| naive(x)) 178 | .collect::>>()?, 179 | )), 180 | } 181 | } 182 | } 183 | 184 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 185 | #[allow(missing_docs)] 186 | pub struct FundingBody { 187 | /// Funding body primary name 188 | pub name: String, 189 | /// Optional [Open Funder Registry](http://www.crossref.org/fundingdata/registry.html) DOI uniquely identifing the funding body 190 | #[serde(rename = "DOI")] 191 | pub doi: Option, 192 | /// Award number(s) for awards given by the funding body 193 | pub award: Option>, 194 | /// Either `crossref` or `publisher` 195 | #[serde(rename = "doi-asserted-by")] 196 | pub doi_asserted_by: Option, 197 | } 198 | 199 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 200 | #[allow(missing_docs)] 201 | pub struct ClinicalTrialNumber { 202 | /// Identifier of the clinical trial 203 | #[serde(rename = "clinical-trial-number")] 204 | pub clinical_trial_number: String, 205 | /// DOI of the clinical trial regsitry that assigned the trial number 206 | pub registry: String, 207 | /// One of `preResults`, `results` or `postResults` 208 | #[serde(rename = "type")] 209 | pub type_: Option, 210 | } 211 | 212 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 213 | #[allow(missing_docs)] 214 | pub struct Contributor { 215 | pub family: String, 216 | pub given: Option, 217 | /// URL-form of an [ORCID](http://orcid.org) identifier 218 | #[serde(rename = "ORCID")] 219 | pub orcid: Option, 220 | /// If true, record owner asserts that the ORCID user completed ORCID OAuth authentication 221 | #[serde(rename = "authenticated-orcid")] 222 | pub authenticated_orcid: Option, 223 | pub affiliation: Option>, 224 | } 225 | 226 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 227 | #[allow(missing_docs)] 228 | pub struct Affiliation { 229 | /// the affiliation's name 230 | pub name: String, 231 | } 232 | 233 | /// represents full date information for an item 234 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 235 | #[serde(rename_all = "kebab-case")] 236 | pub struct Date { 237 | /// Contains an ordered array of year, month, day of month. 238 | /// Only year is required. Note that the field contains a nested array, 239 | /// e.g. [ [ 2006, 5, 19 ] ] to conform to citeproc JSON dates 240 | pub date_parts: DateParts, 241 | /// Seconds since UNIX epoch 242 | pub timestamp: usize, 243 | /// ISO 8601 date time 244 | pub date_time: String, 245 | } 246 | 247 | impl Date { 248 | /// converts the nested array of numbers into the correct representation of chrono [NaiveDate] 249 | pub fn as_date_field(&self) -> Option { 250 | self.date_parts.as_date() 251 | } 252 | } 253 | 254 | /// represents an incomplete date only consisting of year or year and month 255 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 256 | pub struct PartialDate { 257 | /// Contains an ordered array of year, month, day of month. 258 | /// Only year is required 259 | /// e.g. `[ [`2006`] ]` to conform to citeproc JSON dates 260 | #[serde(rename = "date-parts")] 261 | pub date_parts: DateParts, 262 | } 263 | 264 | impl PartialDate { 265 | /// converts the nested array of numbers into the correct representation of chrono [NaiveDate] 266 | pub fn as_date_field(&self) -> Option { 267 | self.date_parts.as_date() 268 | } 269 | } 270 | 271 | /// Helper struct to capture all possible occurrences of dates in the crossref api, a nested Vec of numbers 272 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 273 | pub enum DateField { 274 | /// only a single date vector 275 | Single(NaiveDate), 276 | /// two date vectors represent a range 277 | Range { 278 | /// start date of the range 279 | from: NaiveDate, 280 | /// end date of the range 281 | to: NaiveDate, 282 | }, 283 | /// more than two date vectors are present 284 | Multi(Vec), 285 | } 286 | 287 | /// metadata about when the `Work` entry was updated 288 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 289 | pub struct Update { 290 | /// Date on which the update was published 291 | pub updated: PartialDate, 292 | /// DOI of the updated work 293 | #[serde(rename = "DOI")] 294 | pub doi: String, 295 | /// The type of update, for example retraction or correction 296 | #[serde(rename = "type")] 297 | pub type_: String, 298 | /// A display-friendly label for the update type 299 | pub label: Option, 300 | } 301 | 302 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 303 | #[allow(missing_docs)] 304 | pub struct Assertion { 305 | pub name: String, 306 | pub value: String, 307 | #[serde(rename = "URL")] 308 | pub url: Option, 309 | pub explanation: Option, 310 | pub label: Option, 311 | pub order: Option, 312 | pub group: Option, 313 | } 314 | 315 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 316 | #[serde(rename_all = "kebab-case")] 317 | #[allow(missing_docs)] 318 | pub struct Issue { 319 | /// Date on which the work was published in print 320 | pub published_print: Option, 321 | /// Date on which the work was published online 322 | pub published_online: Option, 323 | /// Issue number of an article's journal 324 | pub issue: Option, 325 | } 326 | 327 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 328 | #[allow(missing_docs)] 329 | pub struct AssertionGroup { 330 | pub name: String, 331 | pub label: Option, 332 | } 333 | 334 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 335 | #[allow(missing_docs)] 336 | pub struct Agency { 337 | pub id: String, 338 | pub label: Option, 339 | } 340 | 341 | /// how the `Work` is licensed 342 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 343 | #[serde(rename_all = "kebab-case")] 344 | pub struct License { 345 | /// Either `vor` (version of record,) `am` (accepted manuscript) or `unspecified` 346 | pub content_version: String, 347 | /// Number of days between the publication date of the work and the start date of this license 348 | pub delay_in_days: i32, 349 | /// Date on which this license begins to take effect 350 | pub start: PartialDate, 351 | /// Link to a web page describing this license 352 | #[serde(rename = "URL")] 353 | pub url: String, 354 | } 355 | 356 | /// metadata about a related resource 357 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 358 | #[serde(rename_all = "kebab-case")] 359 | pub struct ResourceLink { 360 | /// Either `text-mining`, `similarity-checking` or `unspecified` 361 | pub intended_application: String, 362 | /// Either `vor` (version of record,) `am` (accepted manuscript) or `unspecified` 363 | pub content_version: String, 364 | /// Direct link to a full-text download location 365 | #[serde(rename = "URL")] 366 | pub url: String, 367 | /// Content type (or MIME type) of the full-text object 368 | pub content_type: Option, 369 | } 370 | 371 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 372 | #[serde(rename_all = "kebab-case")] 373 | #[allow(missing_docs)] 374 | pub struct Reference { 375 | pub key: String, 376 | #[serde(rename = "DOI")] 377 | pub doi: Option, 378 | /// One of `crossref` or `publisher` 379 | pub doi_asserted_by: Option, 380 | pub issue: Option, 381 | pub first_page: Option, 382 | pub volume: Option, 383 | pub edition: Option, 384 | pub component: Option, 385 | pub standard_designator: Option, 386 | pub standards_body: Option, 387 | pub author: Option, 388 | pub year: Option, 389 | pub unstructured: Option, 390 | pub journal_title: Option, 391 | pub article_title: Option, 392 | pub series_title: Option, 393 | pub volume_title: Option, 394 | #[serde(rename = "ISSN")] 395 | pub issn: Option, 396 | /// One of `pissn` or `eissn` 397 | pub issn_type: Option, 398 | #[serde(rename = "ISBN")] 399 | pub isbn: Option, 400 | pub isbn_type: Option, 401 | } 402 | 403 | /// ISSN info for the `Work` 404 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 405 | #[serde(rename_all = "kebab-case")] 406 | pub struct ISSN { 407 | /// identifier 408 | pub value: String, 409 | /// One of `eissn`, `pissn` or `lissn` 410 | #[serde(rename = "type")] 411 | pub type_: String, 412 | } 413 | 414 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 415 | #[serde(rename_all = "kebab-case")] 416 | #[allow(missing_docs)] 417 | pub struct ContentDomain { 418 | pub domain: Vec, 419 | pub crossmark_restriction: bool, 420 | } 421 | 422 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 423 | #[serde(rename_all = "kebab-case")] 424 | #[allow(missing_docs)] 425 | pub struct Relation { 426 | pub id_type: Option, 427 | pub id: Option, 428 | pub asserted_by: Option, 429 | } 430 | 431 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 432 | #[serde(rename_all = "kebab-case")] 433 | #[allow(missing_docs)] 434 | pub struct Review { 435 | pub running_number: Option, 436 | pub revision_round: Option, 437 | /// One of `pre-publication` or `post-publication` 438 | pub stage: Option, 439 | /// One of `major-revision` or `minor-revision` or `reject` or `reject-with-resubmit` or `accept` 440 | pub recommendation: Option, 441 | /// One of `referee-report` or `editor-report` or `author-comment` or `community-comment` or `aggregate` 442 | #[serde(rename = "type")] 443 | pub type_: String, 444 | pub competing_interest_statement: Option, 445 | pub language: Option, 446 | } 447 | 448 | #[cfg(test)] 449 | mod tests { 450 | use super::*; 451 | use serde_json::*; 452 | #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] 453 | struct Demo { 454 | pub date_parts: DateParts, 455 | } 456 | #[test] 457 | fn date_parts_serde() { 458 | let demo = Demo { 459 | date_parts: DateParts(vec![vec![Some(2017), Some(10), Some(11)]]), 460 | }; 461 | let expected = r##"{"date_parts":[[2017,10,11]]}"##; 462 | assert_eq!(expected, &to_string(&demo).unwrap()); 463 | assert_eq!(demo, from_str::(expected).unwrap()); 464 | } 465 | 466 | #[test] 467 | fn serialize_work() { 468 | let work_str = r##"{ 469 | "indexed": { 470 | "date-parts": [ 471 | [ 472 | 2019, 473 | 2, 474 | 26 475 | ] 476 | ], 477 | "date-time": "2019-02-26T10:43:14Z", 478 | "timestamp": 1551177794515 479 | }, 480 | "reference-count": 105, 481 | "publisher": "American Psychological Association (APA)", 482 | "issue": "1", 483 | "content-domain": { 484 | "domain": [], 485 | "crossmark-restriction": false 486 | }, 487 | "short-container-title": [ 488 | "American Psychologist" 489 | ], 490 | "DOI": "10.1037/0003-066x.59.1.29", 491 | "type": "journal-article", 492 | "created": { 493 | "date-parts": [ 494 | [ 495 | 2004, 496 | 1, 497 | 21 498 | ] 499 | ], 500 | "date-time": "2004-01-21T14:31:19Z", 501 | "timestamp": 1074695479000 502 | }, 503 | "page": "29-40", 504 | "source": "Crossref", 505 | "is-referenced-by-count": 84, 506 | "title": [ 507 | "How the Mind Hurts and Heals the Body." 508 | ], 509 | "prefix": "10.1037", 510 | "volume": "59", 511 | "author": [ 512 | { 513 | "given": "Oakley", 514 | "family": "Ray", 515 | "sequence": "first", 516 | "affiliation": [] 517 | } 518 | ], 519 | "member": "15", 520 | "published-online": { 521 | "date-parts": [ 522 | [ 523 | 2004 524 | ] 525 | ] 526 | }, 527 | "container-title": [ 528 | "American Psychologist" 529 | ], 530 | "original-title": [], 531 | "language": "en", 532 | "link": [ 533 | { 534 | "URL": "http://psycnet.apa.org/journals/amp/59/1/29.pdf", 535 | "content-type": "unspecified", 536 | "content-version": "vor", 537 | "intended-application": "similarity-checking" 538 | } 539 | ], 540 | "deposited": { 541 | "date-parts": [ 542 | [ 543 | 2018, 544 | 4, 545 | 8 546 | ] 547 | ], 548 | "date-time": "2018-04-08T18:56:17Z", 549 | "timestamp": 1523213777000 550 | }, 551 | "score": 1, 552 | "subtitle": [], 553 | "short-title": [], 554 | "issued": { 555 | "date-parts": [ 556 | [ 557 | null 558 | ] 559 | ] 560 | }, 561 | "references-count": 105, 562 | "journal-issue": { 563 | "published-online": { 564 | "date-parts": [ 565 | [ 566 | 2004 567 | ] 568 | ] 569 | }, 570 | "issue": "1" 571 | }, 572 | "alternative-id": [ 573 | "2004-10043-004", 574 | "14736318" 575 | ], 576 | "URL": "http://dx.doi.org/10.1037/0003-066x.59.1.29", 577 | "relation": {}, 578 | "ISSN": [ 579 | "1935-990X", 580 | "0003-066X" 581 | ], 582 | "issn-type": [ 583 | { 584 | "value": "0003-066X", 585 | "type": "print" 586 | }, 587 | { 588 | "value": "1935-990X", 589 | "type": "electronic" 590 | } 591 | ] 592 | } 593 | "##; 594 | 595 | let work: Work = from_str(work_str).unwrap(); 596 | } 597 | 598 | } 599 | -------------------------------------------------------------------------------- /src/query/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::query::facet::FacetCount; 3 | pub use crate::query::funders::{Funders, FundersQuery}; 4 | pub use crate::query::journals::Journals; 5 | pub use crate::query::members::{Members, MembersQuery}; 6 | pub use crate::query::prefixes::Prefixes; 7 | pub use crate::query::types::{Type, Types}; 8 | use crate::query::works::{Works, WorksFilter}; 9 | pub use crate::query::works::{WorksIdentQuery, WorksQuery}; 10 | use chrono::NaiveDate; 11 | use core::fmt::Debug; 12 | use serde::{Deserialize, Serialize}; 13 | use std::borrow::Cow; 14 | use std::fmt; 15 | use std::str::FromStr; 16 | #[cfg(feature = "cli")] 17 | use structopt::StructOpt; 18 | 19 | /// Helper trait for unified interface 20 | pub trait CrossrefParams { 21 | /// the filter applied 22 | type Filter: Filter; 23 | /// all string queries 24 | fn query_terms(&self) -> &[String]; 25 | /// the filters this object can use 26 | fn filters(&self) -> &[Self::Filter]; 27 | /// the sort if set 28 | fn sort(&self) -> Option<&Sort>; 29 | /// the order if set 30 | fn order(&self) -> Option<&Order>; 31 | /// all facets this objects addresses 32 | fn facets(&self) -> &[FacetCount]; 33 | /// the configured result control, if any 34 | fn result_control(&self) -> Option<&ResultControl>; 35 | } 36 | 37 | macro_rules! impl_common_query { 38 | ($i:ident, $filter:ident) => { 39 | /// Each query parameter is ANDed 40 | #[derive(Debug, Clone, Default)] 41 | pub struct $i { 42 | /// search by non specific query 43 | pub queries: Vec, 44 | /// filter to apply while querying 45 | pub filter: Vec<$filter>, 46 | /// sort results by a certain field and 47 | pub sort: Option, 48 | /// set the sort order to `asc` or `desc` 49 | pub order: Option, 50 | /// enable facet information in responses 51 | pub facets: Vec, 52 | /// deep page through `/works` result sets 53 | pub result_control: Option, 54 | } 55 | 56 | impl $i { 57 | /// alias for creating an empty default element 58 | pub fn empty() -> Self { 59 | $i::default() 60 | } 61 | 62 | /// Convenience method to create a new query with a term directly 63 | pub fn new(query: T) -> Self { 64 | Self::empty().query(query) 65 | } 66 | 67 | /// add a new free form query 68 | pub fn query(mut self, query: T) -> Self { 69 | self.queries.push(query.to_string()); 70 | self 71 | } 72 | 73 | /// add a new filter to the query 74 | pub fn filter(mut self, filter: $filter) -> Self { 75 | self.filter.push(filter); 76 | self 77 | } 78 | 79 | /// set sort option to the query 80 | pub fn sort(mut self, sort: Sort) -> Self { 81 | self.sort = Some(sort); 82 | self 83 | } 84 | 85 | /// set order to asc 86 | pub fn order_asc(mut self) -> Self { 87 | self.order = Some(Order::Asc); 88 | self 89 | } 90 | /// set order to desc 91 | pub fn order_desc(mut self) -> Self { 92 | self.order = Some(Order::Desc); 93 | self 94 | } 95 | 96 | /// set order option to query 97 | pub fn order(mut self, order: Order) -> Self { 98 | self.order = Some(order); 99 | self 100 | } 101 | 102 | /// add another facet to query 103 | pub fn facet(mut self, facet: FacetCount) -> Self { 104 | self.facets.push(facet); 105 | self 106 | } 107 | 108 | /// set result control option to query 109 | pub fn result_control(mut self, result_control: ResultControl) -> Self { 110 | self.result_control = Some(result_control); 111 | self 112 | } 113 | } 114 | 115 | impl CrossrefParams for $i { 116 | type Filter = $filter; 117 | 118 | fn query_terms(&self) -> &[String] { 119 | &self.queries 120 | } 121 | fn filters(&self) -> &[Self::Filter] { 122 | &self.filter 123 | } 124 | fn sort(&self) -> Option<&Sort> { 125 | self.sort.as_ref() 126 | } 127 | fn order(&self) -> Option<&Order> { 128 | self.order.as_ref() 129 | } 130 | fn facets(&self) -> &[FacetCount] { 131 | &self.facets 132 | } 133 | fn result_control(&self) -> Option<&ResultControl> { 134 | self.result_control.as_ref() 135 | } 136 | } 137 | 138 | impl CrossrefRoute for $i { 139 | fn route(&self) -> Result { 140 | let mut params = Vec::new(); 141 | if !self.queries.is_empty() { 142 | params.push(Cow::Owned(format!( 143 | "query={}", 144 | format_queries(&self.queries) 145 | ))); 146 | } 147 | if !self.filter.is_empty() { 148 | params.push(self.filter.param()); 149 | } 150 | if !self.facets.is_empty() { 151 | params.push(self.facets.param()); 152 | } 153 | if let Some(sort) = &self.sort { 154 | params.push(sort.param()); 155 | } 156 | if let Some(order) = &self.order { 157 | params.push(order.param()); 158 | } 159 | if let Some(rc) = &self.result_control { 160 | params.push(rc.param()); 161 | } 162 | Ok(params.join("&")) 163 | } 164 | } 165 | }; 166 | } 167 | 168 | /// provides types to filter facets 169 | pub mod facet; 170 | /// provides support to query the `/funders` route 171 | pub mod funders; 172 | /// provides support to query the `/funders` route 173 | pub mod journals; 174 | /// provides support to query the `/journals` route 175 | pub mod members; 176 | /// provides support to query the `/members` route 177 | pub mod prefixes; 178 | /// provides support to query the `/prefixes` route 179 | pub mod types; 180 | /// provides support to query the `/types` route 181 | pub mod works; 182 | 183 | /// represents the visibility of an crossref item 184 | #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] 185 | #[serde(rename_all = "kebab-case")] 186 | #[allow(missing_docs)] 187 | pub enum Visibility { 188 | Open, 189 | Limited, 190 | Closed, 191 | } 192 | 193 | impl Visibility { 194 | /// str identifier 195 | pub fn as_str(&self) -> &str { 196 | match self { 197 | Visibility::Open => "open", 198 | Visibility::Limited => "limited", 199 | Visibility::Closed => "closed", 200 | } 201 | } 202 | } 203 | 204 | /// Determines how results should be sorted 205 | #[derive(Debug, PartialEq, Eq, Clone)] 206 | #[cfg_attr(feature = "cli", derive(StructOpt))] 207 | pub enum Order { 208 | /// list results in ascending order 209 | #[cfg_attr( 210 | feature = "cli", 211 | structopt(name = "asc", about = "list results in ascending order") 212 | )] 213 | Asc, 214 | /// list results in descending order 215 | #[cfg_attr( 216 | feature = "cli", 217 | structopt(name = "desc", about = "list results in descending order") 218 | )] 219 | Desc, 220 | } 221 | 222 | impl Order { 223 | /// the key name for the order parameter 224 | pub fn as_str(&self) -> &str { 225 | match self { 226 | Order::Asc => "asc", 227 | Order::Desc => "desc", 228 | } 229 | } 230 | } 231 | 232 | #[cfg(feature = "cli")] 233 | impl FromStr for Order { 234 | type Err = String; 235 | 236 | fn from_str(s: &str) -> ::std::result::Result { 237 | match s { 238 | "asc" => Ok(Order::Asc), 239 | "desc" => Ok(Order::Desc), 240 | other => Err(format!("Unable to convert {} to Order", other)), 241 | } 242 | } 243 | } 244 | 245 | impl CrossrefQueryParam for Order { 246 | fn param_key(&self) -> Cow { 247 | Cow::Borrowed("order") 248 | } 249 | 250 | fn param_value(&self) -> Option> { 251 | Some(Cow::Borrowed(self.as_str())) 252 | } 253 | } 254 | 255 | /// Results from a list response can be sorted by applying the sort and order parameters. 256 | #[derive(Debug, Clone)] 257 | #[cfg_attr(feature = "cli", derive(StructOpt))] 258 | pub enum Sort { 259 | /// Sort by relevance score 260 | #[cfg_attr( 261 | feature = "cli", 262 | structopt(name = "score", about = "Sort by the relevance score") 263 | )] 264 | Score, 265 | /// Sort by date of most recent change to metadata. Currently the same as `Deposited` 266 | #[cfg_attr( 267 | feature = "cli", 268 | structopt( 269 | name = "updated", 270 | about = "Sort by date of most recent change to metadata." 271 | ) 272 | )] 273 | Updated, 274 | /// Sort by time of most recent deposit 275 | #[cfg_attr( 276 | feature = "cli", 277 | structopt(name = "deposited", about = "Sort by time of most recent deposit") 278 | )] 279 | Deposited, 280 | /// Sort by time of most recent index 281 | #[cfg_attr( 282 | feature = "cli", 283 | structopt(name = "indexed", about = "Sort by time of most recent index") 284 | )] 285 | Indexed, 286 | /// Sort by publication date 287 | #[cfg_attr( 288 | feature = "cli", 289 | structopt(name = "published", about = "Sort by publication date") 290 | )] 291 | Published, 292 | /// Sort by print publication date 293 | #[cfg_attr( 294 | feature = "cli", 295 | structopt(name = "published-print", about = "Sort by print publication date") 296 | )] 297 | PublishedPrint, 298 | /// Sort by online publication date 299 | #[cfg_attr( 300 | feature = "cli", 301 | structopt(name = "published-online", about = "Sort by online publication date") 302 | )] 303 | PublishedOnline, 304 | /// Sort by issued date (earliest known publication date) 305 | #[cfg_attr( 306 | feature = "cli", 307 | structopt( 308 | name = "issued", 309 | about = "Sort by issued date (earliest known publication date)" 310 | ) 311 | )] 312 | Issued, 313 | /// Sort by number of times this DOI is referenced by other Crossref DOIs 314 | #[cfg_attr( 315 | feature = "cli", 316 | structopt( 317 | name = "is-referenced-by-count", 318 | about = "Sort by number of times this DOI is referenced by other Crossref DOIs" 319 | ) 320 | )] 321 | IsReferencedByCount, 322 | /// Sort by number of references included in the references section of the document identified by this DOI 323 | #[cfg_attr( 324 | feature = "cli", 325 | structopt( 326 | name = "reference-count", 327 | about = "Sort by number of references included in the references section of the document identified by this DOI" 328 | ) 329 | )] 330 | ReferenceCount, 331 | } 332 | 333 | impl Sort { 334 | /// the key name for the filter element 335 | pub fn as_str(&self) -> &str { 336 | match self { 337 | Sort::Score => "score", 338 | Sort::Updated => "updated", 339 | Sort::Deposited => "deposited", 340 | Sort::Indexed => "indexed", 341 | Sort::Published => "published", 342 | Sort::PublishedPrint => "published-print", 343 | Sort::PublishedOnline => "published-online", 344 | Sort::Issued => "issued", 345 | Sort::IsReferencedByCount => "is-reference-by-count", 346 | Sort::ReferenceCount => "reference-count", 347 | } 348 | } 349 | } 350 | 351 | #[cfg(feature = "cli")] 352 | impl FromStr for Sort { 353 | type Err = String; 354 | 355 | fn from_str(s: &str) -> ::std::result::Result { 356 | match s { 357 | "score" => Ok(Sort::Score), 358 | "updated" => Ok(Sort::Updated), 359 | "deposited" => Ok(Sort::Deposited), 360 | "indexed" => Ok(Sort::Indexed), 361 | "published" => Ok(Sort::Published), 362 | "published-print" => Ok(Sort::PublishedPrint), 363 | "published-online" => Ok(Sort::PublishedOnline), 364 | "issued" => Ok(Sort::Issued), 365 | "is-reference-by-count" => Ok(Sort::IsReferencedByCount), 366 | "reference-count" => Ok(Sort::ReferenceCount), 367 | other => Err(format!("Unable to convert {} to Sort", other)), 368 | } 369 | } 370 | } 371 | 372 | impl CrossrefQueryParam for Sort { 373 | fn param_key(&self) -> Cow { 374 | Cow::Borrowed("sort") 375 | } 376 | 377 | fn param_value(&self) -> Option> { 378 | Some(Cow::Borrowed(self.as_str())) 379 | } 380 | } 381 | 382 | /// tells crossref how many items shall be returned or where to start 383 | #[derive(Debug, Clone)] 384 | pub enum ResultControl { 385 | /// limits the returned items per page 386 | Rows(usize), 387 | /// sets an offset where crossref begins to retrieve items 388 | /// high offsets (~10k) result in long response times 389 | Offset(usize), 390 | /// combines rows and offset: limit returned items per page, starting at the offset 391 | RowsOffset { 392 | /// row limit 393 | rows: usize, 394 | /// where to start 395 | offset: usize, 396 | }, 397 | /// return random results 398 | Sample(usize), 399 | } 400 | 401 | impl CrossrefQueryParam for ResultControl { 402 | fn param_key(&self) -> Cow { 403 | match self { 404 | ResultControl::Rows(_) => Cow::Borrowed("rows"), 405 | ResultControl::Offset(_) => Cow::Borrowed("offset"), 406 | ResultControl::RowsOffset { rows, .. } => Cow::Owned(format!("rows={}", rows)), 407 | ResultControl::Sample(_) => Cow::Borrowed("sample"), 408 | } 409 | } 410 | 411 | fn param_value(&self) -> Option> { 412 | match self { 413 | ResultControl::Rows(r) | ResultControl::Offset(r) | ResultControl::Sample(r) => { 414 | Some(Cow::Owned(r.to_string())) 415 | } 416 | ResultControl::RowsOffset { offset, .. } => { 417 | Some(Cow::Owned(format!("offset={}", offset))) 418 | } 419 | } 420 | } 421 | } 422 | 423 | /// Major resource components supported by the Crossref API 424 | #[derive(Debug, Clone, Serialize, Deserialize)] 425 | #[serde(rename_all = "lowercase")] 426 | pub enum Component { 427 | /// returns a list of all works (journal articles, conference proceedings, books, components, etc), 20 per page 428 | Works, 429 | /// returns a list of all funders in the [Funder Registry](https://github.com/Crossref/open-funder-registry) 430 | Funders, 431 | /// returns a list of all Crossref members (mostly publishers) 432 | Prefixes, 433 | /// returns a list of valid work types 434 | Members, 435 | /// return a list of licenses applied to works in Crossref metadata 436 | Types, 437 | /// return a list of journals in the Crossref database 438 | Journals, 439 | } 440 | 441 | impl Component { 442 | /// identifier for the component route 443 | pub fn as_str(&self) -> &str { 444 | match self { 445 | Component::Works => "works", 446 | Component::Funders => "funders", 447 | Component::Prefixes => "prefixes", 448 | Component::Members => "members", 449 | Component::Types => "types", 450 | Component::Journals => "journals", 451 | } 452 | } 453 | } 454 | 455 | impl CrossrefRoute for Component { 456 | fn route(&self) -> Result { 457 | Ok(format!("/{}", self.as_str())) 458 | } 459 | } 460 | 461 | /// bundles all available crossref api endpoints 462 | #[derive(Debug, Clone)] 463 | pub enum ResourceComponent { 464 | /// returns a list of all works (journal articles, conference proceedings, books, components, etc), 20 per page 465 | Works(Works), 466 | /// returns a list of all funders in the [Funder Registry](https://github.com/Crossref/open-funder-registry) 467 | Funders(Funders), 468 | /// returns a list of all Crossref members (mostly publishers) 469 | Prefixes(Prefixes), 470 | /// returns a list of valid work types 471 | Members(Members), 472 | /// return a list of licenses applied to works in Crossref metadata 473 | Types(Types), 474 | /// return a list of journals in the Crossref database 475 | Journals(Journals), 476 | } 477 | 478 | impl ResourceComponent { 479 | /// the starting crossref component that in the route `/{primary_component}/{id}/works` 480 | pub fn primary_component(&self) -> Component { 481 | match self { 482 | ResourceComponent::Works(_) => Component::Works, 483 | ResourceComponent::Funders(_) => Component::Funders, 484 | ResourceComponent::Prefixes(_) => Component::Prefixes, 485 | ResourceComponent::Members(_) => Component::Members, 486 | ResourceComponent::Types(_) => Component::Types, 487 | ResourceComponent::Journals(_) => Component::Journals, 488 | } 489 | } 490 | } 491 | 492 | impl fmt::Display for ResourceComponent { 493 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 494 | write!(f, "{}", self.route().map_err(|_| fmt::Error)?) 495 | } 496 | } 497 | 498 | impl CrossrefRoute for ResourceComponent { 499 | fn route(&self) -> Result { 500 | match self { 501 | ResourceComponent::Works(c) => c.route(), 502 | ResourceComponent::Funders(c) => c.route(), 503 | ResourceComponent::Prefixes(c) => c.route(), 504 | ResourceComponent::Members(c) => c.route(), 505 | ResourceComponent::Types(c) => c.route(), 506 | ResourceComponent::Journals(c) => c.route(), 507 | } 508 | } 509 | } 510 | 511 | impl CrossrefQuery for ResourceComponent { 512 | fn resource_component(self) -> ResourceComponent { 513 | self 514 | } 515 | } 516 | 517 | /// Helper trait to mark filters in the query string 518 | pub trait Filter: ParamFragment {} 519 | 520 | impl CrossrefQueryParam for Vec { 521 | /// always use `filter` as the key 522 | fn param_key(&self) -> Cow { 523 | Cow::Borrowed("filter") 524 | } 525 | 526 | /// filters are multi value and values are concat with `,` 527 | fn param_value(&self) -> Option> { 528 | Some(Cow::Owned( 529 | self.iter() 530 | .map(ParamFragment::fragment) 531 | .collect::>() 532 | .join(","), 533 | )) 534 | } 535 | } 536 | 537 | /// represents a key value pair inside a multi value query string parameter 538 | pub trait ParamFragment { 539 | /// the key, or name, of the fragment 540 | fn key(&self) -> Cow; 541 | 542 | /// the value of the fragment, if any 543 | fn value(&self) -> Option>; 544 | 545 | /// key and value are concat using `:` 546 | fn fragment(&self) -> Cow { 547 | if let Some(val) = self.value() { 548 | Cow::Owned(format!("{}:{}", self.key(), val)) 549 | } else { 550 | self.key() 551 | } 552 | } 553 | } 554 | 555 | /// a trait used to capture parameters for the query string of the crossref api 556 | pub trait CrossrefQueryParam { 557 | /// the key name of the parameter in the query string 558 | fn param_key(&self) -> Cow; 559 | /// the value of the parameter, if any 560 | fn param_value(&self) -> Option>; 561 | /// constructs the full parameter for the query string by combining the key and value 562 | fn param(&self) -> Cow { 563 | if let Some(val) = self.param_value() { 564 | Cow::Owned(format!("{}={}", self.param_key(), val)) 565 | } else { 566 | self.param_key() 567 | } 568 | } 569 | } 570 | 571 | impl> CrossrefQueryParam for (T, T) { 572 | fn param_key(&self) -> Cow { 573 | Cow::Borrowed(self.0.as_ref()) 574 | } 575 | 576 | fn param_value(&self) -> Option> { 577 | Some(Cow::Borrowed(self.1.as_ref())) 578 | } 579 | } 580 | 581 | /// represents elements that constructs parts of the crossref request url 582 | pub trait CrossrefRoute { 583 | /// constructs the route for the crossref api 584 | fn route(&self) -> Result; 585 | } 586 | 587 | impl CrossrefRoute for dyn AsRef<[T]> { 588 | fn route(&self) -> Result { 589 | Ok(self 590 | .as_ref() 591 | .iter() 592 | .map(CrossrefQueryParam::param) 593 | .collect::>() 594 | .join("&")) 595 | } 596 | } 597 | 598 | /// root level trait to construct full crossref api request urls 599 | pub trait CrossrefQuery: CrossrefRoute + Clone { 600 | /// the resource component endpoint this route targets 601 | fn resource_component(self) -> ResourceComponent; 602 | 603 | /// constructs the full request url by concating the `base_path` with the `route` 604 | fn to_url(&self, base_path: &str) -> Result { 605 | Ok(format!("{}{}", base_path, self.route()?)) 606 | } 607 | } 608 | 609 | /// formats the topic for crossref by replacing all whitespaces whit `+` 610 | pub(crate) fn format_query>(topic: T) -> String { 611 | topic 612 | .as_ref() 613 | .split_whitespace() 614 | .collect::>() 615 | .join("+") 616 | } 617 | 618 | /// formats the individual topics of a query into the format crossref expects 619 | /// returns a single String consisting of all words combined by '+' 620 | pub(crate) fn format_queries>(topics: &[T]) -> String { 621 | topics 622 | .iter() 623 | .map(format_query) 624 | .collect::>() 625 | .join("+") 626 | } 627 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate provides a client for interacting with the crossref-api 2 | //! 3 | //! [Crossref API docs](https://github.com/CrossRef/rest-api-doc) 4 | //! `Crossref` - Crossref search API. The `Crossref` crate provides methods matching Crossref API routes: 5 | 6 | //! * `works` - `/works` route 7 | //! * `members` - `/members` route 8 | //! * `prefixes` - `/prefixes` route 9 | //! * `funders` - `/funders` route 10 | //! * `journals` - `/journals` route 11 | //! * `types` - `/types` route 12 | //! * `agency` - `/works/{doi}/agency` get DOI minting agency 13 | //! 14 | //! ## Usage 15 | 16 | //! ### Create a `Crossref` client: 17 | 18 | //! ```edition2018 19 | //! # use crossref::Crossref; 20 | //! # fn run() -> Result<(), crossref::Error> { 21 | //! let client = Crossref::builder().build()?; 22 | //! # Ok(()) 23 | //! # } 24 | //! ``` 25 | //! 26 | //! If you have an [Authorization token for Crossref's Plus service](https://github.com/CrossRef/rest-api-doc#authorization-token-for-plus-service): 27 | //! 28 | //! ```edition2018 29 | //! # use crossref::Crossref; 30 | //! # fn run() -> Result<(), crossref::Error> { 31 | //! let client = Crossref::builder() 32 | //! .token("token") 33 | //! .build()?; 34 | //! # Ok(()) 35 | //! # } 36 | //! ``` 37 | //! 38 | //! Encouraged to use the **The Polite Pool**: 39 | //! 40 | //! [Good manners = more reliable service](https://github.com/CrossRef/rest-api-doc#good-manners--more-reliable-service) 41 | //! 42 | //! To get into Crossref's polite pool include a email address 43 | //! 44 | //! ```edition2018 45 | //! # use crossref::Crossref; 46 | //! # fn run() -> Result<(), crossref::Error> { 47 | //! let client = Crossref::builder() 48 | //! .polite("polite@example.com") 49 | //! .token("your token") 50 | //! .build()?; 51 | //! # Ok(()) 52 | //! # } 53 | //! ``` 54 | //! 55 | //! ### Constructing Queries 56 | //! Not all components support queries and there are custom available parameters for each route that supports querying. 57 | //! For each resource components that supports querying there exist a Query struct: `WorksQuery`, `MembersQuery`, `FundersQuery`. The `WorksQuery` also differs from the others by supporting [deep paging with cursors](https://github.com/CrossRef/rest-api-doc#deep-paging-with-cursors) and [field queries](https://github.com/CrossRef/rest-api-doc#works-field-queries). 58 | //! 59 | //! Otherwise creating queries works the same for all resource components: 60 | //! 61 | //! ```edition2018 62 | //! # use crossref::*; 63 | //! # fn run() -> Result<()> { 64 | //! let query = WorksQuery::new("Machine Learning") 65 | //! // field queries supported for `Works` 66 | //! .field_query(FieldQuery::author("Some Author")) 67 | //! // filters are specific for each resource component 68 | //! .filter(WorksFilter::HasOrcid) 69 | //! .order(Order::Asc) 70 | //! .sort(Sort::Score); 71 | //! # Ok(()) 72 | //! # } 73 | //! ``` 74 | //! 75 | //! 76 | //! ### Get Records 77 | //! 78 | //! See [this table](https://github.com/CrossRef/rest-api-doc#resource-components) for a detailed overview of the major components. 79 | //! 80 | //! There are 3 available targets: 81 | //! 82 | //! * **standalone resource components**: `/works`, `/members`, etc. that return a list list of the corresponding items and can be specified with queries 83 | //! * **Resource component with identifiers**: `/works/{doi}?`,`/members/{member_id}?`, etc. that returns a single item if found. 84 | //! * **combined with the `works` route**: The works component can be appended to other resources: `/members/{member_id}/works?` etc. that returns a list of matching `Work` items. 85 | //! 86 | //! This resembles in the enums of the resource components, eg. for `Members`: 87 | //! 88 | //! ```edition2018 89 | //! # use crossref::query::*; 90 | //! pub enum Members { 91 | //! /// target a specific member at `/members/{id}` 92 | //! Identifier(String), 93 | //! /// target all members that match the query at `/members?query...` 94 | //! Query(MembersQuery), 95 | //! /// target a `Work` for a specific member at `/members/{id}/works?query..` 96 | //! Works(WorksIdentQuery), 97 | //! } 98 | //! ``` 99 | //! 100 | //! All options are supported by the client: 101 | //! 102 | //! **Single Item by DOI (ID)** 103 | //! 104 | //! Analogous methods exist for all resource components 105 | //! 106 | //! ```edition2018 107 | //! # use crossref::*; 108 | //! # fn run() -> Result<()> { 109 | //! # let client = Crossref::builder().build()?; 110 | //! let work = client.work("10.1037/0003-066X.59.1.29")?; 111 | //! 112 | //! let agency = client.work_agency("10.1037/0003-066X.59.1.29")?; 113 | //! 114 | //! let funder = client.funder("funder_id")?; 115 | //! 116 | //! let member = client.member("member_id")?; 117 | //! # Ok(()) 118 | //! # } 119 | //! ``` 120 | //! 121 | //! **Query** 122 | //! 123 | //! ```edition2018 124 | //! # use crossref::*; 125 | //! # fn run() -> Result<()> { 126 | //! # let client = Crossref::builder().build()?; 127 | //! let query = WorksQuery::new("Machine Learning"); 128 | //! 129 | //! // one page of the matching results 130 | //! let works = client.works(query)?; 131 | //! # Ok(()) 132 | //! # } 133 | //! ``` 134 | //! 135 | //! Alternatively insert a free form query term directly 136 | //! 137 | //! ```edition2018 138 | //! # use crossref::*; 139 | //! # fn run() -> Result<()> { 140 | //! # let client = Crossref::builder().build()?; 141 | //! 142 | //! // one page of the matching results 143 | //! let works = client.works("Machine Learning")?; 144 | //! # Ok(()) 145 | //! # } 146 | //! ``` 147 | //! 148 | //! **Combining Routes with the `Works` route** 149 | //! 150 | //! For each resource component other than `Works` there exist methods to append a `WorksQuery` with the ID option `/members/{member_id}/works??` 151 | //! 152 | //! ```edition2018 153 | //! # use crossref::*; 154 | //! # fn run() -> Result<()> { 155 | //! # let client = Crossref::builder().build()?; 156 | //! let works = client.member_works( WorksQuery::new("machine learning") 157 | //! .sort(Sort::Score).into_ident("member_id"))?; 158 | //! # Ok(()) 159 | //! # } 160 | //! ``` 161 | //! 162 | //! This would be the same as using the [`Crossref::works`] method by supplying the combined type 163 | //! 164 | //! ```edition2018 165 | //! # use crossref::*; 166 | //! # fn run() -> Result<()> { 167 | //! # let client = Crossref::builder().build()?; 168 | //! let works = client.works(WorksQuery::new("machine learning") 169 | //! .sort(Sort::Score) 170 | //! .into_combined_query::("member_id"))?; 171 | //! # Ok(()) 172 | //! # } 173 | //! ``` 174 | //! 175 | //! ** Deep paging for `Works` ** 176 | //! [Deep paging results](https://github.com/CrossRef/rest-api-doc#deep-paging-with-cursors) 177 | //! Deep paging is supported for all queries, that return a list of `Work`, `WorkList`. 178 | //! This function returns a new iterator over pages of `Work`, which is returned as bulk of items as a `WorkList` by crossref. 179 | //! Usually a single page `WorkList` contains 20 items. 180 | //! 181 | //! # Example 182 | //! 183 | //! Iterate over all `Works` linked to search term `Machine Learning` 184 | //! 185 | //! ```edition2018 186 | //! use crossref::{Crossref, WorksQuery, Work}; 187 | //! # fn run() -> Result<(), crossref::Error> { 188 | //! let client = Crossref::builder().build()?; 189 | //! 190 | //! let all_works: Vec = client.deep_page(WorksQuery::new("Machine Learning")).flat_map(|x|x.items).collect(); 191 | //! 192 | //! # Ok(()) 193 | //! # } 194 | //! ``` 195 | //! 196 | //! Which can be simplified to 197 | //! 198 | //! ```edition2018 199 | //! use crossref::{Crossref, WorksQuery, Work}; 200 | //! # fn run() -> Result<(), crossref::Error> { 201 | //! let client = Crossref::builder().build()?; 202 | //! 203 | //! let all_works: Vec = client.deep_page("Machine Learning").into_work_iter().collect(); 204 | //! 205 | //! # Ok(()) 206 | //! # } 207 | //! ``` 208 | //! 209 | //! 210 | //! # Example 211 | //! 212 | //! Iterate over all the pages (`WorkList`) of the funder with id `funder id` by using a combined query. 213 | //! A single `WorkList` usually holds 20 `Work` items. 214 | //! 215 | //! ```edition2018 216 | //! use crossref::{Crossref, Funders, WorksQuery, Work, WorkList}; 217 | //! # fn run() -> Result<(), crossref::Error> { 218 | //! let client = Crossref::builder().build()?; 219 | //! 220 | //! let all_funder_work_list: Vec = client.deep_page(WorksQuery::default().into_combined_query::("funder id")).collect(); 221 | //! 222 | //! # Ok(()) 223 | //! # } 224 | //! ``` 225 | //! # Example 226 | //! 227 | //! Iterate over all `Work` items of a specfic funder directly. 228 | //! 229 | //! ```edition2018 230 | //! use crossref::{Crossref, Funders, WorksQuery, Work, WorkList}; 231 | //! # fn run() -> Result<(), crossref::Error> { 232 | //! let client = Crossref::builder().build()?; 233 | //! 234 | //! let all_works: Vec = client.deep_page(WorksQuery::default() 235 | //! .into_combined_query::("funder id")) 236 | //! .into_work_iter() 237 | //! .collect(); 238 | //! 239 | //! # Ok(()) 240 | //! # } 241 | //! ``` 242 | 243 | #![deny(warnings)] 244 | #![deny(missing_docs)] 245 | #![allow(unused)] 246 | 247 | mod error; 248 | /// provides types to construct a specific query 249 | pub mod query; 250 | /// provides the response types of the crossref api 251 | pub mod response; 252 | 253 | // TODO extract to optional feature? 254 | /// content negotiation 255 | pub mod cn; 256 | /// textual data mining 257 | pub mod tdm; 258 | 259 | #[doc(inline)] 260 | pub use self::error::{Error, Result}; 261 | 262 | #[doc(inline)] 263 | pub use self::query::works::{ 264 | FieldQuery, WorkListQuery, WorkResultControl, Works, WorksFilter, WorksIdentQuery, WorksQuery, 265 | }; 266 | 267 | #[doc(inline)] 268 | pub use self::query::{Component, CrossrefQuery, CrossrefRoute, Order, Sort}; 269 | pub use self::query::{Funders, Journals, Members, Prefixes, Type, Types}; 270 | pub use self::response::{ 271 | CrossrefType, Funder, FunderList, Journal, JournalList, Member, MemberList, TypeList, Work, 272 | WorkAgency, WorkList, 273 | }; 274 | 275 | pub(crate) use self::response::{Message, Response}; 276 | 277 | use crate::error::ErrorKind; 278 | use crate::query::{FundersQuery, MembersQuery, ResourceComponent}; 279 | use crate::response::{MessageType, Prefix}; 280 | use reqwest::{self, Client}; 281 | use std::iter::FlatMap; 282 | use std::rc::Rc; 283 | 284 | macro_rules! get_item { 285 | ($ident:ident, $value:expr, $got:expr) => { 286 | if let Some(msg) = $value { 287 | match msg { 288 | Message::$ident(item) => Ok(item), 289 | _ => Err(ErrorKind::UnexpectedItem { 290 | expected: MessageType::$ident, 291 | got: $got, 292 | } 293 | .into()), 294 | } 295 | } else { 296 | Err(ErrorKind::MissingMessage { 297 | expected: MessageType::$ident, 298 | } 299 | .into()) 300 | } 301 | }; 302 | } 303 | 304 | macro_rules! impl_combined_works_query { 305 | ($($name:ident $component:ident,)*) => { 306 | $( 307 | /// Return one page of the components's `Work` that match the query 308 | /// 309 | pub fn $name(&self, ident: WorksIdentQuery) -> Result { 310 | let resp = self.get_response(&$component::Works(ident))?; 311 | get_item!(WorkList, resp.message, resp.message_type) 312 | })+ 313 | }; 314 | } 315 | 316 | /// Struct for Crossref search API methods 317 | #[derive(Debug, Clone)] 318 | pub struct Crossref { 319 | /// use another base url than `api.crossref.org` 320 | pub base_url: String, 321 | /// the reqwest client that handles the requests 322 | pub client: Rc, 323 | } 324 | 325 | impl Crossref { 326 | const BASE_URL: &'static str = "https://api.crossref.org"; 327 | 328 | /// Constructs a new `CrossrefBuilder`. 329 | /// 330 | /// This is the same as `Crossref::builder()`. 331 | pub fn builder() -> CrossrefBuilder { 332 | CrossrefBuilder::new() 333 | } 334 | 335 | // generate all functions to query combined endpoints 336 | impl_combined_works_query!(funder_works Funders, member_works Members, 337 | type_works Types, journal_works Journals, prefix_works Prefixes,); 338 | 339 | /// Transforms the `CrossrefQuery` in the request route and executes the request 340 | /// 341 | /// # Errors 342 | /// 343 | /// If it was a bad url, the server will return `Resource not found` a `ResourceNotFound` error will be returned in this case 344 | /// Also fails if the json response body could be parsed into `Response` 345 | /// Fails if there was an error in reqwest executing the request [::reqwest::RequestBuilder::send] 346 | fn get_response(&self, query: &T) -> Result { 347 | let resp = self 348 | .client 349 | .get(&query.to_url(&self.base_url)?) 350 | .send()? 351 | .text()?; 352 | if resp.starts_with("Resource not found") { 353 | Err(ErrorKind::ResourceNotFound { 354 | resource: Box::new(query.clone().resource_component()), 355 | } 356 | .into()) 357 | } else { 358 | Ok(serde_json::from_str(&resp)?) 359 | } 360 | } 361 | 362 | /// Return the `Work` items that match a certain query. 363 | /// 364 | /// To search only by query terms use the convenience query method [Crossref::query_works] 365 | /// 366 | /// # Example 367 | /// 368 | /// ```edition2018 369 | /// use crossref::{Crossref, WorksQuery, WorksFilter, FieldQuery}; 370 | /// # fn run() -> Result<(), crossref::Error> { 371 | /// let client = Crossref::builder().build()?; 372 | /// 373 | /// let query = WorksQuery::new("Machine Learning") 374 | /// .filter(WorksFilter::HasOrcid) 375 | /// .order(crossref::Order::Asc) 376 | /// .field_query(FieldQuery::author("Some Author")) 377 | /// .sort(crossref::Sort::Score); 378 | /// 379 | /// let works = client.works(query)?; 380 | /// 381 | /// # Ok(()) 382 | /// # } 383 | /// ``` 384 | /// 385 | /// # Errors 386 | /// 387 | /// This method fails if the `works` element expands to a bad route `ResourceNotFound` 388 | /// Fails if the response body doesn't have `message` field `MissingMessage`. 389 | /// Fails if anything else than a `WorkList` is returned as message `UnexpectedItem` 390 | pub fn works>(&self, query: T) -> Result { 391 | let resp = self.get_response(&query.into())?; 392 | get_item!(WorkList, resp.message, resp.message_type) 393 | } 394 | 395 | /// Return the `Work` that is identified by the `doi`. 396 | /// 397 | /// # Errors 398 | /// This method fails if the doi could not identified `ResourceNotFound` 399 | /// 400 | pub fn work(&self, doi: &str) -> Result { 401 | let resp = self.get_response(&Works::Identifier(doi.to_string()))?; 402 | get_item!(Work, resp.message, resp.message_type).map(|x| *x) 403 | } 404 | 405 | /// [Deep paging results](https://github.com/CrossRef/rest-api-doc#deep-paging-with-cursors) 406 | /// Deep paging is supported for all queries, that return a list of `Work`, `WorkList`. 407 | /// This function returns a new iterator over pages of `Work`, which is returned as bulk of items as a `WorkList` by crossref. 408 | /// Usually a single page `WorkList` contains 20 items. 409 | /// 410 | /// # Example 411 | /// 412 | /// Iterate over all `Works` linked to search term `Machine Learning` 413 | /// 414 | /// ```edition2018 415 | /// use crossref::{Crossref, WorksQuery, Work}; 416 | /// # fn run() -> Result<(), crossref::Error> { 417 | /// let client = Crossref::builder().build()?; 418 | /// 419 | /// let all_works: Vec = client.deep_page(WorksQuery::new("Machine Learning")).flat_map(|x|x.items).collect(); 420 | /// 421 | /// # Ok(()) 422 | /// # } 423 | /// ``` 424 | /// 425 | /// # Example 426 | /// 427 | /// Iterate over all the pages (`WorkList`) of the funder with id `funder id` by using a combined query. 428 | /// A single `WorkList` usually holds 20 `Work` items. 429 | /// 430 | /// ```edition2018 431 | /// use crossref::{Crossref, Funders, WorksQuery, Work, WorkList}; 432 | /// # fn run() -> Result<(), crossref::Error> { 433 | /// let client = Crossref::builder().build()?; 434 | /// 435 | /// let all_funder_work_list: Vec = client.deep_page(WorksQuery::default().into_combined_query::("funder id")).collect(); 436 | /// 437 | /// # Ok(()) 438 | /// # } 439 | /// ``` 440 | /// # Example 441 | /// 442 | /// Iterate over all `Work` items of a specfic funder directly. 443 | /// 444 | /// ```edition2018 445 | /// use crossref::{Crossref, Funders, WorksQuery, Work, WorkList}; 446 | /// # fn run() -> Result<(), crossref::Error> { 447 | /// let client = Crossref::builder().build()?; 448 | /// 449 | /// let all_works: Vec = client.deep_page(WorksQuery::default() 450 | /// .into_combined_query::("funder id")) 451 | /// .into_work_iter() 452 | /// .collect(); 453 | /// 454 | /// # Ok(()) 455 | /// # } 456 | /// ``` 457 | /// 458 | /// # Example 459 | /// 460 | /// Alternatively deep page without an iterator by handling the cursor directly 461 | /// 462 | /// ```edition2018 463 | /// use crossref::{Crossref, WorksQuery, WorksFilter}; 464 | /// # fn run() -> Result<(), crossref::Error> { 465 | /// let client = Crossref::builder().build()?; 466 | /// 467 | /// // request a next-cursor first 468 | /// let query = WorksQuery::new("Machine Learning") 469 | /// .new_cursor(); 470 | /// 471 | /// let works = client.works(query.clone())?; 472 | /// 473 | /// // this continues from where this first response stopped 474 | /// // if no more work items are available then a empty list will be returned 475 | /// let deep_works = client.works( 476 | /// query.next_cursor(&works.next_cursor.unwrap()) 477 | /// )?; 478 | /// # Ok(()) 479 | /// # } 480 | /// ``` 481 | /// 482 | pub fn deep_page>(&self, query: T) -> WorkListIterator { 483 | WorkListIterator { 484 | query: query.into(), 485 | client: self, 486 | index: 0, 487 | finish_next_iteration: false, 488 | } 489 | } 490 | 491 | /// Return the `Agency` that registers the `Work` identified by the `doi`. 492 | /// 493 | /// # Errors 494 | /// This method fails if the doi could not identified `ResourceNotFound` 495 | /// 496 | pub fn work_agency(&self, doi: &str) -> Result { 497 | let resp = self.get_response(&Works::Agency(doi.to_string()))?; 498 | get_item!(WorkAgency, resp.message, resp.message_type) 499 | } 500 | 501 | /// Return the matching `Funders` items. 502 | pub fn funders(&self, funders: FundersQuery) -> Result { 503 | let resp = self.get_response(&Funders::Query(funders))?; 504 | get_item!(FunderList, resp.message, resp.message_type) 505 | } 506 | 507 | /// Return the `Funder` for the `id` 508 | pub fn funder(&self, id: &str) -> Result { 509 | let resp = self.get_response(&Funders::Identifier(id.to_string()))?; 510 | get_item!(Funder, resp.message, resp.message_type).map(|x| *x) 511 | } 512 | 513 | /// Return the matching `Members` items. 514 | pub fn members(&self, members: MembersQuery) -> Result { 515 | let resp = self.get_response(&Members::Query(members))?; 516 | get_item!(MemberList, resp.message, resp.message_type) 517 | } 518 | 519 | /// Return the `Member` for the `id` 520 | pub fn member(&self, member_id: &str) -> Result { 521 | let resp = self.get_response(&Members::Identifier(member_id.to_string()))?; 522 | get_item!(Member, resp.message, resp.message_type).map(|x| *x) 523 | } 524 | 525 | /// Return the `Prefix` for the `id` 526 | pub fn prefix(&self, id: &str) -> Result { 527 | let resp = self.get_response(&Prefixes::Identifier(id.to_string()))?; 528 | get_item!(Prefix, resp.message, resp.message_type) 529 | } 530 | /// Return a specific `Journal` 531 | pub fn journal(&self, id: &str) -> Result { 532 | let resp = self.get_response(&Journals::Identifier(id.to_string()))?; 533 | get_item!(Journal, resp.message, resp.message_type).map(|x| *x) 534 | } 535 | 536 | /// Return all available `Type` 537 | pub fn types(&self) -> Result { 538 | let resp = self.get_response(&Types::All)?; 539 | get_item!(TypeList, resp.message, resp.message_type) 540 | } 541 | 542 | /// Return the `Type` for the `id` 543 | pub fn type_(&self, id: &Type) -> Result { 544 | let resp = self.get_response(&Types::Identifier(id.id().to_string()))?; 545 | get_item!(Type, resp.message, resp.message_type) 546 | } 547 | 548 | /// Get a random set of DOIs 549 | /// 550 | /// # Example 551 | /// 552 | /// ```edition2018 553 | /// use crossref::Crossref; 554 | /// # fn run() -> Result<(), crossref::Error> { 555 | /// # let client = Crossref::builder().build()?; 556 | /// // this will return 10 random dois from the crossref api 557 | /// let random_dois = client.random_dois(10)?; 558 | /// # Ok(()) 559 | /// # } 560 | /// ``` 561 | pub fn random_dois(&self, len: usize) -> Result> { 562 | self.works(WorksQuery::random(len)) 563 | .map(|x| x.items.into_iter().map(|x| x.doi).collect()) 564 | } 565 | } 566 | 567 | /// A `CrossrefBuilder` can be used to create `Crossref` with additional config. 568 | /// 569 | /// # Example 570 | /// 571 | /// ```edition2018 572 | /// use crossref::Crossref; 573 | /// # fn run() -> Result<(), crossref::Error> { 574 | /// 575 | /// let client = Crossref::builder() 576 | /// .polite("polite@example.com") 577 | /// .token("your token") 578 | /// .build()?; 579 | /// # Ok(()) 580 | /// # } 581 | /// ``` 582 | #[derive(Default)] 583 | pub struct CrossrefBuilder { 584 | /// [Good manners = more reliable service.](https://github.com/CrossRef/rest-api-doc#good-manners--more-reliable-service) 585 | /// 586 | /// will add a `User-Agent` header by default with with the `email` email. 587 | /// crossref can contact you if your script misbehaves 588 | /// this will get you directed to the "polite pool" 589 | user_agent: Option, 590 | /// the token for the Crossref Plus service will be included as `Authorization` header 591 | /// This token will ensure that said requests get directed to a pool of machines that are reserved for "Plus" SLA users. 592 | plus_token: Option, 593 | /// use a different base url than `Crossref::BASE_URL` https://api.crossref.org 594 | base_url: Option, 595 | } 596 | 597 | impl CrossrefBuilder { 598 | /// Constructs a new `CrossrefBuilder`. 599 | /// 600 | /// This is the same as `Crossref::builder()`. 601 | pub fn new() -> CrossrefBuilder { 602 | CrossrefBuilder::default() 603 | } 604 | 605 | /// be polite and set your email as `User-Agent` 606 | /// will get you in the polite pool of crossref 607 | pub fn polite(mut self, email: &str) -> Self { 608 | self.user_agent = Some(format!("mailto:{}", email)); 609 | self 610 | } 611 | 612 | /// set the user agent directly 613 | pub fn user_agent(mut self, user_agent: &str) -> Self { 614 | self.user_agent = Some(user_agent.to_string()); 615 | self 616 | } 617 | 618 | /// set a crossref plus service API token 619 | pub fn token(mut self, token: &str) -> Self { 620 | self.plus_token = Some(token.to_string()); 621 | self 622 | } 623 | 624 | /// Returns a `Crossref` that uses this `CrossrefBuilder` configuration. 625 | /// # Errors 626 | /// 627 | /// This will fail if TLS backend cannot be initialized see [reqwest::ClientBuilder::build] 628 | pub fn build(self) -> Result { 629 | use reqwest::header; 630 | let mut headers = header::HeaderMap::new(); 631 | if let Some(agent) = &self.user_agent { 632 | headers.insert( 633 | header::USER_AGENT, 634 | header::HeaderValue::from_str(agent).map_err(|_| ErrorKind::Config { 635 | msg: format!("failed to create User Agent header for `{}`", agent), 636 | })?, 637 | ); 638 | } 639 | if let Some(token) = &self.plus_token { 640 | headers.insert( 641 | header::AUTHORIZATION, 642 | header::HeaderValue::from_str(token).map_err(|_| ErrorKind::Config { 643 | msg: format!("failed to create AUTHORIZATION header for `{}`", token), 644 | })?, 645 | ); 646 | } 647 | let client = reqwest::Client::builder() 648 | .default_headers(headers) 649 | .build() 650 | .map_err(|_| ErrorKind::Config { 651 | msg: "failed to initialize TLS backend".to_string(), 652 | })?; 653 | 654 | Ok(Crossref { 655 | base_url: self 656 | .base_url 657 | .unwrap_or_else(|| Crossref::BASE_URL.to_string()), 658 | client: Rc::new(client), 659 | }) 660 | } 661 | } 662 | 663 | /// Allows iterating of deep page work request 664 | pub struct WorkListIterator<'a> { 665 | /// the query 666 | query: WorkListQuery, 667 | /// performs each request 668 | client: &'a Crossref, 669 | /// stores how many results already retrieved 670 | index: usize, 671 | /// whether the iterator should finish next iteration 672 | finish_next_iteration: bool, 673 | } 674 | impl<'a> WorkListIterator<'a> { 675 | /// convenience method to create a `WorkIterator` 676 | pub fn into_work_iter(self) -> impl Iterator + 'a { 677 | self.flat_map(|x| x.items) 678 | } 679 | } 680 | 681 | impl<'a> Iterator for WorkListIterator<'a> { 682 | type Item = WorkList; 683 | 684 | fn next(&mut self) -> Option { 685 | if self.finish_next_iteration { 686 | return None; 687 | } 688 | 689 | { 690 | let control = &mut self.query.query_mut().result_control; 691 | 692 | // if no result control is set, set a new cursor 693 | if control.is_none() { 694 | *control = Some(WorkResultControl::new_cursor()); 695 | } 696 | } 697 | 698 | let resp = self.client.get_response(&self.query); 699 | if let Ok(resp) = resp { 700 | let worklist: Result = get_item!(WorkList, resp.message, resp.message_type); 701 | if let Ok(worklist) = worklist { 702 | if let Some(cursor) = &worklist.next_cursor { 703 | match &mut self.query.query_mut().result_control { 704 | Some(WorkResultControl::Cursor { token, .. }) => { 705 | // use the received cursor token in next iteration 706 | *token = Some(cursor.clone()) 707 | } 708 | Some(WorkResultControl::Standard(_)) => { 709 | // standard result control was set, don't deep page and return next iteration 710 | self.finish_next_iteration = true; 711 | } 712 | _ => (), 713 | } 714 | } else { 715 | // no cursor received, end next iteration 716 | self.finish_next_iteration = true; 717 | } 718 | 719 | if worklist.items.is_empty() { 720 | None 721 | } else { 722 | Some(worklist) 723 | } 724 | } else { 725 | // failed to deserialize response into `WorkList` 726 | None 727 | } 728 | } else { 729 | // no response received 730 | None 731 | } 732 | } 733 | } 734 | -------------------------------------------------------------------------------- /src/response/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::query::facet::Facet; 2 | use crate::query::facet::FacetCount; 3 | use crate::query::Visibility; 4 | use crate::response::work::*; 5 | use serde::de::Deserializer; 6 | use serde::{Deserialize, Serialize}; 7 | use serde_json::{from_value, Value}; 8 | use std::collections::HashMap; 9 | use std::fmt; 10 | 11 | /// provides the types for a work response 12 | pub mod work; 13 | 14 | pub use crate::response::work::{Work, WorkList}; 15 | 16 | /// Represents the whole crossref response for a any request. 17 | #[derive(Debug, Clone, Serialize)] 18 | #[serde(rename_all = "kebab-case")] 19 | pub struct Response { 20 | /// the status of the request 21 | pub status: String, 22 | /// the type of the response message holds 23 | pub message_type: MessageType, 24 | /// the version of the service created this message 25 | #[serde(default = "default_msg_version")] 26 | pub message_version: String, 27 | /// the actual message of the response 28 | pub message: Option, 29 | } 30 | 31 | /// at some routes the `msg_version` is missing, this returns the default version for a crossref response 32 | fn default_msg_version() -> String { 33 | "1.0.0".to_string() 34 | } 35 | 36 | /// this macro helps to generate a function that checks whether the message is of a specific type 37 | macro_rules! impl_msg_helper { 38 | (single: $($name:ident -> $ident:ident,)*) => { 39 | $( 40 | /// checks if the message holds the variant 41 | pub fn $name(&self) -> bool { 42 | if let Some(Message::$ident(_)) = &self.message { 43 | true 44 | } else { 45 | false 46 | } 47 | } 48 | )+ 49 | }; 50 | } 51 | 52 | impl Response { 53 | impl_msg_helper!(single: 54 | is_work_ageny -> WorkAgency, 55 | is_funder -> Funder, 56 | is_prefix -> Prefix, 57 | is_work -> Work, 58 | is_type -> Type, 59 | is_journal -> Journal, 60 | is_member -> Member, 61 | is_validation_failure -> ValidationFailure, 62 | is_type_list -> TypeList, 63 | is_work_list -> WorkList, 64 | is_member_list -> MemberList, 65 | is_journal_list -> JournalList, 66 | is_funder_list -> FunderList, 67 | ); 68 | 69 | /// checks whether the `message` holds a variant of `RouteNotFound` 70 | pub fn is_route_not_found(&self) -> bool { 71 | match &self.message { 72 | Some(Message::RouteNotFound) => true, 73 | _ => false, 74 | } 75 | } 76 | } 77 | 78 | impl<'de> Deserialize<'de> for Response { 79 | fn deserialize(deserializer: D) -> Result 80 | where 81 | D: Deserializer<'de>, 82 | { 83 | #[derive(Deserialize)] 84 | #[serde(rename_all = "kebab-case")] 85 | struct ResponseFragment { 86 | status: String, 87 | message_type: MessageType, 88 | #[serde(default = "default_msg_version")] 89 | message_version: String, 90 | message: Option, 91 | } 92 | 93 | #[derive(Deserialize)] 94 | #[serde(rename_all = "kebab-case")] 95 | struct ListResp { 96 | #[serde(default)] 97 | facets: FacetMap, 98 | next_cursor: Option, 99 | total_results: usize, 100 | items_per_page: Option, 101 | query: Option, 102 | items: Value, 103 | } 104 | 105 | let fragment = ResponseFragment::deserialize(deserializer)?; 106 | 107 | macro_rules! msg_arm { 108 | ($ident:ident, $value:expr) => {{ 109 | Message::$ident( 110 | ::serde_json::from_value($value).map_err(::serde::de::Error::custom)?, 111 | ) 112 | }}; 113 | ($ident:ident, $value:expr, $ty:ty) => {{ 114 | let list_resp: ListResp = 115 | ::serde_json::from_value($value).map_err(::serde::de::Error::custom)?; 116 | let items: Vec<$ty> = ::serde_json::from_value(list_resp.items) 117 | .map_err(::serde::de::Error::custom)?; 118 | Message::$ident($ident { 119 | facets: list_resp.facets, 120 | total_results: list_resp.total_results, 121 | items_per_page: list_resp.items_per_page, 122 | query: list_resp.query, 123 | items, 124 | }) 125 | }}; 126 | } 127 | 128 | fn work_list(msg: Value) -> Result { 129 | let list_resp: ListResp = ::serde_json::from_value(msg)?; 130 | let items: Vec = ::serde_json::from_value(list_resp.items)?; 131 | 132 | Ok(Message::WorkList(WorkList { 133 | facets: list_resp.facets, 134 | total_results: list_resp.total_results, 135 | items_per_page: list_resp.items_per_page, 136 | query: list_resp.query, 137 | items, 138 | next_cursor: list_resp.next_cursor, 139 | })) 140 | } 141 | 142 | let message = match fragment.message { 143 | Some(msg) => Some(match &fragment.message_type { 144 | MessageType::ValidationFailure => msg_arm!(ValidationFailure, msg), 145 | MessageType::WorkAgency => msg_arm!(WorkAgency, msg), 146 | MessageType::Prefix => msg_arm!(Prefix, msg), 147 | MessageType::Type => msg_arm!(Type, msg), 148 | MessageType::TypeList => msg_arm!(TypeList, msg, CrossrefType), 149 | MessageType::Work => msg_arm!(Work, msg), 150 | MessageType::WorkList => work_list(msg).map_err(::serde::de::Error::custom)?, 151 | MessageType::Member => msg_arm!(Member, msg), 152 | MessageType::MemberList => msg_arm!(MemberList, msg, Member), 153 | MessageType::Journal => msg_arm!(Journal, msg), 154 | MessageType::JournalList => msg_arm!(JournalList, msg, Journal), 155 | MessageType::Funder => msg_arm!(Funder, msg), 156 | MessageType::FunderList => msg_arm!(FunderList, msg, Funder), 157 | MessageType::RouteNotFound => Message::RouteNotFound, 158 | }), 159 | _ => None, 160 | }; 161 | Ok(Response { 162 | status: fragment.status, 163 | message_type: fragment.message_type, 164 | message_version: fragment.message_version, 165 | message, 166 | }) 167 | } 168 | } 169 | 170 | macro_rules! impl_list_response { 171 | ($($name:ident<$ty:ty>,)*) => { 172 | $( 173 | #[derive(Debug, Clone, Deserialize, Serialize)] 174 | #[serde(rename_all = "kebab-case")] 175 | #[allow(missing_docs)] 176 | pub struct $name { 177 | /// if facets where part in the request they are also included in the response 178 | #[serde(default)] 179 | pub facets: FacetMap, 180 | /// the number of items that match the response 181 | pub total_results: usize, 182 | /// crossref responses for large number of items are divided in pages, number of elements to expect in `items` 183 | pub items_per_page: Option, 184 | /// if a query was set in the request, this will also be part in the response 185 | pub query: Option, 186 | /// all actual message items of the response 187 | pub items: Vec<$ty>, 188 | } 189 | )+ 190 | }; 191 | } 192 | impl_list_response!( 193 | TypeList, 194 | MemberList, 195 | JournalList, 196 | FunderList, 197 | ); 198 | 199 | /// the different payloads of a response 200 | #[derive(Debug, Clone, Deserialize, Serialize)] 201 | #[serde(untagged)] 202 | pub enum Message { 203 | /// if a request failed on the server side 204 | ValidationFailure(Vec), 205 | /// a route could not be found on the server side 206 | RouteNotFound, 207 | /// the agency for a specific work 208 | WorkAgency(WorkAgency), 209 | /// metadata data for the DOI owner prefix 210 | Prefix(Prefix), 211 | /// a valid work type 212 | Type(CrossrefType), 213 | /// a list of valid work types 214 | TypeList(TypeList), 215 | /// a publication(journal, articles...) 216 | Work(Box), 217 | /// a list of publications 218 | WorkList(WorkList), 219 | /// a crossref member (mostly publishers) 220 | Member(Box), 221 | /// a list of crossref members 222 | MemberList(MemberList), 223 | /// a Journal publication 224 | Journal(Box), 225 | /// list of journal publications 226 | JournalList(JournalList), 227 | /// a funder in the [funder registry](https://github.com/Crossref/open-funder-registry) 228 | Funder(Box), 229 | /// a list of funder 230 | FunderList(FunderList), 231 | } 232 | 233 | #[derive(Debug, Clone, Deserialize, Serialize)] 234 | #[allow(missing_docs)] 235 | pub struct CrossrefType { 236 | pub id: String, 237 | /// Name of work's publisher 238 | pub label: String, 239 | } 240 | 241 | impl Into for crate::query::types::Type { 242 | fn into(self) -> CrossrefType { 243 | CrossrefType { 244 | id: self.id().to_string(), 245 | label: self.label().to_string(), 246 | } 247 | } 248 | } 249 | 250 | /// response item for the `/works/{id}/agency` route 251 | #[derive(Debug, Clone, Deserialize, Serialize)] 252 | pub struct WorkAgency { 253 | /// the DOI fo the work that belongs to the `agency` 254 | #[serde(rename = "DOI")] 255 | doi: String, 256 | /// the agency that owns the work with `doi` 257 | agency: Agency, 258 | } 259 | 260 | /// response item for the `/prefix/{id}/` route 261 | #[derive(Debug, Clone, Deserialize, Serialize)] 262 | #[allow(missing_docs)] 263 | pub struct Prefix { 264 | pub member: String, 265 | pub name: String, 266 | pub prefix: String, 267 | } 268 | 269 | /// all possible `message-type` of a response 270 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 271 | #[serde(rename_all = "kebab-case")] 272 | #[allow(missing_docs)] 273 | pub enum MessageType { 274 | WorkAgency, 275 | Funder, 276 | Prefix, 277 | Member, 278 | Work, 279 | WorkList, 280 | FunderList, 281 | Type, 282 | TypeList, 283 | MemberList, 284 | Journal, 285 | JournalList, 286 | ValidationFailure, 287 | RouteNotFound, 288 | } 289 | 290 | impl MessageType { 291 | /// the type identifier for a message 292 | pub fn as_str(&self) -> &str { 293 | match self { 294 | MessageType::WorkAgency => "work-agency", 295 | MessageType::Funder => "funder", 296 | MessageType::Prefix => "prefix", 297 | MessageType::Member => "member", 298 | MessageType::MemberList => "member-list", 299 | MessageType::Work => "work", 300 | MessageType::WorkList => "work-list", 301 | MessageType::FunderList => "funder-list", 302 | MessageType::Type => "type", 303 | MessageType::TypeList => "type-list", 304 | MessageType::Journal => "journal", 305 | MessageType::JournalList => "journal-list", 306 | MessageType::ValidationFailure => "validation-failure", 307 | MessageType::RouteNotFound => "route-not-found", 308 | } 309 | } 310 | } 311 | 312 | impl fmt::Display for MessageType { 313 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 314 | self.as_str().fmt(f) 315 | } 316 | } 317 | 318 | /// if a query was set in the request then it is also part of the result 319 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 320 | #[serde(rename_all = "kebab-case")] 321 | pub struct QueryResponse { 322 | /// from which the returned items start 323 | pub start_index: usize, 324 | /// the terms that were initially set in the request query 325 | pub search_terms: Option, 326 | } 327 | 328 | // TODO impl CrossrefRoute for QueryResponse 329 | 330 | /// facets are returned as map 331 | pub type FacetMap = HashMap; 332 | 333 | /// if a `facet` was set in a request `FacetMap` will be in a `List` response as additional field of the message 334 | #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] 335 | #[serde(rename_all = "kebab-case")] 336 | pub struct FacetItem { 337 | /// represents the length of `values` 338 | pub value_count: usize, 339 | /// contains the 340 | pub values: HashMap, 341 | } 342 | 343 | /// response item if a request could be processed 344 | #[derive(Debug, Clone, Deserialize, Serialize)] 345 | #[serde(rename_all = "kebab-case")] 346 | pub struct Failure { 347 | /// identifier for a failue like `parameter-not-found` 348 | #[serde(rename = "type")] 349 | type_: String, 350 | /// value that caused the failure 351 | value: String, 352 | /// the message from the server 353 | message: String, 354 | } 355 | 356 | /// response item for the `/funder/{id}` route 357 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 358 | #[serde(rename_all = "kebab-case", default)] 359 | #[allow(missing_docs)] 360 | pub struct Funder { 361 | pub hierarchy_names: HashMap>, 362 | pub hierarchy: HashMap>>, 363 | pub id: String, 364 | pub location: String, 365 | pub work_count: Option, 366 | pub descendant_work_count: Option, 367 | pub descendants: Vec, 368 | pub name: String, 369 | pub alt_names: Vec, 370 | pub uri: String, 371 | pub replaces: Vec, 372 | pub replaced_by: Vec, 373 | pub tokens: Vec, 374 | } 375 | 376 | /// response item for the `/member/{id}` route 377 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 378 | #[serde(rename_all = "kebab-case", default)] 379 | #[allow(missing_docs)] 380 | pub struct Member { 381 | pub primary_name: String, 382 | pub last_status_check_time: usize, 383 | pub counts: Counts, 384 | pub breakdowns: Breakdowns, 385 | pub prefixes: Vec, 386 | pub coverage: Coverage, 387 | pub prefix: Vec, 388 | pub id: usize, 389 | pub tokens: Vec, 390 | pub counts_type: HashMap>, 391 | pub coverage_type: Value, 392 | pub flags: HashMap, 393 | pub location: String, 394 | pub names: Vec, 395 | } 396 | 397 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 398 | #[serde(rename_all = "kebab-case", default)] 399 | #[allow(missing_docs)] 400 | pub struct Counts { 401 | pub total_dois: usize, 402 | pub current_dois: usize, 403 | pub backfile_dois: usize, 404 | } 405 | 406 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 407 | #[serde(rename_all = "kebab-case", default)] 408 | #[allow(missing_docs)] 409 | pub struct Breakdowns { 410 | pub dois_by_issued_year: Vec>, 411 | } 412 | 413 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 414 | #[serde(rename_all = "kebab-case", default)] 415 | #[allow(missing_docs)] 416 | pub struct Coverage { 417 | pub affiliations_current: f32, 418 | pub similarity_checking_current: f32, 419 | pub funders_backfile: f32, 420 | pub licenses_backfile: f32, 421 | pub funders_current: f32, 422 | pub affiliations_backfile: f32, 423 | pub resource_links_backfile: f32, 424 | pub orcids_backfile: f32, 425 | pub update_policies_current: f32, 426 | pub open_references_backfile: f32, 427 | pub orcids_current: f32, 428 | pub similarity_checking_backfile: f32, 429 | pub references_backfile: f32, 430 | pub award_numbers_backfile: f32, 431 | pub update_policies_backfile: f32, 432 | pub licenses_current: f32, 433 | pub award_numbers_current: f32, 434 | pub abstracts_backfile: f32, 435 | pub resource_links_current: f32, 436 | pub abstracts_current: f32, 437 | pub open_references_current: f32, 438 | pub references_current: f32, 439 | } 440 | 441 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 442 | #[serde(rename_all = "kebab-case", default)] 443 | #[allow(missing_docs)] 444 | pub struct RefPrefix { 445 | pub value: String, 446 | pub name: String, 447 | pub public_references: bool, 448 | pub reference_visibility: Option, 449 | } 450 | 451 | /// response item for the `/journal/{id}` route 452 | #[derive(Debug, Clone, Deserialize, Serialize)] 453 | #[serde(rename_all = "kebab-case")] 454 | #[allow(missing_docs)] 455 | pub struct Journal { 456 | /// could not determine type, possible PartialDateParts 457 | pub last_status_check_time: Option, 458 | pub counts: Option, 459 | pub breakdowns: Option, 460 | pub publisher: Option, 461 | pub coverage: Option, 462 | pub title: Option, 463 | pub subjects: Vec, 464 | pub coverage_type: Option, 465 | pub flags: Option, 466 | #[serde(rename = "ISSN")] 467 | pub issn: Vec, 468 | pub issn_type: Vec, 469 | } 470 | 471 | #[cfg(test)] 472 | mod tests { 473 | use super::*; 474 | use serde_json::*; 475 | 476 | #[test] 477 | fn facets_deserialize() { 478 | let facets = r#"{ 479 | "affiliation": { 480 | "value-count": 5, 481 | "values": { 482 | "of": 177247, 483 | "university": 147649, 484 | "department": 128741, 485 | "and": 102652, 486 | "medicine": 96232 487 | } 488 | }, 489 | "orcid": { 490 | "value-count": 10, 491 | "values": { 492 | "http:\/\/orcid.org\/0000-0002-0270-1711": 67 493 | } 494 | } 495 | }"#; 496 | 497 | let _: FacetMap = from_str(facets).unwrap(); 498 | } 499 | 500 | #[test] 501 | fn agency_msg_deserialize() { 502 | let agency_str = 503 | r#"{"status":"ok","message-type":"work-agency","message-version":"1.0.0","message":{"DOI":"10.1037\/0003-066x.59.1.29","agency":{"id":"crossref","label":"Crossref"}}}"#; 504 | 505 | let agency: Response = from_str(agency_str).unwrap(); 506 | 507 | assert!(agency.is_work_ageny()); 508 | } 509 | 510 | #[test] 511 | fn funder_list_msg_deserialize() { 512 | let funders_str = r#"{"status":"ok","message-type":"funder-list","message-version":"1.0.0","message":{"items-per-page":2,"query":{"start-index":0,"search-terms":"NSF"},"total-results":9,"items":[{ "id": "501100004190", 513 | "location": "Norway", 514 | "name": "Norsk Sykepleierforbund", 515 | "alt-names": [ 516 | "NSF" 517 | ], 518 | "uri": "http:\/\/dx.doi.org\/10.13039\/501100004190", 519 | "replaces": [], 520 | "replaced-by": [], 521 | "tokens": [ 522 | "norsk" 523 | ] 524 | }]}}"#; 525 | 526 | let funders: Response = from_str(funders_str).unwrap(); 527 | 528 | assert!(funders.is_funder_list()); 529 | } 530 | 531 | #[test] 532 | fn funder_msg_deserialize() { 533 | let funder_str = r#"{"status":"ok","message-type":"funder","message-version":"1.0.0","message":{ "id": "501100004190", 534 | "location": "Norway", 535 | "name": "Norsk Sykepleierforbund", 536 | "alt-names": [ 537 | "NSF" 538 | ], 539 | "uri": "http:\/\/dx.doi.org\/10.13039\/501100004190", 540 | "replaces": [], 541 | "replaced-by": [], 542 | "tokens": [ 543 | "norsk" 544 | ], 545 | "work-count": 43, 546 | "descendants": [], 547 | "hierarchy-names": { 548 | "100000019": "National Hemophilia Foundation" 549 | }, 550 | "descendant-work-count": 43, 551 | "hierarchy": { 552 | "100000019": {} 553 | } 554 | }}"#; 555 | 556 | let funder: Response = from_str(funder_str).unwrap(); 557 | 558 | assert!(funder.is_funder()); 559 | } 560 | 561 | #[test] 562 | fn funder_msg_deserialize2() { 563 | let funder_str = r#"{"status":"ok","message-type":"funder","message-version":"1.0.0","message":{"hierarchy-names":{"100006130":"Office","100000015":"U.S. Department of Energy","100013165":"National"},"replaced-by":[],"work-count":44026,"name":"U.S. Department of Energy","descendants":["100006166"],"descendant-work-count":68704,"id":"100000015","tokens":["us"],"replaces":[],"uri":"http:\/\/dx.doi.org\/10.13039\/100000015","hierarchy":{"100000015":{"100006130":{"more":true},"100013165":{},"100006138":{"more":true}}},"alt-names":["DOE"],"location":"United States"}}"#; 564 | 565 | let funder: Response = from_str(funder_str).unwrap(); 566 | 567 | assert!(funder.is_funder()); 568 | } 569 | 570 | #[test] 571 | fn prefix_msg_deserialize() { 572 | let prefix_str = r#"{"status":"ok","message-type":"prefix","message-version":"1.0.0","message":{"member":"http:\/\/id.crossref.org\/member\/78","name":"Elsevier BV","prefix":"http:\/\/id.crossref.org\/prefix\/10.1016"}}"#; 573 | 574 | let prefix: Response = from_str(prefix_str).unwrap(); 575 | 576 | assert!(prefix.is_prefix()); 577 | } 578 | 579 | #[test] 580 | fn members_list_msg_deserialize() { 581 | let members_list_str = r#"{"status":"ok","message-type":"member-list","message-version":"1.0.0","message":{"items-per-page":2,"query":{"start-index":0,"search-terms":null},"total-results":10257,"items":[{"last-status-check-time":1551766727771,"primary-name":"Society for Leukocyte Biology","counts":{"total-dois":0,"current-dois":0,"backfile-dois":0},"breakdowns":{"dois-by-issued-year":[]},"prefixes":["10.1189"],"coverage":{"affiliations-current":0,"similarity-checking-current":0,"funders-backfile":0,"licenses-backfile":0,"funders-current":0,"affiliations-backfile":0,"resource-links-backfile":0,"orcids-backfile":0,"update-policies-current":0,"open-references-backfile":0,"orcids-current":0,"similarity-checking-backfile":0,"references-backfile":0,"award-numbers-backfile":0,"update-policies-backfile":0,"licenses-current":0,"award-numbers-current":0,"abstracts-backfile":0,"resource-links-current":0,"abstracts-current":0,"open-references-current":0,"references-current":0},"prefix":[{"value":"10.1189","name":"Society for Leukocyte Biology","public-references":false,"reference-visibility":"limited"}],"id":183,"tokens":["society","for","leukocyte","biology"],"counts-type":{"all":{},"current":{},"backfile":{}},"coverage-type":{"all":null,"backfile":null,"current":null},"flags":{"deposits-abstracts-current":false,"deposits-orcids-current":false,"deposits":false,"deposits-affiliations-backfile":false,"deposits-update-policies-backfile":false,"deposits-similarity-checking-backfile":false,"deposits-award-numbers-current":false,"deposits-resource-links-current":false,"deposits-articles":false,"deposits-affiliations-current":false,"deposits-funders-current":false,"deposits-references-backfile":false,"deposits-abstracts-backfile":false,"deposits-licenses-backfile":false,"deposits-award-numbers-backfile":false,"deposits-open-references-backfile":false,"deposits-open-references-current":false,"deposits-references-current":false,"deposits-resource-links-backfile":false,"deposits-orcids-backfile":false,"deposits-funders-backfile":false,"deposits-update-policies-current":false,"deposits-similarity-checking-current":false,"deposits-licenses-current":false},"location":"9650 Rockville Pike Attn: Lynn Willis Bethesda MD 20814 United States","names":["Society for Leukocyte Biology"]}]}}"#; 582 | 583 | let members_list: Response = from_str(members_list_str).unwrap(); 584 | 585 | assert!(members_list.is_member_list()); 586 | } 587 | 588 | #[test] 589 | fn member_msg_deserialize() { 590 | let member_str = r#"{"status":"ok","message-type":"member","message-version":"1.0.0","message":{"last-status-check-time":1551766727771,"primary-name":"Society for Leukocyte Biology","counts":{"total-dois":0,"current-dois":0,"backfile-dois":0},"breakdowns":{"dois-by-issued-year":[]},"prefixes":["10.1189"],"coverage":{"affiliations-current":0,"similarity-checking-current":0,"funders-backfile":0,"licenses-backfile":0,"funders-current":0,"affiliations-backfile":0,"resource-links-backfile":0,"orcids-backfile":0,"update-policies-current":0,"open-references-backfile":0,"orcids-current":0,"similarity-checking-backfile":0,"references-backfile":0,"award-numbers-backfile":0,"update-policies-backfile":0,"licenses-current":0,"award-numbers-current":0,"abstracts-backfile":0,"resource-links-current":0,"abstracts-current":0,"open-references-current":0,"references-current":0},"prefix":[{"value":"10.1189","name":"Society for Leukocyte Biology","public-references":false,"reference-visibility":"limited"}],"id":183,"tokens":["society","for","leukocyte","biology"],"counts-type":{"all":{},"current":{},"backfile":{}},"coverage-type":{"all":null,"backfile":null,"current":null},"flags":{"deposits-abstracts-current":false,"deposits-orcids-current":false,"deposits":false,"deposits-affiliations-backfile":false,"deposits-update-policies-backfile":false,"deposits-similarity-checking-backfile":false,"deposits-award-numbers-current":false,"deposits-resource-links-current":false,"deposits-articles":false,"deposits-affiliations-current":false,"deposits-funders-current":false,"deposits-references-backfile":false,"deposits-abstracts-backfile":false,"deposits-licenses-backfile":false,"deposits-award-numbers-backfile":false,"deposits-open-references-backfile":false,"deposits-open-references-current":false,"deposits-references-current":false,"deposits-resource-links-backfile":false,"deposits-orcids-backfile":false,"deposits-funders-backfile":false,"deposits-update-policies-current":false,"deposits-similarity-checking-current":false,"deposits-licenses-current":false},"location":"9650 Rockville Pike Attn: Lynn Willis Bethesda MD 20814 United States","names":["Society for Leukocyte Biology"]}}"#; 591 | 592 | let member: Response = from_str(member_str).unwrap(); 593 | 594 | assert!(member.is_member()); 595 | } 596 | 597 | #[test] 598 | fn journals_list_msg_deserialize() { 599 | let journal_list_str = r#"{"status":"ok","message-type":"journal-list","message-version":"1.0.0","message":{"items-per-page":2,"query":{"start-index":0,"search-terms":null},"total-results":10257,"items":[{"last-status-check-time":null,"counts":null,"breakdowns":null,"publisher":"Fundacao Educacional de Criciuma- FUCRI","coverage":null,"title":"A INFLU\u00caNCIA DA PUBLICIDADE NA TRANSI\u00c7\u00c3O NUTRICIONAL UMA S\u00cdNTESE PARA ENTENDER A OBESIDADE","subjects":[],"coverage-type":null,"flags":null,"ISSN":[],"issn-type":[]}]}}"#; 600 | 601 | let journal_list: Response = from_str(journal_list_str).unwrap(); 602 | 603 | assert!(journal_list.is_journal_list()); 604 | } 605 | 606 | #[test] 607 | fn journal_msg_deserialize() { 608 | let journal_str = r#"{"status":"ok","message-type":"journal","message-version":"1.0.0","message":{"last-status-check-time":null,"counts":null,"breakdowns":null,"publisher":"Fundacao Educacional de Criciuma- FUCRI","coverage":null,"title":"A INFLU\u00caNCIA DA PUBLICIDADE NA TRANSI\u00c7\u00c3O NUTRICIONAL UMA S\u00cdNTESE PARA ENTENDER A OBESIDADE","subjects":[],"coverage-type":null,"flags":null,"ISSN":[],"issn-type":[]}}"#; 609 | 610 | let journal: Response = from_str(journal_str).unwrap(); 611 | 612 | assert!(journal.is_journal()); 613 | } 614 | 615 | #[test] 616 | fn type_list_msg_deserialize() { 617 | let type_list_str = r#"{"status":"ok","message-type":"type-list","message-version":"1.0.0","message":{"total-results":27,"items":[{"id":"book-section","label":"Book Section"},{"id":"monograph","label":"Monograph"}]}}"#; 618 | let type_list: Response = from_str(type_list_str).unwrap(); 619 | 620 | assert!(type_list.is_type_list()); 621 | } 622 | 623 | #[test] 624 | fn type_msg_deserialize() { 625 | let type_str = r#"{"status":"ok","message-type":"type","message-version":"1.0.0","message":{"id":"book-section","label":"Book Section"}}"#; 626 | let type_: Response = from_str(type_str).unwrap(); 627 | 628 | assert!(type_.is_type()); 629 | } 630 | 631 | #[test] 632 | fn validation_failure_deserialize() { 633 | let failure_str = r#"{"status":"failed","message-type":"validation-failure","message":[{"type":"parameter-not-allowed","value":"query.*","message":"This route does not support field query parameters"}]}"#; 634 | let failure: Response = from_str(failure_str).unwrap(); 635 | 636 | assert!(failure.is_validation_failure()); 637 | } 638 | 639 | #[test] 640 | fn work_msg_deserialize() { 641 | let failure_str = r#"{"status":"ok","message-type":"work","message-version":"1.0.0","message":{"indexed":{"date-parts":[[2019,2,14]],"date-time":"2019-02-14T05:10:15Z","timestamp":1550121015066},"reference-count":105,"publisher":"American Psychological Association (APA)","issue":"1","content-domain":{"domain":[],"crossmark-restriction":false},"short-container-title":["American Psychologist"],"DOI":"10.1037\/0003-066x.59.1.29","type":"journal-article","created":{"date-parts":[[2004,1,21]],"date-time":"2004-01-21T14:31:19Z","timestamp":1074695479000},"page":"29-40","source":"Crossref","is-referenced-by-count":83,"title":["How the Mind Hurts and Heals the Body."],"prefix":"10.1037","volume":"59","author":[{"given":"Oakley","family":"Ray","sequence":"first","affiliation":[]}],"member":"15","published-online":{"date-parts":[[2004]]},"container-title":["American Psychologist"],"original-title":[],"language":"en","link":[{"URL":"http:\/\/psycnet.apa.org\/journals\/amp\/59\/1\/29.pdf","content-type":"unspecified","content-version":"vor","intended-application":"similarity-checking"}],"deposited":{"date-parts":[[2018,4,8]],"date-time":"2018-04-08T18:56:17Z","timestamp":1523213777000},"score":1.0,"subtitle":[],"short-title":[],"issued":{"date-parts":[[2004]]},"references-count":105,"journal-issue":{"published-online":{"date-parts":[[2004]]},"issue":"1"},"alternative-id":["2004-10043-004","14736318"],"URL":"http:\/\/dx.doi.org\/10.1037\/0003-066x.59.1.29","relation":{},"ISSN":["1935-990X","0003-066X"],"issn-type":[{"value":"0003-066X","type":"print"},{"value":"1935-990X","type":"electronic"}]}}"#; 642 | let work: Response = from_str(failure_str).unwrap(); 643 | 644 | assert!(work.is_work()); 645 | } 646 | } 647 | -------------------------------------------------------------------------------- /src/query/works.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::query::facet::FacetCount; 3 | use crate::query::types::Type; 4 | use crate::query::*; 5 | use chrono::NaiveDate; 6 | use serde::Serializer as SerdeSerializer; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | use std::borrow::Cow; 10 | #[cfg(feature = "cli")] 11 | use structopt::StructOpt; 12 | 13 | /// Filters allow you to narrow queries. All filter results are lists 14 | #[derive(Debug, Clone)] 15 | pub enum WorksFilter { 16 | /// metadata which includes one or more funder entry 17 | HasFunder, 18 | /// metadata which include the `id` in FundRef data 19 | Funder(String), 20 | /// funder records where location = `{country name}`. 21 | /// Only works on `/funders` route 22 | Location(String), 23 | /// metadata belonging to a DOI owner prefix `{owner_prefix}` (e.g. 10.1016 ) 24 | Prefix(String), 25 | /// metadata belonging to a Crossref member 26 | Member(String), 27 | /// metadata indexed since (inclusive) 28 | FromIndexDate(NaiveDate), 29 | /// metadata indexed before (inclusive) 30 | UntilIndexDate(NaiveDate), 31 | /// metadata last (re)deposited since (inclusive) 32 | FromDepositDate(NaiveDate), 33 | /// metadata last (re)deposited before (inclusive) 34 | UntilDepositDate(NaiveDate), 35 | /// Metadata updated since (inclusive) {date}. 36 | /// Currently the same as `from-deposit-date` 37 | FromUpdateDate(NaiveDate), 38 | /// Metadata updated before (inclusive) {date}. 39 | /// Currently the same as `until-deposit-date` 40 | UntilUpdateDate(NaiveDate), 41 | /// metadata first deposited since (inclusive) 42 | FromCreatedDate(NaiveDate), 43 | /// metadata first deposited before (inclusive) 44 | UntilCreatedDate(NaiveDate), 45 | /// metadata where published date is since (inclusive) 46 | FromPubDate(NaiveDate), 47 | /// metadata where published date is before (inclusive) 48 | UntilPubDate(NaiveDate), 49 | /// metadata where online published date is since (inclusive) 50 | FromOnlinePubDate(NaiveDate), 51 | /// metadata where online published date is before (inclusive) 52 | UntilOnlinePubDate(NaiveDate), 53 | /// metadata where print published date is since (inclusive) 54 | FromPrintPubDate(NaiveDate), 55 | /// metadata where print published date is before (inclusive) 56 | UntilPrintPubDate(NaiveDate), 57 | /// metadata where posted date is since (inclusive) 58 | FromPostedDate(NaiveDate), 59 | /// metadata where posted date is before (inclusive) 60 | UntilPostedDate(NaiveDate), 61 | /// metadata where accepted date is since (inclusive) 62 | FromAcceptedDate(NaiveDate), 63 | /// metadata where accepted date is before (inclusive) 64 | UntilAcceptedDate(NaiveDate), 65 | /// metadata that includes any `` elements. 66 | HasLicense, 67 | /// metadata where ` value equals the value 68 | LicenseUrl(String), 69 | /// metadata where the ``'s applies_to attribute is 70 | LicenseVersion(String), 71 | /// metadata where difference between publication date and the ``'s start_date attribute is <= value (in days) 72 | LicenseDelay(i32), 73 | /// metadata that includes any full text `` elements 74 | HasFullText, 75 | /// metadata where `` element's content_version attribute is the value 76 | FullTextVersion(String), 77 | /// metadata where `` element's content_type attribute is value (e.g. `application/pdf)` 78 | FullTextType(String), 79 | /// metadata where `` link has one of the following intended applications: `text-mining`, `similarity-checking` or `unspecified` 80 | FullTextApplication(String), 81 | /// metadata for works that have a list of references 82 | HasReferences, 83 | /// metadata for works where references are either `open`, `limited` (to Metadata Plus subscribers) or `closed` 84 | ReferenceVisibility(Visibility), 85 | /// metadata which include name of archive partner 86 | HasArchive, 87 | /// metadata which where value of archive partner is the value 88 | Archive(String), 89 | /// metadata which includes one or more ORCIDs 90 | HasOrcid, 91 | /// metadata which includes one or more ORCIDs where the depositing publisher claims to have witness the ORCID owner authenticate with ORCID 92 | HasAuthenticatedOrcid, 93 | /// metadata where `` element's value = the value 94 | Orcid(String), 95 | /// metadata where record has an ISSN = the value. Format is xxxx-xxxx 96 | Issn(String), 97 | /// metadata where record has an ISBN = the value 98 | Isbn(String), 99 | /// metadata records whose type = value. 100 | /// Type must be an ID value from the list of types returned by the `/types` resource 101 | Type(Type), 102 | /// metadata records whose article or serial are mentioned in the given value. 103 | /// Currently the only supported value is `doaj` 104 | Directory(String), 105 | /// metadata describing the DOI 106 | Doi(String), 107 | /// metadata for records that represent editorial updates to the DOI 108 | Updates(String), 109 | /// metadata for records that represent editorial updates 110 | IsUpdate, 111 | /// metadata for records that include a link to an editorial update policy 112 | HasUpdatePolicy, 113 | /// metadata for records with a publication title exactly with an exact match 114 | ContainerTitle(String), 115 | /// metadata for records with an exact matching category label. 116 | /// Category labels come from [this list](https://www.elsevier.com/solutions/scopus/content) published by Scopus 117 | CategoryName(String), 118 | /// metadata for records with an exacty matching type label 119 | TypeName(String), 120 | /// metadata for records with a matching award number. 121 | /// Optionally combine with `award.funder` 122 | AwardNumber(String), 123 | /// metadata for records with an award with matching funder. 124 | /// Optionally combine with `award.number` 125 | AwardFunder(String), 126 | /// metadata for records with any assertions 127 | HasAssertion, 128 | /// metadata for records with an assertion in a particular group 129 | AssertionGroup(String), 130 | /// metadata for records with a particular named assertion 131 | Assertion(String), 132 | /// metadata for records that have any affiliation information 133 | HasAffiliation, 134 | /// metadata for records with the given alternative ID, 135 | /// which may be a publisher-specific ID, or any other identifier a publisher may have provided 136 | AlternativeId, 137 | /// metadata for records with a given article number 138 | ArticleNumber, 139 | /// metadata for records which include an abstract 140 | HasAbstract, 141 | /// metadata for records which include a clinical trial number 142 | HasClinicalTrialNumber, 143 | /// metadata where the publisher records a particular domain name as the location Crossmark content will appear 144 | ContentDomain(String), 145 | /// metadata where the publisher records a domain name location for Crossmark content 146 | HasContentDomain, 147 | /// metadata where the publisher restricts Crossmark usage to content domains 148 | HasDomainRestriction, 149 | /// metadata for records that either assert or are the object of a relation 150 | HasRelation, 151 | /// One of the relation types from the Crossref relations schema 152 | /// (e.g. `is-referenced-by`, `is-parent-of`, `is-preprint-of`) 153 | RelationType, 154 | /// Relations where the object identifier matches the identifier provided 155 | RelationObject, 156 | /// One of the identifier types from the Crossref relations schema (e.g. `doi`, `issn`) 157 | RelationObjectType(String), 158 | } 159 | 160 | impl WorksFilter { 161 | /// the identifier for a the query key 162 | pub fn name(&self) -> &str { 163 | match self { 164 | WorksFilter::HasFunder => "has-funder", 165 | WorksFilter::Funder(_) => "funder", 166 | WorksFilter::Location(_) => "location", 167 | WorksFilter::Prefix(_) => "prefix", 168 | WorksFilter::Member(_) => "member", 169 | WorksFilter::FromIndexDate(_) => "from-index-date", 170 | WorksFilter::UntilIndexDate(_) => "until-index-date", 171 | WorksFilter::FromDepositDate(_) => "from-deposit-date", 172 | WorksFilter::UntilDepositDate(_) => "until-deposit-date", 173 | WorksFilter::FromUpdateDate(_) => "from-update-date", 174 | WorksFilter::UntilUpdateDate(_) => "until-update-date", 175 | WorksFilter::FromCreatedDate(_) => "from-created-date", 176 | WorksFilter::UntilCreatedDate(_) => "until-created-date", 177 | WorksFilter::FromPubDate(_) => "from-pub-date", 178 | WorksFilter::UntilPubDate(_) => "until-pub-date", 179 | WorksFilter::FromOnlinePubDate(_) => "from-online-pub-date", 180 | WorksFilter::UntilOnlinePubDate(_) => "until-online-pub-date", 181 | WorksFilter::FromPrintPubDate(_) => "from-print-pub-date", 182 | WorksFilter::UntilPrintPubDate(_) => "until-print-pub-date", 183 | WorksFilter::FromPostedDate(_) => "from-posted-date", 184 | WorksFilter::UntilPostedDate(_) => "until-posted-date", 185 | WorksFilter::FromAcceptedDate(_) => "from-accepted-date", 186 | WorksFilter::UntilAcceptedDate(_) => "until-accepted-date", 187 | WorksFilter::HasLicense => "has-license", 188 | WorksFilter::LicenseUrl(_) => "license.url", 189 | WorksFilter::LicenseVersion(_) => "license.version", 190 | WorksFilter::LicenseDelay(_) => "license.delay", 191 | WorksFilter::HasFullText => "has-full-text", 192 | WorksFilter::FullTextVersion(_) => "full-text.version", 193 | WorksFilter::FullTextType(_) => "full-text.type", 194 | WorksFilter::FullTextApplication(_) => "full-text.application", 195 | WorksFilter::HasReferences => "has-references", 196 | WorksFilter::ReferenceVisibility(_) => "reference-visibility", 197 | WorksFilter::HasArchive => "has-archive", 198 | WorksFilter::Archive(_) => "archive", 199 | WorksFilter::HasOrcid => "has-orcid", 200 | WorksFilter::HasAuthenticatedOrcid => "has-authenticated-orcid", 201 | WorksFilter::Orcid(_) => "orcid", 202 | WorksFilter::Issn(_) => "issn", 203 | WorksFilter::Isbn(_) => "isbn", 204 | WorksFilter::Type(_) => "type", 205 | WorksFilter::Directory(_) => "directory", 206 | WorksFilter::Doi(_) => "doi", 207 | WorksFilter::Updates(_) => "updates", 208 | WorksFilter::IsUpdate => "is-update", 209 | WorksFilter::HasUpdatePolicy => "has-update-policy", 210 | WorksFilter::ContainerTitle(_) => "container-title", 211 | WorksFilter::CategoryName(_) => "category-name", 212 | WorksFilter::TypeName(_) => "type-name", 213 | WorksFilter::AwardNumber(_) => "award.number", 214 | WorksFilter::AwardFunder(_) => "award.funder", 215 | WorksFilter::HasAssertion => "has-assertion", 216 | WorksFilter::AssertionGroup(_) => "assertion-group", 217 | WorksFilter::Assertion(_) => "assertion", 218 | WorksFilter::HasAffiliation => "has-affiliation", 219 | WorksFilter::AlternativeId => "alternative-id", 220 | WorksFilter::ArticleNumber => "article-number", 221 | WorksFilter::HasAbstract => "has-abstract", 222 | WorksFilter::HasClinicalTrialNumber => "has-clinical-trial-number ", 223 | WorksFilter::ContentDomain(_) => "content-domain", 224 | WorksFilter::HasContentDomain => "has-content-domain", 225 | WorksFilter::HasDomainRestriction => "has-domain-restriction", 226 | WorksFilter::HasRelation => "has-relation", 227 | WorksFilter::RelationType => "relation.type", 228 | WorksFilter::RelationObject => "relation.object", 229 | WorksFilter::RelationObjectType(_) => "relation.object-type", 230 | } 231 | } 232 | } 233 | 234 | impl ParamFragment for WorksFilter { 235 | fn key(&self) -> Cow { 236 | Cow::Borrowed(self.name()) 237 | } 238 | 239 | fn value(&self) -> Option> { 240 | match self { 241 | WorksFilter::Funder(s) 242 | | WorksFilter::Location(s) 243 | | WorksFilter::Prefix(s) 244 | | WorksFilter::Member(s) 245 | | WorksFilter::LicenseUrl(s) 246 | | WorksFilter::LicenseVersion(s) 247 | | WorksFilter::FullTextVersion(s) 248 | | WorksFilter::FullTextType(s) 249 | | WorksFilter::FullTextApplication(s) 250 | | WorksFilter::Archive(s) 251 | | WorksFilter::Orcid(s) 252 | | WorksFilter::Issn(s) 253 | | WorksFilter::Isbn(s) 254 | | WorksFilter::Directory(s) 255 | | WorksFilter::Doi(s) 256 | | WorksFilter::Updates(s) 257 | | WorksFilter::ContainerTitle(s) 258 | | WorksFilter::CategoryName(s) 259 | | WorksFilter::AwardNumber(s) 260 | | WorksFilter::TypeName(s) 261 | | WorksFilter::AwardFunder(s) 262 | | WorksFilter::AssertionGroup(s) 263 | | WorksFilter::Assertion(s) 264 | | WorksFilter::ContentDomain(s) 265 | | WorksFilter::RelationObjectType(s) => Some(Cow::Borrowed(s.as_str())), 266 | WorksFilter::ReferenceVisibility(vis) => Some(Cow::Borrowed(vis.as_str())), 267 | WorksFilter::FromIndexDate(d) 268 | | WorksFilter::UntilIndexDate(d) 269 | | WorksFilter::FromDepositDate(d) 270 | | WorksFilter::UntilDepositDate(d) 271 | | WorksFilter::FromUpdateDate(d) 272 | | WorksFilter::UntilUpdateDate(d) 273 | | WorksFilter::FromCreatedDate(d) 274 | | WorksFilter::UntilCreatedDate(d) 275 | | WorksFilter::FromPubDate(d) 276 | | WorksFilter::UntilPubDate(d) 277 | | WorksFilter::FromOnlinePubDate(d) 278 | | WorksFilter::UntilOnlinePubDate(d) 279 | | WorksFilter::FromPrintPubDate(d) 280 | | WorksFilter::UntilPrintPubDate(d) 281 | | WorksFilter::FromPostedDate(d) 282 | | WorksFilter::UntilPostedDate(d) 283 | | WorksFilter::FromAcceptedDate(d) 284 | | WorksFilter::UntilAcceptedDate(d) => { 285 | Some(Cow::Owned(d.format("%Y-%m-%d").to_string())) 286 | } 287 | WorksFilter::Type(t) => Some(Cow::Borrowed(t.id())), 288 | _ => Some(Cow::Borrowed("true")), 289 | } 290 | } 291 | } 292 | 293 | impl Filter for WorksFilter {} 294 | 295 | /// Field queries are available on the `/works` route and allow for queries that match only particular fields of metadata. 296 | #[derive(Debug, Clone)] 297 | #[cfg_attr(feature = "cli", derive(StructOpt))] 298 | pub struct FieldQuery { 299 | /// match any only particular fields of metadata. 300 | pub name: String, 301 | /// the value of the query 302 | pub value: String, 303 | } 304 | 305 | impl FieldQuery { 306 | /// creates a new `Field` query for `title` and `subtitle` 307 | pub fn title(title: &str) -> Self { 308 | Self { 309 | name: "title".to_string(), 310 | value: title.to_string(), 311 | } 312 | } 313 | 314 | /// creates a new `Field` query for `container-title` aka `publication.name` 315 | pub fn container_title(container_title: &str) -> Self { 316 | Self { 317 | name: "container-title".to_string(), 318 | value: container_title.to_string(), 319 | } 320 | } 321 | /// creates a new `Field` query author given and family names 322 | pub fn author(author: &str) -> Self { 323 | Self { 324 | name: "author".to_string(), 325 | value: author.to_string(), 326 | } 327 | } 328 | /// creates a new `Field` query for editor given and family names 329 | pub fn editor(editor: &str) -> Self { 330 | Self { 331 | name: "editor".to_string(), 332 | value: editor.to_string(), 333 | } 334 | } 335 | /// creates a new `Field` query for chair given and family names 336 | pub fn chair(chair: &str) -> Self { 337 | Self { 338 | name: "chair".to_string(), 339 | value: chair.to_string(), 340 | } 341 | } 342 | /// creates a new `Field` query for translator given and family names 343 | pub fn translator(translator: &str) -> Self { 344 | Self { 345 | name: "translator".to_string(), 346 | value: translator.to_string(), 347 | } 348 | } 349 | /// creates a new `Field` query for author, editor, chair and translator given and family names 350 | pub fn contributor(contributor: &str) -> Self { 351 | Self { 352 | name: "contributor".to_string(), 353 | value: contributor.to_string(), 354 | } 355 | } 356 | /// creates a new `Field` query for bibliographic information, useful for citation look up. 357 | /// Includes titles, authors, ISSNs and publication years 358 | pub fn bibliographic(bibliographic: &str) -> Self { 359 | Self { 360 | name: "bibliographic".to_string(), 361 | value: bibliographic.to_string(), 362 | } 363 | } 364 | /// creates a new `Field` query for contributor affiliations 365 | pub fn affiliation(affiliation: &str) -> Self { 366 | Self { 367 | name: "affiliation".to_string(), 368 | value: affiliation.to_string(), 369 | } 370 | } 371 | } 372 | 373 | impl CrossrefQueryParam for FieldQuery { 374 | fn param_key(&self) -> Cow { 375 | Cow::Borrowed(&self.name) 376 | } 377 | fn param_value(&self) -> Option> { 378 | Some(Cow::Owned(format_query(&self.value))) 379 | } 380 | } 381 | 382 | /// limits from where and how many `Work` items should be returned 383 | #[derive(Debug, Clone)] 384 | pub enum WorkResultControl { 385 | /// use the standard ResultControl available for all components 386 | Standard(ResultControl), 387 | /// If you are expecting results beyond 10K, then use a cursor to deep page through the results 388 | Cursor { 389 | /// the cursor token provided by crossref when initially set to a value of `*` 390 | token: Option, 391 | /// limit the results 392 | rows: Option, 393 | }, 394 | } 395 | 396 | impl WorkResultControl { 397 | /// set a cursor with `*` value, a new cursor will be provided in the `next-cursor` field of the result 398 | pub fn new_cursor() -> Self { 399 | WorkResultControl::Cursor { 400 | token: None, 401 | rows: None, 402 | } 403 | } 404 | 405 | /// create a new Cursor with only a token value 406 | pub fn cursor(token: &str) -> Self { 407 | WorkResultControl::Cursor { 408 | token: Some(token.to_string()), 409 | rows: None, 410 | } 411 | } 412 | } 413 | 414 | impl Default for WorkResultControl { 415 | fn default() -> Self { 416 | WorkResultControl::new_cursor() 417 | } 418 | } 419 | 420 | impl CrossrefQueryParam for WorkResultControl { 421 | fn param_key(&self) -> Cow { 422 | match self { 423 | WorkResultControl::Standard(s) => s.param_key(), 424 | WorkResultControl::Cursor { token, .. } => Cow::Owned(format!( 425 | "cursor={}", 426 | token.as_ref().map(String::as_str).unwrap_or("*") 427 | )), 428 | } 429 | } 430 | 431 | fn param_value(&self) -> Option> { 432 | match self { 433 | WorkResultControl::Standard(s) => s.param_value(), 434 | WorkResultControl::Cursor { rows, .. } => match rows { 435 | Some(r) => Some(Cow::Owned(format!("rows={}", r))), 436 | _ => None, 437 | }, 438 | } 439 | } 440 | } 441 | /// 442 | /// Retrieve a publication by DOI 443 | /// 444 | /// # Example 445 | /// 446 | /// ```edition2018 447 | /// use crossref::Works; 448 | /// 449 | /// let works = Works::doi("10.1037/0003-066X.59.1.29"); 450 | /// ``` 451 | /// 452 | /// Target the agency of a specific publication, where the str supplied is corresponded to the publication's DOI 453 | /// 454 | /// # Example 455 | /// 456 | /// ```edition2018 457 | /// use crossref::Works; 458 | /// 459 | /// let works = Works::agency_for_doi("10.1037/0003-066X.59.1.29"); 460 | /// ``` 461 | #[derive(Debug, Clone)] 462 | pub enum Works { 463 | /// target a Work by a specific id 464 | Identifier(String), 465 | /// target Works by a query 466 | Query(WorksQuery), 467 | /// return the registration agency for a DOI 468 | Agency(String), 469 | } 470 | 471 | impl Works { 472 | /// create a new `Works::Identifier` by converting `doi` to a `String` 473 | pub fn doi(doi: &str) -> Self { 474 | Works::Identifier(doi.to_string()) 475 | } 476 | /// create a new `Works::Agency` targeting the registration agency for the DOI 477 | pub fn agency_for_doi(doi: &str) -> Self { 478 | Works::Agency(doi.to_string()) 479 | } 480 | } 481 | 482 | impl CrossrefRoute for Works { 483 | fn route(&self) -> Result { 484 | match self { 485 | Works::Identifier(s) => Ok(format!("{}/{}", Component::Works.route()?, s)), 486 | Works::Agency(s) => Ok(format!("{}/{}/agency", Component::Works.route()?, s)), 487 | Works::Query(query) => query.route(), 488 | } 489 | } 490 | } 491 | 492 | impl CrossrefQuery for Works { 493 | fn resource_component(self) -> ResourceComponent { 494 | ResourceComponent::Works(self) 495 | } 496 | } 497 | 498 | /// Wraps queries that target `WorkList`, either directly or combined 499 | #[derive(Debug, Clone)] 500 | #[allow(missing_docs)] 501 | pub enum WorkListQuery { 502 | /// Target `Works` directly 503 | Works(WorksQuery), 504 | /// Target the corresponding `Works` of a specific `Component` 505 | Combined { 506 | primary_component: Component, 507 | ident: WorksIdentQuery, 508 | }, 509 | } 510 | 511 | impl WorkListQuery { 512 | /// the underlying `WorksQuery` 513 | pub fn query(&self) -> &WorksQuery { 514 | match self { 515 | WorkListQuery::Works(query) => query, 516 | WorkListQuery::Combined { ident, .. } => &ident.query, 517 | } 518 | } 519 | 520 | /// mut reference to the underlying `Worksquery` 521 | pub fn query_mut(&mut self) -> &mut WorksQuery { 522 | match self { 523 | WorkListQuery::Works(query) => query, 524 | WorkListQuery::Combined { ident, .. } => &mut ident.query, 525 | } 526 | } 527 | } 528 | 529 | impl Into for WorksQuery { 530 | fn into(self) -> WorkListQuery { 531 | WorkListQuery::Works(self) 532 | } 533 | } 534 | 535 | impl From for WorkListQuery { 536 | fn from(term: T) -> Self { 537 | WorkListQuery::Works(WorksQuery::new(term)) 538 | } 539 | } 540 | 541 | impl CrossrefRoute for WorkListQuery { 542 | fn route(&self) -> Result { 543 | match self { 544 | WorkListQuery::Works(query) => query.route(), 545 | WorkListQuery::Combined { 546 | primary_component, 547 | ident, 548 | } => Ok(format!( 549 | "{}/{}{}", 550 | primary_component.route()?, 551 | ident.id, 552 | ident.query.route()? 553 | )), 554 | } 555 | } 556 | } 557 | 558 | impl CrossrefQuery for WorkListQuery { 559 | fn resource_component(self) -> ResourceComponent { 560 | match self { 561 | WorkListQuery::Works(query) => ResourceComponent::Works(Works::Query(query)), 562 | WorkListQuery::Combined { 563 | primary_component, 564 | ident, 565 | } => match primary_component { 566 | Component::Funders => ResourceComponent::Funders(Funders::Works(ident)), 567 | Component::Journals => ResourceComponent::Journals(Journals::Works(ident)), 568 | Component::Members => ResourceComponent::Members(Members::Works(ident)), 569 | Component::Prefixes => ResourceComponent::Prefixes(Prefixes::Works(ident)), 570 | Component::Types => ResourceComponent::Types(Types::Works(ident)), 571 | Component::Works => ResourceComponent::Works(Works::Query(ident.query)), 572 | }, 573 | } 574 | } 575 | } 576 | 577 | /// Target `Works` as secondary resource component 578 | /// 579 | /// # Example 580 | /// 581 | /// ```edition2018 582 | /// use crossref::{WorksIdentQuery, WorksQuery}; 583 | /// 584 | /// let combined = WorksIdentQuery::new("100000015", WorksQuery::new("ontologies")); 585 | /// 586 | /// ``` 587 | /// Is equal to create a `WorksIdentQuery` from a `WorksQuery` 588 | /// 589 | /// ```edition2018 590 | /// use crossref::WorksQuery; 591 | /// 592 | /// let combined = WorksQuery::new("ontologies").into_ident("100000015"); 593 | /// 594 | /// ``` 595 | /// helper struct to capture an id for a `Component` other than `/works` and an additional query for the `/works` route 596 | #[derive(Debug, Clone)] 597 | pub struct WorksIdentQuery { 598 | /// the id of an component item 599 | pub id: String, 600 | /// the query to filter the works results 601 | pub query: WorksQuery, 602 | } 603 | 604 | impl WorksIdentQuery { 605 | /// create a new Ident Query for the `id` 606 | pub fn new>(id: T, query: WorksQuery) -> Self { 607 | WorksIdentQuery { 608 | id: id.into(), 609 | query, 610 | } 611 | } 612 | } 613 | 614 | /// Trait to determine that the type can be used in a combined query 615 | pub trait WorksCombiner { 616 | /// the primary component of this type 617 | fn primary_component() -> Component; 618 | 619 | /// construct a new type 620 | fn ident_query(ident: WorksIdentQuery) -> Self; 621 | 622 | /// the combined crossref route 623 | fn combined_route(ident: &WorksIdentQuery) -> Result { 624 | Ok(format!( 625 | "{}/{}{}", 626 | Self::primary_component().route()?, 627 | ident.id, 628 | ident.query.route()? 629 | )) 630 | } 631 | 632 | /// create a new combined `WorkListQuery` with the primary component 633 | fn work_list_query(ident: WorksIdentQuery) -> WorkListQuery { 634 | WorkListQuery::Combined { 635 | primary_component: Self::primary_component(), 636 | ident, 637 | } 638 | } 639 | } 640 | 641 | macro_rules! impl_combiner { 642 | ($($name:ident,)*) => { 643 | $( 644 | impl WorksCombiner for $name { 645 | fn primary_component() -> Component { 646 | Component::$name 647 | } 648 | 649 | fn ident_query(ident: WorksIdentQuery) -> Self { 650 | $name::Works(ident) 651 | } 652 | } 653 | )+ 654 | }; 655 | } 656 | 657 | impl_combiner!(Journals, Funders, Members, Prefixes, Types,); 658 | 659 | impl WorksQuery { 660 | /// alias for creating an empty default element 661 | pub fn empty() -> Self { 662 | WorksQuery::default() 663 | } 664 | 665 | /// creates an new `WorksQuery` with the desired sample size that will result in 666 | /// a request for random dois 667 | pub fn random(len: usize) -> Self { 668 | WorksQuery::default().sample(len) 669 | } 670 | 671 | /// Convenience method to create a new `WorksQuery` with a term directly 672 | pub fn new(query: T) -> Self { 673 | WorksQuery::empty().query(query) 674 | } 675 | 676 | /// add a new free form query 677 | pub fn sample(mut self, len: usize) -> Self { 678 | self.sample = Some(len); 679 | self 680 | } 681 | 682 | /// add a new free form query 683 | pub fn query(mut self, query: T) -> Self { 684 | self.free_form_queries.push(query.to_string()); 685 | self 686 | } 687 | 688 | /// Create a new query for the topics renear+ontologies 689 | /// 690 | /// # Example 691 | /// 692 | /// ```edition2018 693 | /// use crossref::WorksQuery; 694 | /// 695 | /// let query = WorksQuery::default().queries(&["renear", "ontologies"]); 696 | /// ``` 697 | /// add a bunch of free form query terms 698 | pub fn queries(mut self, queries: &[T]) -> Self { 699 | self.free_form_queries 700 | .extend(queries.iter().map(T::to_string)); 701 | self 702 | } 703 | 704 | /// add a new field query form query 705 | pub fn field_query(mut self, query: FieldQuery) -> Self { 706 | self.field_queries.push(query); 707 | self 708 | } 709 | 710 | /// ```edition2018 711 | /// use crossref::{FieldQuery,WorksQuery}; 712 | /// 713 | /// let query = WorksQuery::default().field_queries(vec![FieldQuery::title("room at the bottom"), FieldQuery::author("richard feynman")]); 714 | /// ``` 715 | /// add a bunch of free form query terms 716 | pub fn field_queries(mut self, queries: Vec) -> Self { 717 | self.field_queries.extend(queries.into_iter()); 718 | self 719 | } 720 | 721 | /// add a new filter to the query 722 | pub fn filter(mut self, filter: WorksFilter) -> Self { 723 | self.filter.push(filter); 724 | self 725 | } 726 | 727 | /// set sort option to the query 728 | pub fn sort(mut self, sort: Sort) -> Self { 729 | self.sort = Some(sort); 730 | self 731 | } 732 | 733 | /// set order option to query 734 | pub fn order(mut self, order: Order) -> Self { 735 | self.order = Some(order); 736 | self 737 | } 738 | 739 | /// add another facet to query 740 | pub fn facet(mut self, facet: FacetCount) -> Self { 741 | self.facets.push(facet); 742 | self 743 | } 744 | 745 | /// set the cursor for result control deep paging 746 | pub fn next_cursor(mut self, cursor: &str) -> Self { 747 | let rows = match self.result_control { 748 | Some(WorkResultControl::Standard(ResultControl::Rows(rows))) => Some(rows), 749 | _ => None, 750 | }; 751 | self.result_control = Some(WorkResultControl::Cursor { 752 | token: Some(cursor.to_string()), 753 | rows, 754 | }); 755 | self 756 | } 757 | 758 | /// set an empty cursor 759 | pub fn new_cursor(mut self) -> Self { 760 | self.result_control = Some(WorkResultControl::new_cursor()); 761 | self 762 | } 763 | /// set result control option to query 764 | pub fn result_control(mut self, result_control: WorkResultControl) -> Self { 765 | self.result_control = Some(result_control); 766 | self 767 | } 768 | 769 | /// Wrap the query in a combined query. 770 | /// 771 | /// # Example 772 | /// Create a Funders Query that targets all works of a funder with id `funder id`. 773 | /// 774 | /// ```edition2018 775 | /// # use crossref::{WorksQuery, Funders}; 776 | /// let funders: Funders = WorksQuery::default().into_combined("funder id"); 777 | /// ``` 778 | pub fn into_combined(self, id: &str) -> W { 779 | W::ident_query(self.into_ident(id)) 780 | } 781 | 782 | /// Bind the query to a specific id of a primary endpoint element 783 | pub fn into_ident(self, id: &str) -> WorksIdentQuery { 784 | WorksIdentQuery::new(id, self) 785 | } 786 | 787 | /// wrap this query in new `WorkListQuery` that targets the `/works` route of a primary component with an id. 788 | /// The query will evaluate to the same as [`into_combined`] 789 | /// 790 | /// # Example 791 | /// 792 | /// Create a query that targets all `Works` of a funder with id `funder id` 793 | /// 794 | /// ```edition2018 795 | /// # use crossref::{WorksQuery, Funders}; 796 | /// let query = WorksQuery::default() 797 | /// .into_combined_query::("funder id"); 798 | /// 799 | /// ``` 800 | pub fn into_combined_query(self, id: &str) -> WorkListQuery { 801 | W::work_list_query(self.into_ident(id)) 802 | } 803 | } 804 | 805 | /// Used to construct a query that targets crossref `Works` elements 806 | /// 807 | /// # Example 808 | /// 809 | /// ```edition2018 810 | /// use crossref::{Order, WorksQuery}; 811 | /// 812 | /// // create a new query for topcis machine+learning ordered desc 813 | /// let query = WorksQuery::new("machine learning").order(Order::Desc); 814 | /// ``` 815 | /// 816 | /// Each query parameter is ANDed 817 | #[derive(Debug, Clone, Default)] 818 | pub struct WorksQuery { 819 | /// search by non specific query 820 | pub free_form_queries: Vec, 821 | /// match only particular fields of metadata 822 | pub field_queries: Vec, 823 | /// filter to apply while querying 824 | pub filter: Vec, 825 | /// sort results by a certain field and 826 | pub sort: Option, 827 | /// set the sort order to `asc` or `desc` 828 | pub order: Option, 829 | /// enable facet information in responses 830 | pub facets: Vec, 831 | /// deep page through `/works` result sets 832 | pub result_control: Option, 833 | /// request random dois 834 | /// if set all other parameters are ignored 835 | pub sample: Option, 836 | } 837 | 838 | impl CrossrefRoute for WorksQuery { 839 | fn route(&self) -> Result { 840 | let mut params = Vec::new(); 841 | 842 | if let Some(sample) = self.sample { 843 | return Ok(format!("sample={}", sample)); 844 | } 845 | 846 | if !self.free_form_queries.is_empty() { 847 | params.push(Cow::Owned(format!( 848 | "query={}", 849 | format_queries(&self.free_form_queries) 850 | ))); 851 | } 852 | if !self.field_queries.is_empty() { 853 | params.extend(self.field_queries.iter().map(CrossrefQueryParam::param)) 854 | } 855 | if !self.filter.is_empty() { 856 | params.push(self.filter.param()); 857 | } 858 | if !self.facets.is_empty() { 859 | params.push(self.facets.param()); 860 | } 861 | if let Some(sort) = &self.sort { 862 | params.push(sort.param()); 863 | } 864 | if let Some(order) = &self.order { 865 | params.push(order.param()); 866 | } 867 | if let Some(rc) = &self.result_control { 868 | params.push(rc.param()); 869 | } 870 | 871 | Ok(format!( 872 | "{}?{}", 873 | Component::Works.route()?, 874 | params.join("&") 875 | )) 876 | } 877 | } 878 | 879 | impl CrossrefParams for WorksQuery { 880 | type Filter = WorksFilter; 881 | 882 | fn query_terms(&self) -> &[String] { 883 | &self.free_form_queries 884 | } 885 | fn filters(&self) -> &[Self::Filter] { 886 | &self.filter 887 | } 888 | fn sort(&self) -> Option<&Sort> { 889 | self.sort.as_ref() 890 | } 891 | fn order(&self) -> Option<&Order> { 892 | self.order.as_ref() 893 | } 894 | fn facets(&self) -> &[FacetCount] { 895 | &self.facets 896 | } 897 | fn result_control(&self) -> Option<&ResultControl> { 898 | if let Some(WorkResultControl::Standard(ref std)) = self.result_control { 899 | Some(std) 900 | } else { 901 | None 902 | } 903 | } 904 | } 905 | 906 | #[cfg(test)] 907 | mod tests { 908 | use super::*; 909 | 910 | #[test] 911 | fn serialize_works_ident() { 912 | let works = Works::doi("10.1037/0003-066X.59.1.29"); 913 | 914 | assert_eq!("/works/10.1037/0003-066X.59.1.29", &works.route().unwrap()) 915 | } 916 | } 917 | --------------------------------------------------------------------------------