Compare commits

...

65 Commits

Author SHA1 Message Date
Yanick Champoux 19b0f8fa70 v0.0.1
- First release.
2023-11-15 14:24:10 -05:00
Yanick Champoux 2e684886f7 use a simple eval 2023-11-15 14:22:55 -05:00
Yanick Champoux 64b3a06edf add dzil 2023-11-15 14:20:40 -05:00
Yanick Champoux 066ec3dde6 Merge branch 'deserialize' 2023-11-15 13:28:02 -05:00
Yanick Champoux 73b20e91b3 deserialize 2023-11-15 13:27:45 -05:00
Yanick Champoux cf0a37de7f Merge branch 'documentation' 2023-11-15 09:42:04 -05:00
Yanick Champoux 7373177ec6 only tidy perl files 2023-11-15 09:39:13 -05:00
Yanick Champoux 832e77e555 add worktree test 2023-11-15 09:39:13 -05:00
Yanick Champoux 29716aa141 documentation 2023-11-15 09:39:13 -05:00
Yanick Champoux 6ad18ac108 Merge branch 'before-serializer' 2023-11-14 10:29:06 -05:00
Yanick Champoux 38aba7d167 add before_serializer 2023-11-14 10:28:43 -05:00
Yanick Champoux a6303ca9f7 Merge branch 'move-schema' 2023-11-13 13:51:05 -05:00
Yanick Champoux 2acc581955 move the Schema class 2023-11-13 13:38:56 -05:00
Yanick Champoux 2e125c1771 misc Schema changes 2023-11-13 13:15:29 -05:00
Yanick Champoux e93942cb61 attributes can be a function 2023-11-13 12:43:57 -05:00
Yanick Champoux 0a2eee55ce auto-add the self link within the app 2023-11-13 10:38:51 -05:00
Yanick Champoux ebbb5f9466 no data field when there is no data 2023-11-13 10:31:24 -05:00
Yanick Champoux 67c4d58b9d add a test 2023-11-13 10:01:24 -05:00
Yanick Champoux 8e5df8fbf0 formatting 2023-11-13 09:57:37 -05:00
Yanick Champoux 250bd940da formatting 2023-11-13 09:49:27 -05:00
Yanick Champoux 83e2f01add Merge branch 'add-the-plugin' 2023-11-12 20:55:33 -05:00
Yanick Champoux ad4258cf04 move the merge tests to xt 2023-11-12 20:55:00 -05:00
Yanick Champoux 7cee2ecb47 fleshing out the plugin 2023-11-12 20:53:42 -05:00
Yanick Champoux 80f9d7b582 Merge branch 'test-added' 2023-11-12 17:10:25 -05:00
Yanick Champoux 3df5204252 check that at least one test was added 2023-11-12 17:10:10 -05:00
Yanick Champoux e89a3ffc97 Merge branch 'serialize-array' 2023-11-12 13:23:45 -05:00
Yanick Champoux 612c87f6b1 allow for the serializer to get the $xtra data as well 2023-11-12 13:23:01 -05:00
Yanick Champoux c8c273056a add test to check if branch is ready for merging 2023-11-12 13:22:24 -05:00
Yanick Champoux d758761f05 finalize the serializing example 2023-11-12 12:43:29 -05:00
Yanick Champoux ea112b2bd5 format 2023-11-12 12:29:35 -05:00
Yanick Champoux 0a67377791 add a todos.t with general todos 2023-11-12 12:27:56 -05:00
Yanick Champoux 2884d8b5cc Merge branch 'serializer' 2023-11-10 15:31:05 -05:00
Yanick Champoux 52eba22e47 add the serializer 2023-11-10 15:30:25 -05:00
Yanick Champoux 936b2344cd add Taskfile 2023-11-10 15:29:32 -05:00
Yanick Champoux 078d7d7a85 ignore backups 2023-11-10 15:27:36 -05:00
Yanick Champoux 3736a8ba36 Merge branch 'relationship-links' 2023-11-01 17:39:20 -04:00
Yanick Champoux 57eeb08419 relationship links 2023-11-01 17:39:15 -04:00
Yanick Champoux 63ead8b937 add allowed_attributes 2023-11-01 16:03:28 -04:00
Yanick Champoux fb5cd7ce3a flesh up the example 2023-11-01 15:28:47 -04:00
Yanick Champoux 5dd5e664e9 scalar data are ids 2023-11-01 15:21:06 -04:00
Yanick Champoux 8639858b60 relationships 2023-11-01 13:16:34 -04:00
Yanick Champoux fe516b6b32 use croak, not carp 2023-11-01 11:10:25 -04:00
Yanick Champoux fc89cb74d8 remove ::Type 2023-11-01 11:09:54 -04:00
Yanick Champoux 4d4c4ee838 rename serialize and top_level_serialize 2023-11-01 11:03:11 -04:00
Yanick Champoux a5e552b06e Merge branch 'attributes' 2023-10-31 16:45:49 -04:00
Yanick Champoux b4eea91aae attributes on the Schema 2023-10-31 16:45:42 -04:00
Yanick Champoux d7e7c7447c Merge branch 'top-level-links' 2023-10-31 16:30:14 -04:00
Yanick Champoux 6debdda412 add example for top links 2023-10-31 16:30:01 -04:00
Yanick Champoux 7a9e68a108 hush the warning 2023-10-31 16:29:48 -04:00
Yanick Champoux d9769d985e Merge branch 'example' 2023-10-31 16:19:05 -04:00
Yanick Champoux eec187e879 beginning of example.t 2023-10-31 16:18:51 -04:00
Yanick Champoux c0d7724aca Merge branch 'meta' 2023-10-31 16:09:17 -04:00
Yanick Champoux 0c0389f6a4 add test for meta 2023-10-31 16:09:05 -04:00
Yanick Champoux d51ee14eac jsonapi field in the ::Schema 2023-10-31 15:54:28 -04:00
Yanick Champoux e679ab442d Merge branch 'jsonapi' 2023-10-31 15:41:26 -04:00
Yanick Champoux ec81b71bc3 add to the gitignore 2023-10-31 15:41:12 -04:00
Yanick Champoux 9a2aa6f0b4 add Registry::Schema 2023-10-31 15:41:10 -04:00
Yanick Champoux ae39624e80 Merge branch 'registry-type' 2023-10-31 12:17:42 -04:00
Yanick Champoux f7d4eadf74 add registry::type 2023-10-31 12:17:28 -04:00
Yanick Champoux d2508a2fec add the registry test 2023-10-31 12:16:36 -04:00
Yanick Champoux b0384f3ad7 ignore more files 2023-10-31 12:14:17 -04:00
Yanick Champoux f8dd464d06 Merge branch 'registry-class' 2023-10-31 10:40:01 -04:00
Yanick Champoux a71a6dea02 add .gitignore 2023-10-31 10:39:46 -04:00
Yanick Champoux 41506d4fcb add a stubby Registry class 2023-10-31 10:39:35 -04:00
Yanick Champoux 0a5a48f0ee initial creationg of the module 2023-10-31 10:22:36 -04:00
26 changed files with 1420 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.test_info.*
lastlog.jsonl
*.bak
lab
.envrc
*.orig
*.tdy
*.ERR
Dancer2-Plugin-JsonApi-*

