commit 68ae9d4791853e956dd51df3026abc58a71d42ba Author: Andreas Granig Date: Mon Jan 13 13:55:36 2014 +0100 Move Data::HAL to Sipwise repo. This library has been developed by Lars Dieckow for Sipwise GmbH, who is the copyright holder. diff --git a/Build.PL b/Build.PL new file mode 100644 index 0000000..c5e8ffb --- /dev/null +++ b/Build.PL @@ -0,0 +1,56 @@ +use Module::Build qw(); +Module::Build->new( + add_to_cleanup => [ 'Data-HAL-*' ], + build_requires => { + 'Data::Visitor::Callback' => 0, + 'File::Slurp' => 0, + 'JSON' => 0, + 'Log::Any::Adapter' => 0, + 'Log::Any::Adapter::FileHandle' => 0, + 'strictures' => 0, + 'Test::Fatal' => 0, + 'Test::More' => 0, + }, + configure_requires => { + 'Module::Build' => 0.40, + }, + dist_version_from => 'lib/Data/HAL.pm', + dynamic_config => 0, + license => 'perl', + meta-spec => { + url => 'http://module-build.sourceforge.net/META-spec-v1.4.html', + version => '1.4', + }, + module_name => 'Data::HAL', + no_index => { + file => [ + 'lib/Data/HAL/URI/NamespaceMap.pm', + ], + }, + requires => { + 'boolean' => 0, + 'Clone' => 0, + 'Data::Visitor::Callback' => 0, + 'failures' => 0, + 'HTTP::Headers::Util' => 0, + 'JSON' => 0, + 'Log::Any' => 0, + 'MIME::Type' => 0, + 'Moo' => 0, + 'perl' => '5.008', + 'Safe::Isa' => 0, + 'Scalar::Util' => 0, + 'strictures' => 0, + 'Types::Standard' => 0, + 'URI' => 0, + 'URI::IRI' => 0, + 'URI::NamespaceMap' => 0, + 'URI::Template' => 0, + 'XML::RegExp' => 0, + }, + resources => { + license => 'http://dev.perl.org/licenses/', + bugtracker => 'http://rt.cpan.org/NoAuth/Bugs.html?Dist=Data-HAL', + repository => 'https://github.com/daxim/Data-HAL', + }, +)->create_build_script; diff --git a/Changes b/Changes new file mode 100644 index 0000000..f4946c6 --- /dev/null +++ b/Changes @@ -0,0 +1,9 @@ +=encoding UTF-8 + +=head1 NAME + +Changes - Revision history for Data-HAL + +=head2 1.000 2013-10-15 + +Initial release. diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP new file mode 100644 index 0000000..3a3a788 --- /dev/null +++ b/MANIFEST.SKIP @@ -0,0 +1,63 @@ +# Avoid version control files. +\bRCS(?!\.pm)\b +\bCVS(?!\.pm)\b +\bSCCS(?!\.pm)\b +,v$ +\B\.svn\b +\B\.git\b +\B\.gitignore\b +\b_darcs\b +\B\.cvsignore$ + +# Avoid VMS specific MakeMaker generated files +\bDescrip.MMS$ +\bDESCRIP.MMS$ +\bdescrip.mms$ + +# Avoid Makemaker generated and utility files. +\bMANIFEST\.bak +\bMakefile$ +\bblib/ +\bMakeMaker-\d +\bpm_to_blib\.ts$ +\bpm_to_blib$ +\bblibdirs\.ts$ # 6.18 through 6.25 generated this + +# Avoid Module::Build generated and utility files. +\bBuild$ +\b_build/ +\bBuild.bat$ +\bBuild.COM$ +\bBUILD.COM$ +\bbuild.com$ +^MANIFEST\.SKIP + +# Avoid temp and backup files. +~$ +\.old$ +\#$ +\b\.# +\.bak$ +\.tmp$ +\.# +\.rej$ +\..*\.sw.?$ + +# Avoid OS-specific files/dirs +# Mac OSX metadata +\B\.DS_Store +# Mac OSX SMB mount metadata files +\B\._ + +# Avoid Devel::Cover and Devel::CoverX::Covered files. +\bcover_db\b +\bcovered\b + +# Avoid prove files +\B\.prove$ + +# Avoid configuration metadata file +^MYMETA\. + +# Avoid archives of this distribution +\bData-HAL-[\d\.\_]+ diff --git a/README b/README new file mode 100644 index 0000000..6a36aba --- /dev/null +++ b/README @@ -0,0 +1,26 @@ +=encoding UTF-8 + +Data-HAL version 1.000 is Copyright © 2013 Sipwise GmbH +Author: Lars Dieckow + +=head1 NAME + +README - basic information for users prior to downloading + +=head1 INSTALLATION + +See L, +L, +L. + +=head1 LICENCE AND COPYRIGHT + +Copyright © 2013 Sipwise GmbH C<< >> + +This library is free software; you can redistribute it and/or modify it +under the same terms as Perl 5.18.0. + +=head2 Disclaimer of warranty + +This library is distributed in the hope that it will be useful, but without +any warranty; without even the implied warranty of merchantability or fitness diff --git a/lib/Data/HAL.pm b/lib/Data/HAL.pm new file mode 100644 index 0000000..3806cfb --- /dev/null +++ b/lib/Data/HAL.pm @@ -0,0 +1,432 @@ +package Data::HAL; +use strictures; +use boolean qw(false true); +use Clone qw(clone); +use Data::HAL::Link qw(); +use Data::HAL::URI qw(); +use Data::HAL::URI::NamespaceMap qw(); +use Data::Visitor::Callback qw(); +use failures qw(Data::HAL::InvalidJSON); +use HTTP::Headers::Util qw(join_header_words); +use JSON qw(); +use Moo; # has +use Safe::Isa qw($_isa); +use Scalar::Util qw(reftype); +use Types::Standard qw(ArrayRef Bool HashRef InstanceOf Str); + +our $VERSION = '1.000'; + +my $uri_from_str = sub { + my ($val) = @_; + return $val->$_isa('Data::HAL::URI') ? $val : Data::HAL::URI->new($val); +}; + +has('embedded', is => 'rw', isa => ArrayRef[InstanceOf['Data::HAL']]); +has('links', is => 'rw', isa => ArrayRef[InstanceOf['Data::HAL::Link']]); +has('resource', is => 'rw', isa => HashRef, default => sub {return {};}); +has('relation', is => 'rw', isa => InstanceOf['Data::HAL::URI'], coerce => $uri_from_str); +has('_nsmap', is => 'rw', isa => InstanceOf['Data::HAL::URI::NamespaceMap']); +has('_recursing', is => 'ro', isa => Bool); + +sub BUILD { + my ($self) = @_; + $self->_expand_curies unless $self->_recursing; + return; +} + +sub from_json { + my ($self, $json, $relation) = @_; + my $nested = clone JSON::from_json($json); + failure::Data::HAL::InvalidJSON->throw('not a JSON object') unless reftype $nested eq reftype {}; + return $self->_from_nested($self->_boolify($nested), $relation)->_expand_curies; +} + +sub _boolify { + my ($self, $nested) = @_; + my $visited = Data::Visitor::Callback->new( + object => sub { + my (undef, $val) = @_; + if (JSON::is_bool($val)) { + $val = false if $val == JSON::false; + $val = true if $val == JSON::true; + } + return $val; + } + )->visit($nested); + return $visited; +} + +sub _from_nested { + my ($class, $nested, $relation, $_recursing) = @_; + my $embedded = delete $nested->{_embedded}; + my $links = delete $nested->{_links}; + my $self = $class->new( + $relation ? (relation => $relation) : (), + resource => $nested, + $_recursing ? (_recursing => $_recursing) : (), + ); + if ($embedded) { + $self->embedded([]); + for my $k (keys %{ $embedded }) { + push @{ $self->embedded }, (reftype $embedded->{$k} eq reftype []) + ? map { $self->_from_nested($_, $k, 1) } @{ $embedded->{$k} } + : $self->_from_nested($embedded->{$k}, $k, 1); + } + } + if ($links) { + $self->links([]); + for my $k (keys %{ $links }) { + push @{ $self->links }, (reftype $links->{$k} eq reftype []) + ? map { Data::HAL::Link->new(%{ $_ }, relation => $k) } @{ $links->{$k} } + : Data::HAL::Link->new(%{ $links->{$k} }, relation => $k); + } + } + return $self; +} + +sub _expand_curies { + my ($self) = @_; + my $nsmap = Data::HAL::URI::NamespaceMap->new; + if ($self->links) { + for my $l (@{ $self->links }) { + if ('curies' eq $l->relation->as_string) { + $nsmap->add_mapping($l->name, $l->href); + } + } + } + $self->_nsmap($nsmap); + $self->_recurse_curies($self); + return $self; +} + +sub _recurse_curies { + my ($self, $root) = @_; + if ($self->relation) { + my $uri = $root->_nsmap->uri($self->relation->as_string); + if ($uri) { + $self->relation(Data::HAL::URI->new( + uri => $uri->uri, + _original => $self->relation->_original, + )); + } + } + if ($self->links) { + for my $l (@{ $self->links }) { + my $uri = $root->_nsmap->uri($l->relation->as_string($root)); + next unless $uri; + $l->relation(Data::HAL::URI->new( + uri => $uri->uri, + _original => $l->relation->_original, + )); + } + } + if ($self->embedded) { + for my $e (@{ $self->embedded }) { + $e->_recurse_curies($root); + } + } + return $self; +} + +sub _to_nested { + my ($self, $root) = @_; + if ($self->relation) { + $self->relation(Data::HAL::URI->new( + uri => URI->new($self->relation->_original), + _original => $self->relation->_original, + )); + } + my $hal = clone $self->resource; + for my $prop (qw(links embedded)) { + if ($self->$prop) { + for my $p (@{ $self->$prop }) { + my ($nested, $r) = $p->_to_nested($root); + if (exists $hal->{"_$prop"}{$r}) { + if (reftype $hal->{"_$prop"}{$r} eq reftype []) { + push @{ $hal->{"_$prop"}{$r} }, $nested; + } else { + my $attr = delete $hal->{"_$prop"}{$r}; + push @{ $hal->{"_$prop"}{$r} }, $attr, $nested; + } + } else { + $hal->{"_$prop"}{$r} = $nested; + } + } + } + } + return($hal, $self->relation ? $self->relation->as_string : ()); +} + +sub TO_JSON { + my ($self) = @_; + my ($nested) = $self->_to_nested($self); + my $visited = Data::Visitor::Callback->new( + boolean => sub { + my (undef, $val) = @_; + return $val ? JSON::true : JSON::false; + } + )->visit($nested); + return $visited; +} + +sub as_json { + my ($self) = @_; + return JSON::to_json($self->TO_JSON, { canonical => 1, pretty => 1, utf8 => 1 }); +} + +sub http_headers { + my ($self) = @_; + my @headers; + if ($self->links) { + if (my ($profile_link) = grep { 'profile' eq $_->relation->as_string } @{ $self->links }) { + push @headers, 'Content-Type' => join_header_words( + 'application/hal+json' => undef, profile => $profile_link->href->as_string + ); + } else { + push @headers, 'Content-Type' => 'application/hal+json'; + } + push @headers, + map { (Link => $_->as_http_link_value) } + grep { 'curies' ne $_->relation->as_string } + @{ $self->links }; + } + return @headers; +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Data::HAL - Hypertext Application Language resource + +=head1 VERSION + +This document describes Data::HAL version 1.000 + +=head1 SYNOPSIS + + use Data::HAL qw(); + use Data::HAL::Link qw(); + { + my $hal = Data::HAL->from_json($json_str); + my $resource_member_data_href = $hal->resource; + my $links_aref = $hal->links; + my $embedded_resources_aref = $hal->embedded; + } + + { + my $hal = Data::HAL->new( + resource => {foo => 23, bar => 42}, + links => [Data::HAL::Link->new(relation => 'self', href => '/')], + ); + + my $json_str = $hal->as_json; + # { + # "_links" : { + # "self" : { + # "href" : "/" + # } + # }, + # "bar" : 42, + # "foo" : 23 + # } + + my @headers = $hal->http_headers; + # ( + # 'Content-Type' => 'application/hal+json', + # 'Link' => ';rel="self"' + # ) + } + +=head1 DESCRIPTION + +HAL is a format you can use in your hypermedia API. + +=head2 Introduction + +This section is completely quoted from the specification: + +There is an emergence of non-HTML HTTP applications ("Web APIs") +which use hyperlinks to direct clients around their resources. + +The JSON Hypertext Application Language (HAL) is a standard which +establishes conventions for expressing hypermedia controls, such as +links, with JSON. + +HAL is a generic media type with which Web APIs can be developed and +exposed as series of links. Clients of these APIs can select links +by their link relation type and traverse them in order to progress +through the application. + +HAL's conventions result in a uniform interface for serving and +consuming hypermedia, enabling the creation of general-purpose +libraries that can be re-used on any API utilising HAL. + +The primary design goals of HAL are generality and simplicity. HAL +can be applied to many different domains, and imposes the minimal +amount of structure necessary to cover the key requirements of a +hypermedia Web API. + +=head2 Conformance + +The author claims to conform with L, published 2013-10-03. + +=head1 INTERFACE + +=head2 Composition + +None. + +=head2 Constructors + +=head3 C + + Data::HAL->from_json($json_str) + +Takes a HAL+JSON document as string and returns a C object. + +=head3 C + + Data::HAL->new( + resource => {foo => 23, bar => 42}, + links => [Data::HAL::Link->new(relation => 'self', href => '/')] + ) + +Default L constructor, returns a C object. + +=head2 Attributes + +=head3 C + +Type C, +L<< embedded resource objects|http://tools.ietf.org/html/draft-kelly-json-hal#section-4.1.2 >> + +=head3 C + +Type C, +L<< link objects|http://tools.ietf.org/html/draft-kelly-json-hal#section-4.1.1 >> + +=head3 C + +Type C, member data +L<< representing the current state of the resource|http://tools.ietf.org/html/draft-kelly-json-hal#section-4 >> + +=head3 C + +Type L, +L<< identifier of the semantics of a link|http://tools.ietf.org/html/rfc5988#section-4 >> + +Perl strings are coerced to the L type. + +A stand-alone HAL+JSON document, when deserialised, will not have this attribute set in the root resource since nothing +is linking to the document. + +=head2 Methods + +=head3 C + +Serialisation hook for the L (or compatible) module. + +This method is not intended to be called directly from your code. Instead call L or +C<< JSON::to_json $hal, { convert_blessed => 1 } >> or similar. + +=head3 C + +Returns the resource object serialised as a HAL+JSON document string. + +=head3 C + +Returns a list of pairs of HTTP message headers. The keys are field name strings and the values are field content +strings. B: since field names can repeat, assigning this list to a hash loses information. + +The list is suitable as input for e.g. the +L<< C accessor in HTTP::Headers|HTTP::Headers/$h->header( $f1 => $v1, $f2 => $v2, ... ) >> +or the L<< C attribute in Plack::Response|Plack::Response/headers >>. + +=head4 C + +The value is C, perhaps with a C parameter. + +=head4 C + +See L. + +=head2 Exports + +None. + +=head1 DIAGNOSTICS + +=head2 C + +The L constructor throws this exception of type C when the JSON input is a +malformed HAL+JSON document. + +=head1 CONFIGURATION AND ENVIRONMENT + +Requires no configuration files or environment variables. + +=head1 DEPENDENCIES + +See meta file in the source distribution. + +=head1 INCOMPATIBILITIES + +None reported. + +=head1 BUGS AND LIMITATIONS + +Please report any bugs or feature requests to C, or through the web interface at +L. + +=head2 tight coupling to JSON + +It is currently difficult to exert control over the specifics of (de)serialisation, perhaps put the (de)serialisers +into attributes? + +=head2 error type is an attribute, not a class name + +It is not worth it to design an error class hierarchy for a single error. + +=head2 Data::HAL::URI::NamespaceMap is undocumented + +It is used only internally. + +=head1 TO DO + +=over + +=item make everything cache-friendly + +=item non-standard accessors for link objects + +=item support §8.3. cache pattern + +=item support HAL XML + +=back + +=head1 SEE ALSO + +L, the more mature, featureful hypermedia protocol + +=head1 AUTHOR + +Lars Dɪᴇᴄᴋᴏᴡ C<< >> + +=head1 LICENCE AND COPYRIGHT + +Copyright © 2013 Lars Dɪᴇᴄᴋᴏᴡ C<< >> + +This library is free software; you can redistribute it and/or modify it +under the same terms as Perl 5.18.0. + +=head2 Disclaimer of warranty + +This library is distributed in the hope that it will be useful, but without +any warranty; without even the implied warranty of merchantability or fitness +for a particular purpose. diff --git a/lib/Data/HAL/Link.pm b/lib/Data/HAL/Link.pm new file mode 100644 index 0000000..842b1c0 --- /dev/null +++ b/lib/Data/HAL/Link.pm @@ -0,0 +1,190 @@ +package Data::HAL::Link; +use strictures; +use boolean qw(false true); +use Data::HAL::URI qw(); +use HTTP::Headers::Util qw(join_header_words); +use JSON qw(); +use Log::Any qw($log); +use MIME::Type qw(); +use Moo; # has +use Safe::Isa qw($_can $_isa); +use Types::Standard qw(InstanceOf Str); + +our $VERSION = '1.000'; + +my $uri_from_str = sub { + my ($val) = @_; + return $val->$_isa('Data::HAL::URI') ? $val : Data::HAL::URI->new($val); +}; + +my $boolean_from_perlbool = sub { + my ($val) = @_; + return $val ? true : false; +}; + +has('relation', is => 'rw', isa => InstanceOf['Data::HAL::URI'], coerce => $uri_from_str, required => 1); +has('href', is => 'rw', isa => InstanceOf['Data::HAL::URI'], coerce => $uri_from_str, required => 1); +has('templated', is => 'rw', isa => InstanceOf['boolean'], coerce => $boolean_from_perlbool); +has('type', is => 'rw', isa => InstanceOf['MIME::Type']); +has('deprecation', is => 'rw', isa => InstanceOf['Data::HAL::URI'], coerce => $uri_from_str); +has('name', is => 'rw', isa => Str); +has('profile', is => 'rw', isa => InstanceOf['Data::HAL::URI'], coerce => $uri_from_str); +has('title', is => 'rw', isa => Str); +has('hreflang', is => 'rw', isa => Str); + +sub BUILD { + my ($self) = @_; + if ($self->deprecation) { + $log->warn(sprintf 'The link (relation: "%s", href: "%s") is deprecated, see <%s>', + $self->relation->as_string, $self->href->as_string, $self->deprecation->as_string); + } + return; +} + +sub _to_nested { + my ($self, $root) = @_; + my $hal; + for my $attr (map { $_->accessor } $self->meta->get_all_attributes) { + my $val = $self->$attr; + if (defined $val) { + $hal->{$attr} = $val->$_can('as_string') ? $val->as_string($root) : $val; + } + } + my $r = delete $hal->{relation}; + return($hal, $r); +} + +sub as_http_link_value { + my ($self) = @_; + return if 'curies' eq $self->relation->as_string; + return join_header_words( + '<'.$self->href->as_string.'>' => undef, + rel => $self->relation->as_string, + $self->hreflang ? (hreflang => $self->hreflang) : (), + $self->title ? (title => $self->title) : (), + $self->type ? (type => $self->type) : (), + $self->name ? (name => $self->name) : (), + $self->profile ? (profile => $self->profile->as_string) : (), + ); +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Data::HAL::Link - Hypertext Application Language link + +=head1 VERSION + +This document describes Data::HAL::Link version 1.000 + +=head1 SYNOPSIS + + map { +{ + href => $_->href->uri->as_string, + rel => $_->relation->uri->as_string + } } @{ $resource->links } + +=head1 DESCRIPTION + +This section is completely quoted from the specification: + +A Link Object represents a hyperlink from the containing resource to +a URI. + +=head1 INTERFACE + +=head2 Composition + +None. + +=head2 Constructors + +=head3 C + +When the L attribute is set, the constructor logs a L warning: + +C<< The link (relation: "%s", href: "%s") is deprecated, see <%s> >> + +You can consume it with a L of your choice, e.g. + + use Log::Any::Adapter 'Stderr'; + +Otherwise the constructor behaves like the default L constructor. Returns a C object. + +=head2 Attributes + +Perl strings are coerced to the L type in the attributes L, L, L, +L. + +=head3 C + +Type L, B, +L + +=head3 C + +Type L, B, +L + +=head3 C + +Type L, +L<< whether C is a URI template|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.2 >> + +Perl boolean values are coerced to the L type. + +=head3 C + +Type L, +L<< media type of the C resource|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.3 >> + +=head3 C + +Type L, if existing +L<< indicates the link is deprecated|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.4 >> + +Setting the attribute triggers a log message in the constructor L. + +=head3 C + +Type C, L<< secondary key for selecting link objects which share the same relation +type|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.5 >> + +=head3 C + +Type L, +L<< RFC 6906 profile of the target resource|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.6 >> + +=head3 C + +Type C<Str>, L<< labels the link with a human-readable +identifier|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.7 >> + +=head3 C<hreflang> + +Type C<Str>, +L<< indicates the language of the target resource|http://tools.ietf.org/html/draft-kelly-json-hal#section-5.8 >> + +=head2 Methods + +=head3 C<as_http_link_value> + +Returns the link as a L<< RFC 5988 C<link-value>|http://tools.ietf.org/html/rfc5988#section-5 >> string, e.g. +C<< </orders?page=2>;rel="next" >>. + +=head2 Exports + +None. + +=head1 DIAGNOSTICS + +See L</new> constructor. + +=head1 CONFIGURATION AND ENVIRONMENT + +Requires no configuration files or environment variables. diff --git a/lib/Data/HAL/URI.pm b/lib/Data/HAL/URI.pm new file mode 100644 index 0000000..e840e2e --- /dev/null +++ b/lib/Data/HAL/URI.pm @@ -0,0 +1,109 @@ +package Data::HAL::URI; +use strictures; +use Moo; # has +use Types::Standard qw(InstanceOf Str); +use URI qw(); + +our $VERSION = '1.000'; + +has('_original', is => 'rw', isa => Str); +# just records what was passed to the constructor, this is a work-around for +# URI->new being a lossy operation +has( + 'uri', + is => 'ro', + isa => InstanceOf['URI'], + default => sub { + my ($self) = @_; + return URI->new($self->_original); + }, + handles => [qw(abs as_iri canonical clone eq fragment implementor new_abs opaque path rel scheme secure + STORABLE_freeze STORABLE_thaw)], + lazy => 1, + required => 1, +); + +sub BUILDARGS { + my (undef, @arg) = @_; + return 1 == @arg ? {_original => $arg[0]} : {@arg}; +} + +sub as_string { + my ($self, $root) = @_; + if ( + $self->eq($self->_original) + || + $root && $root->_nsmap && $self->uri->eq($root->_nsmap->uri($self->_original)->as_string) + ) { + return $self->_original; + } else { + return $self->uri->as_string; + } +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Data::HAL::URI - URI wrapper + +=head1 VERSION + +This document describes Data::HAL::URI version 1.000 + +=head1 SYNOPSIS + + my $relation = $resource->relation->as_string; + +=head1 DESCRIPTION + +This is a wrapper for L<URI> objects. + +=head1 INTERFACE + +=head2 Composition + +None, but L<URI> methods are delegated through the L</uri> attribute. + +=head2 Constructors + +=head3 C<new> + + my $u = Data::HAL::URI->new('http://example.com/something'); + +Takes a string argument, returns a C<Data::HAL::URI> object. + +=head2 Attributes + +=head3 C<uri> + +Type C<URI>, B<required>, B<readonly>, can only be set from the L</new> constructor. + +This attribute delegates all methods to L<URI> except L</as_string>. + +=head2 Methods + +=head3 C<as_string> + +Returns the original argument to the constructor if still equal to the L</uri>, where equality also takes CURIE +expansion into account, or otherwise the L</uri> as string. + +The unaltered behaviour is still available through the L</uri> accessor, e.g.: + + $resource->relation->uri->as_string + +=head2 Exports + +None. + +=head1 DIAGNOSTICS + +None. + +=head1 CONFIGURATION AND ENVIRONMENT + +Requires no configuration files or environment variables. diff --git a/lib/Data/HAL/URI/NamespaceMap.pm b/lib/Data/HAL/URI/NamespaceMap.pm new file mode 100644 index 0000000..0061744 --- /dev/null +++ b/lib/Data/HAL/URI/NamespaceMap.pm @@ -0,0 +1,27 @@ +package Data::HAL::URI::NamespaceMap; +use strictures; +use Data::HAL::URI qw(); +use Moo; # extends +use URI::IRI qw(); +use URI::Template qw(); +use XML::RegExp qw(); +extends 'URI::NamespaceMap'; + +our $VERSION = '1.000'; + +sub uri { + my ($self, $abbr) = @_; + if (my ($prefix, $reference) = $abbr =~ m/\A ($XML::RegExp::NCName) : (.*) \z/msx) { + if ($self->namespace_uri($prefix)) { + return Data::HAL::URI->new( + _original => $abbr, + uri => URI::Template + ->new($self->namespace_uri($prefix)->as_string) + ->process(rel => URI::IRI->new($reference)->canonical->as_string), + ); + } + } + return; +} + +1; diff --git a/t/00.load.t b/t/00.load.t new file mode 100644 index 0000000..f3fcf09 --- /dev/null +++ b/t/00.load.t @@ -0,0 +1,13 @@ +use strictures; +use Test::More import => [qw(diag done_testing use_ok)]; + +BEGIN { + use_ok('Data::HAL'); + use_ok('Data::HAL::Link'); + use_ok('Data::HAL::URI'); + use_ok('Data::HAL::URI::NamespaceMap'); +} + +diag('Testing Data::HAL ' . Data::HAL->VERSION); + +done_testing; diff --git a/t/curies.t b/t/curies.t new file mode 100644 index 0000000..6e437ce --- /dev/null +++ b/t/curies.t @@ -0,0 +1,15 @@ +use strictures; +use Test::More import => [qw(done_testing is ok)]; +use Data::HAL qw(); +use File::Slurp qw(read_file); + +my $hal = Data::HAL->from_json(scalar read_file 't/exampleWithMultipleNestedSubresources.json'); +my @expanded_curies = map { $_->relation->uri->as_string } @{ $hal->links }; +ok grep { 'https://example.com/apidocs/ns/parent' eq $_ } @expanded_curies; +ok grep { 'https://example.com/apidocs/ns/users' eq $_ } @expanded_curies; + +is $hal->embedded->[0]->relation->uri->as_string, 'https://example.com/apidocs/ns/user'; +is $hal->embedded->[1]->relation->uri->as_string, 'https://example.com/apidocs/ns/user'; +is $hal->embedded->[0]->embedded->[0]->relation->uri->as_string, 'https://example.com/apidocs/phone/cell'; + +done_testing; diff --git a/t/deprecation.t b/t/deprecation.t new file mode 100644 index 0000000..12f96f4 --- /dev/null +++ b/t/deprecation.t @@ -0,0 +1,14 @@ +use strictures; +use Data::HAL qw(); +use File::Slurp qw(read_file); +use Log::Any::Adapter qw(); +use Log::Any::Adapter::FileHandle qw(); +use Test::More import => [qw(done_testing is)]; + +my $log; +open my $fh, '>', \$log; +Log::Any::Adapter->set('FileHandle', fh => $fh); +Data::HAL->from_json(scalar read_file 't/example3.json'); +is $log, qq{[warning] The link (relation: "widgets", href: "/widgets") } + . "is deprecated, see <http://example.com/blog/2013/09/no-more-widgets>\n"; +done_testing; diff --git a/t/double.t b/t/double.t new file mode 100644 index 0000000..7d02f93 --- /dev/null +++ b/t/double.t @@ -0,0 +1,11 @@ +use strictures; +use Test::More import => [qw(done_testing is)]; +use Data::HAL qw(); +use File::Slurp qw(read_file); + +my $hal = Data::HAL->from_json(scalar read_file 't/example.json'); +my $json1 = $hal->as_json; +my $json2 = $hal->as_json; +is $json2, $json1; + +done_testing; diff --git a/t/example.json b/t/example.json new file mode 100644 index 0000000..c5d83ff --- /dev/null +++ b/t/example.json @@ -0,0 +1,30 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "age" : 33, + "expired" : false, + "id" : 123456, + "name" : "Example Resource", + "optional" : true +} diff --git a/t/example1.json b/t/example1.json new file mode 100644 index 0000000..5c38715 --- /dev/null +++ b/t/example1.json @@ -0,0 +1,40 @@ +{ + "_links": { + "self": { "href": "/orders" }, + "next": { "href": "/orders?page=2" }, + "find": { + "href": "/orders{?id}", + "templated": true + }, + "admin": [{ + "href": "/admins/2", + "title": "Fred" + }, { + "href": "/admins/5", + "title": "Kate" + }] + }, + "currentlyProcessing": 14, + "shippedToday": 20, + "_embedded": { + "order": [{ + "_links": { + "self": { "href": "/orders/123" }, + "basket": { "href": "/baskets/98712" }, + "customer": { "href": "/customers/7809" } + }, + "total": 30.00, + "currency": "USD", + "status": "shipped" + }, { + "_links": { + "self": { "href": "/orders/124" }, + "basket": { "href": "/baskets/97213" }, + "customer": { "href": "/customers/12369" } + }, + "total": 20.00, + "currency": "USD", + "status": "processing" + }] + } +} diff --git a/t/example2.json b/t/example2.json new file mode 100644 index 0000000..74eaf42 --- /dev/null +++ b/t/example2.json @@ -0,0 +1,11 @@ +{ + "_links": { + "self": { "href": "/orders" }, + "curies": [{ + "name": "acme", + "href": "http://docs.acme.com/relations/{rel}", + "templated": true + }], + "acme:widgets": { "href": "/widgets" } + } +} diff --git a/t/example3.json b/t/example3.json new file mode 100644 index 0000000..782645e --- /dev/null +++ b/t/example3.json @@ -0,0 +1,6 @@ +{ + "_links": { + "self": { "href": "/orders" }, + "widgets": { "href": "/widgets", "deprecation": "http://example.com/blog/2013/09/no-more-widgets" } + } +} diff --git a/t/example4.json b/t/example4.json new file mode 100644 index 0000000..08d6a4e --- /dev/null +++ b/t/example4.json @@ -0,0 +1,6 @@ +{ + "_links": { + "self": { "href": "/orders" }, + "profile": { "href": "http://example.com/shop/documentation" } + } +} diff --git a/t/exampleWithArray.json b/t/exampleWithArray.json new file mode 100644 index 0000000..7ac6d57 --- /dev/null +++ b/t/exampleWithArray.json @@ -0,0 +1,4 @@ +{ + "array" : [ "one", "two", "three" ], + "name" : "Example Resource" +} diff --git a/t/exampleWithLiteralNullProperty.json b/t/exampleWithLiteralNullProperty.json new file mode 100644 index 0000000..6ba5118 --- /dev/null +++ b/t/exampleWithLiteralNullProperty.json @@ -0,0 +1,31 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "age" : 33, + "expired" : false, + "id" : 123456, + "name" : "Example Resource", + "nullval" : "null", + "optional" : true +} diff --git a/t/exampleWithMultipleNestedSubresources.json b/t/exampleWithMultipleNestedSubresources.json new file mode 100644 index 0000000..5e96f55 --- /dev/null +++ b/t/exampleWithMultipleNestedSubresources.json @@ -0,0 +1,65 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/phone/{rel}", + "name" : "phone", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "_embedded" : { + "ns:user" : [ { + "_links" : { + "self" : { + "href" : "https://example.com/user/11" + } + }, + "age" : 32, + "expired" : false, + "id" : 11, + "name" : "Example User", + "optional" : true, + "_embedded" : { + "phone:cell" : { + "_links" : { + "self" : { + "href" : "https://example.com/phone/1" + } + }, + "id" : 1, + "number" : "555-666-7890" + } + } + }, { + "_links" : { + "self" : { + "href" : "https://example.com/user/12" + } + }, + "age" : 32, + "expired" : false, + "id" : 12, + "name" : "Example User", + "optional" : true + } ] + } +} diff --git a/t/exampleWithMultipleSubresources.json b/t/exampleWithMultipleSubresources.json new file mode 100644 index 0000000..abaf2a0 --- /dev/null +++ b/t/exampleWithMultipleSubresources.json @@ -0,0 +1,50 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "_embedded" : { + "ns:user" : [ { + "_links" : { + "self" : { + "href" : "https://example.com/user/11" + } + }, + "age" : 32, + "expired" : false, + "id" : 11, + "name" : "Example User", + "optional" : true + }, { + "_links" : { + "self" : { + "href" : "https://example.com/user/12" + } + }, + "age" : 32, + "expired" : false, + "id" : 12, + "name" : "Example User", + "optional" : true + } ] + } +} diff --git a/t/exampleWithNullProperty.json b/t/exampleWithNullProperty.json new file mode 100644 index 0000000..2ea7aed --- /dev/null +++ b/t/exampleWithNullProperty.json @@ -0,0 +1,31 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "age" : 33, + "expired" : false, + "id" : 123456, + "name" : "Example Resource", + "nullprop" : null, + "optional" : true +} diff --git a/t/exampleWithSubresource.json b/t/exampleWithSubresource.json new file mode 100644 index 0000000..5b48b8b --- /dev/null +++ b/t/exampleWithSubresource.json @@ -0,0 +1,39 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "_embedded" : { + "ns:user" : { + "_links" : { + "self" : { + "href" : "https://example.com/user/11" + } + }, + "age" : 32, + "expired" : false, + "id" : 11, + "name" : "Example User", + "optional" : true + } + } +} diff --git a/t/exampleWithSubresourceLinkingToItself.json b/t/exampleWithSubresourceLinkingToItself.json new file mode 100644 index 0000000..f01a37f --- /dev/null +++ b/t/exampleWithSubresourceLinkingToItself.json @@ -0,0 +1,42 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer/123456" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:users" : { + "href" : "https://example.com/api/customer/123456?users" + } + }, + "_embedded" : { + "ns:user" : { + "_links" : { + "self" : { + "href" : "https://example.com/user/11" + }, + "role:admin" : { + "href" : "https://example.com/user/11" + } + }, + "age" : 32, + "expired" : false, + "id" : 11, + "name" : "Example User", + "optional" : true + } + } +} diff --git a/t/exampleWithTemplate.json b/t/exampleWithTemplate.json new file mode 100644 index 0000000..10f9d1d --- /dev/null +++ b/t/exampleWithTemplate.json @@ -0,0 +1,26 @@ +{ + "_links" : { + "curies" : [ { + "href" : "https://example.com/apidocs/ns/{rel}", + "name" : "ns", + "templated" : true + }, { + "href" : "https://example.com/apidocs/role/{rel}", + "name" : "role", + "templated" : true + } ], + "self" : { + "href" : "https://example.com/api/customer" + }, + "ns:parent" : { + "href" : "https://example.com/api/customer/1234", + "name" : "bob", + "title" : "The Parent", + "hreflang" : "en" + }, + "ns:query" : { + "href" : "https://example.com/api/customer/search{?queryParam}", + "templated" : true + } + } +} diff --git a/t/exampleWithUnderscoredProperty.json b/t/exampleWithUnderscoredProperty.json new file mode 100644 index 0000000..6860947 --- /dev/null +++ b/t/exampleWithUnderscoredProperty.json @@ -0,0 +1,8 @@ +{ + "_links" : { + "self" : { + "href" : "https://example.com/api/customer/123456" + } + }, + "_name" : "Example Resource" +} diff --git a/t/exampleWithoutHref.json b/t/exampleWithoutHref.json new file mode 100644 index 0000000..53ac1b2 --- /dev/null +++ b/t/exampleWithoutHref.json @@ -0,0 +1,3 @@ +{ + "name" : "Example Resource" +} diff --git a/t/headers.t b/t/headers.t new file mode 100644 index 0000000..fd3295b --- /dev/null +++ b/t/headers.t @@ -0,0 +1,23 @@ +use strictures; +use Test::More import => [qw(done_testing is_deeply)]; +use Data::HAL qw(); + +my $hal = Data::HAL->new( + links => [ + Data::HAL::Link->new(relation => 'self', href => 'http://example.com/a-resource'), + Data::HAL::Link->new(relation => 'profile', href => 'http://example.com/docs'), + Data::HAL::Link->new(relation => 'curies', name => 'example', href => 'http://example.com/rel#{rel}', templated => 1), + Data::HAL::Link->new(relation => 'example:foo', href => '/a-foo-thing'), + Data::HAL::Link->new(relation => 'example:bar', href => '/some-bar'), + ], +); + +is_deeply [$hal->http_headers], [ + 'Content-Type' => 'application/hal+json; profile="http://example.com/docs"', + 'Link' => '<http://example.com/a-resource>; rel=self', + 'Link' => '<http://example.com/docs>; rel=profile', + 'Link' => '</a-foo-thing>; rel="http://example.com/rel#foo"', + 'Link' => '</some-bar>; rel="http://example.com/rel#bar"', +]; + +done_testing; diff --git a/t/invalid.t b/t/invalid.t new file mode 100644 index 0000000..5166d75 --- /dev/null +++ b/t/invalid.t @@ -0,0 +1,7 @@ +use strictures; +use Data::HAL qw(); +use Test::Fatal qw(exception); +use Test::More import => [qw(done_testing isa_ok)]; + +isa_ok exception { Data::HAL->from_json('[]') }, 'failure::Data::HAL::InvalidJSON'; +done_testing; diff --git a/t/profile.t b/t/profile.t new file mode 100644 index 0000000..a261970 --- /dev/null +++ b/t/profile.t @@ -0,0 +1,8 @@ +use strictures; +use Data::HAL qw(); +use File::Slurp qw(read_file); +use Test::More import => [qw(done_testing is)]; + +is {Data::HAL->from_json(scalar read_file 't/example4.json')->http_headers}->{'Content-Type'}, + 'application/hal+json; profile="http://example.com/shop/documentation"'; +done_testing; diff --git a/t/round-trip.t b/t/round-trip.t new file mode 100644 index 0000000..b8333d6 --- /dev/null +++ b/t/round-trip.t @@ -0,0 +1,27 @@ +use strictures; +use Test::More import => [qw(done_testing is_deeply)]; +use Data::HAL qw(); +use Data::Visitor::Callback qw(); +use File::Slurp qw(read_file); +use JSON qw(); + +sub round_trip { + my ($file_name, $json_in) = @_; + my $json_out = Data::HAL->from_json($json_in)->as_json; + my $in_unwrapped = Data::Visitor::Callback->new( + array => sub { + if (1 == @{ $_ }) { + my ($inner) = @{ $_ }; + return $inner; + } + return $_; + } + )->visit(JSON::from_json($json_in)); + is_deeply JSON::from_json($json_out), $in_unwrapped, $file_name; +} + +for my $file_name (sort glob 't/*.json') { + round_trip($file_name, scalar read_file $file_name); +} + +done_testing;