17
.travis.yml Normal file
View File

@ -0,0 +1,17 @@
---
before_install:
- export HARNESS_OPTIONS=j10:c HARNESS_TIMER=1
- git config --global user.name "Dist Zilla Plugin TravisCI"
- git config --global user.email $HOSTNAME":not-for-mail@travis-ci.com"
install:
- cpanm --with-recommends --installdeps -n .
language: perl
matrix:
include:
- perl: '5.22'
- perl: '5.24'
- perl: '5.26'
- perl: '5.28'
- perl: '5.30'
script:
- prove -l t

13
AUTHOR_PLEDGE Normal file
View File

@ -0,0 +1,13 @@
# CPAN Covenant for Dancer2-Plugin-JsonApi
I, Yanick Champoux <yanick@babyl.ca>, hereby give modules@perl.org permission to grant co-maintainership
to Dancer2-Plugin-JsonApi, if all the following conditions are met:
(1) I haven't released the module for a year or more
(2) There are outstanding issues in the module's public bug tracker
(3) Email to my CPAN email address hasn't been answered after a month
(4) The requester wants to make worthwhile changes that will benefit CPAN
In the event of my death, then the time-limits in (1) and (3) do not apply.

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
yanick@babyl.ca.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

17
Changes Normal file
View File

@ -0,0 +1,17 @@
Revision history for Dancer2-Plugin-JsonApi
{{$NEXT}}
[API CHANGES]
[BUG FIXES]
[DOCUMENTATION]
[ENHANCEMENTS]
[NEW FEATURES]
[STATISTICS]
0.0.1 2023-11-15
- First release.

34
MANIFEST Normal file
View File

@ -0,0 +1,34 @@
AUTHOR_PLEDGE
CODE_OF_CONDUCT.md
Changes
INSTALL
LICENSE
MANIFEST
META.json
META.yml
Makefile.PL
README.mkdn
SIGNATURE
Taskfile.yaml
cpanfile
dist.ini
doap.xml
lib/Dancer2/Plugin/JsonApi.pm
lib/Dancer2/Plugin/JsonApi/Registry.pm
lib/Dancer2/Plugin/JsonApi/Schema.pm
lib/Dancer2/Serializer/JsonApi.pm
t/00-compile.t
t/00-report-prereqs.dd
t/00-report-prereqs.t
t/compile.t
t/example.t
t/plugin.t
t/registry.t
t/relationships.t
t/schema.t
t/serializer.t
t/todos.t
xt/added-test.t
xt/perltidy.t
xt/release/unused-vars.t
xt/worktree-clean.t

17
Taskfile.yaml Normal file
View File

@ -0,0 +1,17 @@
# https://taskfile.dev
version: '3'
vars:
GREETING: Hello, World!
TARGET_BRANCH: main
tasks:
tidy:
cmds:
- git diff-ls --diff-filter=ACMR {{.TARGET_BRANCH}} | grep -e '\.pm$\|\.t$|\.pod$' | xargs -IX perltidy -b X
default:
cmds:
- echo "{{.GREETING}}"
silent: true

43
cpanfile Normal file
View File

@ -0,0 +1,43 @@
# This file is generated by Dist::Zilla::Plugin::CPANFile v6.030
# Do not edit this file directly. To change prereqs, edit the `dist.ini` file.
requires "Carp" => "0";
requires "Dancer2::Core::Role::Serializer" => "0";
requires "Dancer2::Plugin" => "0";
requires "Dancer2::Serializer::JSON" => "0";
requires "List::AllUtils" => "0";
requires "Moo" => "0";
requires "Set::Object" => "0";
requires "experimental" => "0";
requires "perl" => "v5.38.0";
on 'test' => sub {
requires "Clone" => "0";
requires "Dancer2" => "0";
requires "ExtUtils::MakeMaker" => "0";
requires "File::Spec" => "0";
requires "IO::Handle" => "0";
requires "IPC::Open3" => "0";
requires "JSON" => "0";
requires "Test2::Plugin::ExitSummary" => "0";
requires "Test2::V0" => "0";
requires "Test::More" => "0";
requires "strict" => "0";
requires "warnings" => "0";
};
on 'test' => sub {
recommends "CPAN::Meta" => "2.120900";
};
on 'configure' => sub {
requires "ExtUtils::MakeMaker" => "0";
};
on 'develop' => sub {
requires "Git::Wrapper" => "0";
requires "Test2::V0" => "0";
requires "Test::More" => "0.96";
requires "Test::PerlTidy" => "0";
requires "Test::Vars" => "0";
};

8
dist.ini Normal file
View File

@ -0,0 +1,8 @@
name = Dancer2-Plugin-JsonApi
author = Yanick Champoux <yanick@babyl.ca>
license = Perl_5
copyright_holder = Yanick Champoux
copyright_year = 2023
[@YANICK]

View File

@ -0,0 +1,70 @@
# ABSTRACT: JsonApi helpers for Dancer2 apps
use 5.38.0;
package Dancer2::Plugin::JsonApi;
use Dancer2::Plugin::JsonApi::Registry;
use Dancer2::Plugin;
use Dancer2::Serializer::JsonApi;
has registry => (
plugin_keyword => 'jsonapi_registry',
is => 'ro',
default => sub ($self) {
Dancer2::Plugin::JsonApi::Registry->new( app => $self->app );
}
);
sub jsonapi : PluginKeyword ( $plugin, $type, $sub ) {
return sub {
my $result = $sub->();
return [
$type => $result,
{ vars => $plugin->app->request->vars,
request => $plugin->app->request
}
];
};
}
sub BUILD ( $self, @ ) {
my $serializer = eval {
$self->app->serializer_engine
};
unless ($serializer) {
$self->app->set_serializer_engine(
Dancer2::Serializer::JsonApi->new );
$serializer = $self->app->serializer_engine;
}
$serializer->registry( $self->registry )
if ref $serializer eq 'Dancer2::Serializer::JsonApi';
}
1;
__END__
=head1 NAME
Dancer2::Plugin::JsonAPI
=head2 DESCRIPTION
If the serializer is not already explicitly set, the plugin will configure it to be L<Dancer2::Serializer::JsonApi>.
=head2 SEE ALSO
=over
=item * The L<JSON:API|https://jsonapi.org> specs, natch.
=item * L<json-api-serializer|https://www.npmjs.com/package/json-api-serializer> My go to for serializing JSON API documents in JavaScript-land. Also, inspiration for this module.
=back

View File

@ -0,0 +1,48 @@
package Dancer2::Plugin::JsonApi::Registry;
use 5.32.0;
use Dancer2::Plugin::JsonApi::Schema;
use Carp;
use Moo;
use experimental qw/ signatures /;
sub serialize ( $self, $type, $data, $extra_data = {} ) {
return $self->type($type)->serialize( $data, $extra_data );
}
sub deserialize ( $self, $data, $included = [] ) {
my $type =
ref $data->{data} eq 'ARRAY'
? $data->{data}[0]->{type}
: $data->{data}{type};
return $self->type($type)->deserialize( $data, $included );
}
has types => (
is => 'ro',
default => sub { +{} },
);
has app => ( is => 'ro', );
sub add_type ( $self, $type, $definition = {} ) {
$self->{types}{$type} = Dancer2::Plugin::JsonApi::Schema->new(
registry => $self,
type => $type,
%$definition
);
}
sub type ( $self, $type ) {
return $self->types->{$type} //=
Dancer2::Plugin::JsonApi::Schema->new( type => $type );
}
1;
__END__

View File

@ -0,0 +1,23 @@
=head1 DESCRIPTION
The registry for the different types of data managed by the plugin.
=head1 METHODS
=head2 add_type($type, $definition = {})
Adds a data type to the registry.
=head2 type($type)
Returns the type's C<Dancer2::Plugin::JsonApi::Schema>. Throws an
error if the type does not exist.
=cut
=head2 serialize($type,$data,$extra_data={})
Returns the serialized form of C<$data>.
=cut

View File

@ -0,0 +1,223 @@
use 5.32.0;
package Dancer2::Plugin::JsonApi::Schema;
use Moo;
use experimental qw/ signatures /;
use List::AllUtils qw/ pairmap pairgrep /;
use Set::Object qw/set/;
has registry => ( is => 'ro' );
has type => (
required => 1,
is => 'ro',
);
has id => (
is => 'ro',
default => 'id'
);
has links => ( is => 'ro' );
has top_level_links => ( is => 'ro' );
has top_level_meta => ( is => 'ro' );
has relationships => ( is => 'ro', default => sub { +{} } );
has allowed_attributes => ( is => 'ro' );
has before_serialize => ( is => 'ro' );
sub serialize ( $self, $data, $extra_data = {} ) {
my $serial = {};
$serial->{jsonapi} = { version => '1.0' };
my @included;
if ( defined $data ) {
$serial->{data} =
$self->serialize_data( $data, $extra_data, \@included );
}
$serial->{links} = gen_links( $self->top_level_links, $data, $extra_data )
if $self->top_level_links;
if ( $self->registry and $self->registry->app ) {
$serial->{links}{self} = $self->registry->app->request->path;
}
$serial->{meta} = gen_links( $self->top_level_meta, $data, $extra_data )
if $self->top_level_meta;
$serial->{included} = [ dedupe_included(@included) ] if @included;
return $serial;
}
sub dedupe_included {
my %seen;
return grep { not $seen{ $_->{type} }{ $_->{id} }++ } @_;
}
has attributes => (
is => 'ro',
default => sub {
my $self = shift;
return sub {
my ( $data, $extra_data ) = @_;
return {} if ref $data ne 'HASH';
my @keys = grep { not $self->relationships->{$_} }
grep { $_ ne $self->id } keys %$data;
return { $data->%{@keys} };
}
}
);
sub serialize_data ( $self, $data, $extra_data = {}, $included = undef ) {
return [ map { $self->serialize_data( $_, $extra_data, $included ) }
@$data ]
if ref $data eq 'ARRAY';
if ( $self->before_serialize ) {
$data = $self->before_serialize->( $data, $extra_data );
}
# it's a scalar? it's the id
return { id => $data, type => $self->type } unless ref $data;
my $s = {
type => $self->type,
id => $self->gen_id( $data, $extra_data )
};
if ( $self->links ) {
$s->{links} = gen_links( $self->links, $data, $extra_data );
}
$s->{attributes} = gen_links( $self->attributes, $data, $extra_data );
my %relationships = $self->relationships->%*;
for my $key ( keys %relationships ) {
my $attr = $data->{$key};
my @inc;
my $t = $self->registry->serialize( $relationships{$key}{type},
$attr, \@inc );
if ( my $data = obj_ref( $t->{data}, \@inc ) ) {
$s->{relationships}{$key}{data} = $data;
}
if ( my $links = $relationships{$key}{links} ) {
$s->{relationships}{$key}{links} =
gen_links( $links, $data, $extra_data );
}
push @$included, @inc if $included;
}
delete $s->{attributes} unless $s->{attributes}->%*;
if ( $self->allowed_attributes ) {
delete $s->{attributes}{$_}
for ( set( keys $s->{attributes}->%* ) -
set( $self->allowed_attributes->@* ) )->@*;
}
return $s;
}
sub obj_ref ( $data, $included ) {
return [ map { obj_ref( $_, $included ) } @$data ]
if ref $data eq 'ARRAY';
return $data if keys %$data == 2;
return unless keys %$data;
push @$included, $data;
return +{ $data->%{qw/ id type/} };
}
sub gen_id ( $self, $data, $xtra ) {
my $id = $self->id;
return ref $id ? $id->( $data, $xtra ) : $data->{$id};
}
sub gen_links ( $links, $data, $extra_data = {} ) {
return $links->( $data, $extra_data ) if ref $links eq 'CODE';
return { pairmap { $a => gen_item( $b, $data, $extra_data ) } %$links };
}
sub gen_item ( $item, $data, $extra_data ) {
return $item unless ref $item;
return $item->( $data, $extra_data );
}
sub deserialize ( $self, $serialized, $included = [] ) {
my $data = $serialized->{data};
my @included = ( ( $serialized->{included} // [] )->@*, @$included );
return $self->deserialize_data( $data, \@included );
}
sub expand_object ( $obj, $included ) {
if ( ref $obj eq 'ARRAY' ) {
return [ map { expand_object( $_, $included ) } @$obj ];
}
for (@$included) {
return $_ if $_->{type} eq $obj->{type} and $_->{id} eq $obj->{id};
}
return $obj;
}
sub deserialize_data ( $self, $data, $included ) {
if ( ref $data eq 'ARRAY' ) {
return [ map { $self->deserialize_data( $_, $included ) } @$data ];
}
my %obj = (
( $data->{attributes} // {} )->%*,
pairmap {
$a =>
$self->registry->type( $self->relationships->{$a}{type} )
->deserialize_data( $b, $included )
} pairmap { $a => expand_object( $b, $included ) }
pairmap { $a => $b->{data} } ( $data->{relationships} // {} )->%*
);
my $id_key = $self->id;
if ( !ref $id_key ) {
$obj{$id_key} = $data->{id};
}
if ( $data->{type} eq 'photo' ) {
# die keys %$data;
}
if ( 1 == keys %obj and exists $obj{id} ) {
return $data->{id};
}
return \%obj;
}
1;

View File

@ -0,0 +1,64 @@
=head1 DESCRIPTION
Defines a type of object to serialize/deserialize from/to the
JSON:API format.
=head1 ATTRIBUTES
=head2 registry
L<Dancer2::Plugin::JsonApi::Registry> to use to find the definition of
other object types.
=head2 before_serialize
Accepts a function, which will be called on the original C<$data> to serialize
to groom it.
before_serialize => sub($data,$xtra) {
# lowercase all keys
return +{ pairmap { lc($a) => $b } %$data }
}
=head2 type
The JSON:API object type. Read-only, required.
=head2 id
Key to use as a reference to the object. Can be a string,
or a function that will be passed the original data object.
Read-only, defaults to the string C<id>.
=head2 links
Links to include as part of the object.
=head2 top_level_links
Links to include to the serialized top level, if the top level object
is of the type defined by this class.
=head2 top_level_meta
Meta information to include to the serialized top level, if the top level object
is of the type defined by this class.
=head2 relationships
Relationships for the object type.
=head2 allowed_attributes
List of attributes that can be serialized/deserialized.
=head1 METHODS
=head2 top_level_serialize($data,$extra_data = {})
Serializes C<$data> as a top-level JSON:API object.
=head2 serialize_data($data,$extra_data)
Serializes the inner C<$data>.

View File

@ -0,0 +1,119 @@
use 5.38.0;
=head1 DESCRIPTION
Serializer for JSON:API. Takes in a data structure, munge it to conforms to the JSON:API format (potentially based on a provided registry of JSON:API schemas),
and encode it as JSON.
Note that using Dancer2::Plugin::JsonApi in an app will automatically
set C<Dancer2::Serializer::JsonApi> as its serializer if it's not already defined.
=head1 SYNOPSIS
As part of a Dancer2 App:
# in config.yaml
serializer: JsonApi
As a standalone module:
use Dancer2::Serializer::JsonApi;
use Dancer2::Plugin::JsonApi::Registry;
my $registry = Dancer2::Plugin::JsonApi::Registry->new;
$registry->add_type( 'spaceship' => {
relationships => {
crew => { type => 'person' }
}
} );
$registry->add_type( 'person' );
my $serializer = Dancer2::Serializer::JsonApi->new(
registry => $registry
);
my $serialized = $serializer->serialize([
'spaceship', {
id => 1,
name => 'Unrequited Retribution',
crew => [
{ id => 2, name => 'One-eye Flanagan', species => 'human' },
{ id => 3, name => 'Flabgor', species => 'Moisterian' },
]
}
]);
=cut
package Dancer2::Serializer::JsonApi;
use Dancer2::Plugin::JsonApi::Registry;
use Dancer2::Serializer::JSON;
use Moo;
=head1 ATTRIBUTES
=head2 content_type
Returns the content type used by the serializer, which is C<application/vnd.api+json>;
=cut
has content_type => ( is => 'ro', default => 'application/vnd.api+json' );
with 'Dancer2::Core::Role::Serializer';
=head2 registry
The L<Dancer2::Plugin::JsonApi::Registry> to use.
=cut
has registry => (
is => 'rw',
default => sub { Dancer2::Plugin::JsonApi::Registry->new }
);
=head2 json_serializer
The underlying JSON serializer. Defaults to L<Dancer2::Serializer::JSON>.
=cut
has json_serializer => (
is => 'ro',
default => sub { Dancer2::Serializer::JSON->new }
);
=head1 METHODS
=head2 $self->serialize( [ $type, $data, $xtra ])
Serializes the C<$data> using the C<$type> from the registry.
The returned value will be a JSON string.
=cut
sub serialize {
my ( $self, $data ) = @_;
return $self->json_serializer->serialize(
$self->registry->serialize(@$data) );
}
=head2 $self->deserialize( $json_string )
Takes in the serialized C<$json_string> and recreate data out of it.
=cut
sub deserialize ( $self, $serialized, @ ) {
$self->registry->deserialize(
$self->json_serializer->deserialize($serialized) );
}
1;

8
t/compile.t Normal file
View File

@ -0,0 +1,8 @@
use Dancer2::Plugin::JsonApi::Registry;
use Test2::V0;
# we have to start somewhere
ok "it compiles!";
done_testing;

226
t/example.t Normal file
View File

@ -0,0 +1,226 @@
use 5.32.0;
use Test2::V0;
use Clone qw/ clone /;
use Dancer2::Plugin::JsonApi::Registry;
use experimental qw/ signatures /;
# example taken straight from https://www.npmjs.com/package/json-api-serializer
my $data = [
{ id => "1",
title => "JSON API paints my bikeshed!",
body => "The shortest article. Ever.",
created => "2015-05-22T14:56:29.000Z",
updated => "2015-05-22T14:56:28.000Z",
author => {
id => "1",
firstName => "Kaley",
lastName => "Maggio",
email => "Kaley-Maggio\@example.com",
age => "80",
gender => "male"
},
tags => [ "1", "2" ],
photos => [
"ed70cf44-9a34-4878-84e6-0c0e4a450cfe",
"24ba3666-a593-498c-9f5d-55a4ee08c72e",
"f386492d-df61-4573-b4e3-54f6f5d08acf"
],
comments => [
{ _id => "1",
body => "First !",
created => "2015-08-14T18:42:16.475Z"
},
{ _id => "2",
body => "I Like !",
created => "2015-09-14T18:42:12.475Z"
},
{ _id => "3",
body => "Awesome",
created => "2015-09-15T18:42:12.475Z"
}
]
}
];
my $registry = Dancer2::Plugin::JsonApi::Registry->new;
$registry->add_type(
'article',
{ top_level_meta => sub ( $data, $xtra ) {
return +{
count => $xtra->{count},
total => 0 + @$data,
};
},
top_level_links => { self => '/articles', },
links => {
self => sub ( $data, @ ) {
return "/articles/" . $data->{id};
},
},
relationships => {
'tags' => { type => 'tag' },
'comments' => { type => 'comment' },
photos => { type => 'photo' },
author => {
type => "people",
links => sub ( $data, @ ) {
return +{
self => "/articles/"
. $data->{id}
. "/relationships/author",
related => "/articles/" . $data->{id} . "/author"
};
}
},
}
}
);
$registry->add_type('tag');
$registry->add_type('photo');
$registry->add_type( 'comment',
{ id => '_id', allowed_attributes => ['body'] } );
$registry->add_type(
'people',
{ links => sub ( $data, @ ) { +{ self => '/peoples/' . $data->{id} } }
}
);
my $output = $registry->serialize( 'article', $data, { count => 2 } );
like $output->{data}[0]{relationships}{author},
{ links => {
"self" => "/articles/1/relationships/author",
"related" => "/articles/1/author"
}
};
like $output->{data}[0]{relationships}{author},
{ links => {
"self" => "/articles/1/relationships/author",
"related" => "/articles/1/author"
}
};
like $output => {
"jsonapi" => { "version" => "1.0" },
"meta" => {
"count" => 2,
"total" => 1
},
"links" => { "self" => "/articles" },
"data" => [
{ "type" => "article",
"id" => "1",
"attributes" => {
"title" => "JSON API paints my bikeshed!",
"body" => "The shortest article. Ever.",
"created" => "2015-05-22T14:56:29.000Z"
},
"relationships" => {
"author" => {
"data" => {
"type" => "people",
"id" => "1"
},
"links" => {
"self" => "/articles/1/relationships/author",
"related" => "/articles/1/author"
}
},
"tags" => {
"data" => [
{ "type" => "tag",
"id" => "1"
},
{ "type" => "tag",
"id" => "2"
}
]
},
"photos" => {
"data" => [
{ "type" => "photo",
"id" => "ed70cf44-9a34-4878-84e6-0c0e4a450cfe"
},
{ "type" => "photo",
"id" => "24ba3666-a593-498c-9f5d-55a4ee08c72e"
},
{ "type" => "photo",
"id" => "f386492d-df61-4573-b4e3-54f6f5d08acf"
}
]
},
"comments" => {
"data" => [
{ "type" => "comment",
"id" => "1"
},
{ "type" => "comment",
"id" => "2"
},
{ "type" => "comment",
"id" => "3"
}
]
}
},
"links" => { "self" => "/articles/1" }
}
],
};
is $output->{included}, bag {
item($_)
for (
{ "type" => "people",
"id" => "1",
"attributes" => {
"firstName" => "Kaley",
"lastName" => "Maggio",
"email" => "Kaley-Maggio\@example.com",
"age" => "80",
"gender" => "male"
},
"links" => { "self" => "/peoples/1" },
},
{ "type" => "comment",
"id" => "1",
"attributes" => { "body" => "First !" }
},
{ "type" => "comment",
"id" => "2",
"attributes" => { "body" => "I Like !" }
},
{ "type" => "comment",
"id" => "3",
"attributes" => { "body" => "Awesome" }
}
);
};
subtest 'comments only have the body attribute' => sub {
for
my $comment ( grep { $_->{type} eq 'comment' } $output->{included}->@* )
{
my @attr = keys $comment->{attributes}->%*;
is( \@attr => ['body'], "only the body for comments" );
}
};
subtest 'deserialize' => sub {
my $roundtrip = $registry->deserialize($output);
my $expected = clone($data);
delete $_->{created} for $expected->[0]{comments}->@*;
like $roundtrip => $expected;
};
done_testing;

10
t/plugin.t Normal file
View File

@ -0,0 +1,10 @@
use 5.38.0;
use Test2::V0;
use Dancer2;
use Dancer2::Plugin::JsonApi;
pass 'we compile!';
done_testing;

28
t/registry.t Normal file
View File

@ -0,0 +1,28 @@
use Test2::V0;
use Dancer2::Plugin::JsonApi::Registry;
use experimental qw/ signatures /;
my $registry = Dancer2::Plugin::JsonApi::Registry->new;
$registry->add_type(
people => {
id => 'id',
links => {
self => sub ( $data, @ ) {
no warnings qw/ uninitialized /;
return "/peoples/$data->{id}";
}
}
}
);
isa_ok $registry->type('people') => 'Dancer2::Plugin::JsonApi::Schema';
like(
$registry->serialize( people => {} ),
{ jsonapi => { version => '1.0' } }
);
done_testing();

66
t/relationships.t Normal file
View File

@ -0,0 +1,66 @@
use Test2::V0;
use Dancer2::Plugin::JsonApi::Registry;
use experimental qw/ signatures /;
my $registry = Dancer2::Plugin::JsonApi::Registry->new;
$registry->add_type( 'thing',
{ relationships => { subthings => { type => 'subthing' }, } } );
$registry->add_type('subthing');
subtest basic => sub {
my $s = $registry->serialize(
'thing',
{ id => 1,
subthings => [ { id => 2, x => 10 }, { id => 3 } ]
}
);
ok not $s->{data}{attributes};
like $s => {
data => {
id => 1,
type => 'thing',
relationships =>
{ subthings => { data => [ { id => 2 }, { id => 3 } ] } }
},
included =>
[ { type => 'subthing', id => 2, attributes => { x => 10 } }, ]
};
};
subtest "don't repeat includes" => sub {
my $s = $registry->serialize(
'thing',
[
{ id => 1,
subthings => [
{ id => 2,
x => 10
},
{ id => 3,
y => 20
}
]
},
{ id => 2,
subthings => [
{ id => 3,
y => 20
},
{ id => 2,
x => 10
}
]
}
]
);
is $s->{included}->@* + 0, 2;
};
done_testing;

145
t/schema.t Normal file
View File

@ -0,0 +1,145 @@
use Test2::V0;
use Dancer2::Plugin::JsonApi::Schema;
use Dancer2::Plugin::JsonApi::Registry;
use experimental qw/ signatures /;
my $type = Dancer2::Plugin::JsonApi::Schema->new( 'type' => 'thing' );
like $type->serialize( { attr1 => 'a', id => '123' }, { foo => 1 } ) => {
jsonapi => { version => '1.0' },
data => { type => 'thing', id => '123' }
};
is( Dancer2::Plugin::JsonApi::Schema->new(
'type' => 'thing',
id => 'foo'
)->serialize( { foo => '123' } )->{data}{id} => '123',
'custom id'
);
my $serialized = schema_serialize(
{ 'type' => 'thing',
id => sub ( $data, @ ) { $data->{x} . $data->{y} },
links => { self => '/some/url' },
},
{ x => '1', y => '2' }
);
is( $serialized->{data}{id} => '12',
'custom id, function'
);
like $serialized->{data}, { links => { self => '/some/url' } }, "links";
sub schema_serialize ( $schema, $data ) {
return Dancer2::Plugin::JsonApi::Schema->new(%$schema)->serialize($data);
}
like(
Dancer2::Plugin::JsonApi::Schema->new(
type => 'thing',
top_level_meta => {
foo => 1,
bar => sub ( $data, $xtra ) {
$xtra->{bar};
}
}
)->serialize( {}, { bar => 'yup' } ),
{ meta => { foo => 1, bar => 'yup' } }
);
subtest 'attributes' => sub {
my $serialized =
Dancer2::Plugin::JsonApi::Schema->new( type => 'thing', )
->serialize( { id => 1, foo => 'bar' } );
is $serialized->{data} => {
type => 'thing',
id => 1,
attributes => { foo => 'bar', }
};
};
subtest 'a single scalar == id', sub {
my $serialized =
Dancer2::Plugin::JsonApi::Schema->new( type => 'thing' )
->serialize('blah');
is $serialized->{data} => {
type => 'thing',
id => 'blah',
};
};
subtest 'allowed_attributes', sub {
my $serialized = Dancer2::Plugin::JsonApi::Schema->new(
type => 'thing',
allowed_attributes => ['foo'],
)->serialize( { id => 1, foo => 2, bar => 3 } );
is $serialized->{data} => {
type => 'thing',
id => 1,
attributes => { foo => 2, }
};
};
subtest 'empty data', sub {
my $serialized =
Dancer2::Plugin::JsonApi::Schema->new( type => 'thing' )
->serialize(undef);
ok( !$serialized->{data}, "there is no data" );
};
package FakeRequest {
use Moo;
has path => ( is => 'ro', default => '/some/path' );
}
package FakeApp {
use Moo;
has request => (
is => 'ro',
default => sub {
FakeRequest->new;
}
);
}
subtest "add the self link if tied to the app" => sub {
my $serialized = Dancer2::Plugin::JsonApi::Schema->new(
type => 'thing',
registry =>
Dancer2::Plugin::JsonApi::Registry->new( app => FakeApp->new )
)->serialize(undef);
is $serialized->{links}{self} => '/some/path';
};
subtest 'attributes function' => sub {
my $serialized = Dancer2::Plugin::JsonApi::Schema->new(
type => 'thing',
attributes => sub ( $data, @ ) {
return +{ reverse %$data },;
},
)->serialize( { id => 1, 'a' .. 'd' } );
is $serialized->{data}{attributes} => { 1 => 'id', b => 'a', d => 'c' };
};
subtest 'before_serializer' => sub {
my $serialized = Dancer2::Plugin::JsonApi::Schema->new(
type => 'thing',
before_serialize => sub ( $data, @ ) {
return +{ %$data, nbr_attrs => scalar keys %$data };
},
)->serialize( { id => 1, a => 'b' } );
is $serialized->{data}{attributes}{nbr_attrs} => 2;
};
done_testing();

22
t/serializer.t Normal file
View File

@ -0,0 +1,22 @@
use Test2::V0;
use JSON qw/ from_json /;
use Dancer2::Serializer::JsonApi;
my $serializer =
Dancer2::Serializer::JsonApi->new( log_cb => sub { warn @_ } );
my $data = [ 'thing' => { id => 2 } ];
my $serialized = $serializer->serialize($data);
like from_json($serialized),
{ jsonapi => { version => '1.0' },
data => { id => 2, type => 'thing' },
};
todo 'not implemented yet' => sub {
is $serializer->deserialize($serialized) => $data;
};
done_testing;

9
t/todos.t Normal file
View File

@ -0,0 +1,9 @@
use Test2::V0;
use Test2::Plugin::ExitSummary;
todo 'general list of todos' => sub {
fail $_ for
'blocked_attributes';
};
done_testing;

36
xt/added-test.t Normal file
View File

@ -0,0 +1,36 @@
# verifies that at least one test file has been modified
# (the goal being that one test has been added or altered)
use 5.38.0;
use Test2::V0;
use Git::Wrapper;
my $target_branch = $ENV{TARGET_BRANCH} // 'main';
my $git = Git::Wrapper->new('.');
my $on_target = grep { "* $target_branch" eq $_ } $git->branch;
skip_all "already on target branch" if $on_target;
skip_all "manually disabled" if $ENV{NO_NEW_TEST};
ok test_file_modified( $git->diff($target_branch) ), "added to a test file";
sub test_file_modified (@diff) {
my $in_test_file = 0;
for (@diff) {
if (/^diff/) {
$in_test_file = /\.t$/;
next;
}
return 1 if $in_test_file and /^\+/;
}
return 0;
}
done_testing;

25
xt/perltidy.t Normal file
View File

@ -0,0 +1,25 @@
use 5.32.0;
use Test2::V0;
use Git::Wrapper;
use Test::PerlTidy qw( run_tests );
my $target_branch = $ENV{TARGET_BRANCH} // 'main';
my $git = Git::Wrapper->new('.');
my $on_target = grep { "* $target_branch" eq $_ } $git->branch;
if ($on_target) {
run_tests();
}
else {
my @files =
$git->diff( { name_only => 1, diff_filter => 'ACMR' }, $target_branch );
ok Test::PerlTidy::is_file_tidy($_), $_
for grep { /\.(pl|pm|pod|t)$/ } @files;
}
done_testing;

12
xt/worktree-clean.t Normal file
View File

@ -0,0 +1,12 @@
use Test2::V0;
use Git::Wrapper;
my $git = Git::Wrapper->new('.');
my $status = $git->status;
# note: 'yath' might create .test_info and lastlog.jsonl files
ok !$status->is_dirty => "worktree is clean";
done_testing;