You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
571 lines
20 KiB
571 lines
20 KiB
package NGCP::Panel::Controller::API::Root;
|
|
use NGCP::Panel::Utils::Generic qw(:all);
|
|
|
|
use Sipwise::Base;
|
|
|
|
use Encode qw(encode);
|
|
use Clone qw/clone/;
|
|
use HTTP::Headers qw();
|
|
use HTTP::Response qw();
|
|
use HTTP::Status qw(:constants);
|
|
use File::Find::Rule;
|
|
use JSON qw(to_json encode_json);
|
|
use YAML::XS qw/Dump/;
|
|
use Safe::Isa qw($_isa);
|
|
use NGCP::Panel::Utils::API;
|
|
use parent qw/Catalyst::Controller NGCP::Panel::Role::API/;
|
|
|
|
use NGCP::Panel::Utils::Journal qw();
|
|
|
|
#with 'NGCP::Panel::Role::API';
|
|
|
|
sub dispatch_path{return '/api/';}
|
|
|
|
sub allowed_methods{
|
|
return [qw/GET OPTIONS HEAD/];
|
|
}
|
|
|
|
__PACKAGE__->config(
|
|
action => {
|
|
map { $_ => {
|
|
ACLDetachTo => 'invalid_user',
|
|
AllowedRole => [qw/admin reseller ccareadmin ccare lintercept subscriberadmin subscriber/],
|
|
Args => 0,
|
|
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
|
|
Method => $_,
|
|
Path => __PACKAGE__->dispatch_path,
|
|
} } @{ __PACKAGE__->allowed_methods },
|
|
},
|
|
);
|
|
|
|
sub gather_default_action_roles {
|
|
my ($self, %args) = @_; my @roles = ();
|
|
push @roles, 'NGCP::Panel::Role::HTTPMethods' if $args{attributes}->{Method};
|
|
return @roles;
|
|
}
|
|
|
|
sub auto :Private {
|
|
my ($self, $c) = @_;
|
|
|
|
$self->set_body($c);
|
|
$self->log_request($c);
|
|
return 1;
|
|
}
|
|
|
|
sub GET : Allow {
|
|
my ($self, $c) = @_;
|
|
|
|
my $response_type;
|
|
if ($c->req->params->{swagger}) {
|
|
$response_type = 'swagger';
|
|
} elsif ($c->req->header('Accept') && $c->req->header('Accept') eq 'application/json') {
|
|
$response_type = 'plain_json';
|
|
} elsif ($c->req->params->{oldapidoc}) {
|
|
$response_type = 'oldapidoc';
|
|
} else {
|
|
$response_type = 'swaggerui';
|
|
}
|
|
|
|
if ($response_type eq 'swaggerui') {
|
|
$c->detach('swaggerui');
|
|
}
|
|
|
|
my $blacklist = {
|
|
"DomainPreferenceDefs" => 1,
|
|
"SubscriberPreferenceDefs" => 1,
|
|
"CustomerPreferenceDefs" => 1,
|
|
"ProfilePreferenceDefs" => 1,
|
|
"PeeringServerPreferenceDefs" => 1,
|
|
"ResellerPreferenceDefs" => 1,
|
|
"PbxDevicePreferenceDefs" => 1,
|
|
"PbxDeviceProfilePreferenceDefs" => 1,
|
|
"PbxFieldDevicePreferenceDefs" => 1,
|
|
"MetaConfigDefs" => 1,
|
|
"AuthTokens" => 1,
|
|
};
|
|
|
|
my $colls = NGCP::Panel::Utils::API::get_collections_files;
|
|
my %user_roles = map {$_ => 1} $c->user->roles;
|
|
foreach my $coll(@$colls) {
|
|
my $mod = $coll;
|
|
$mod =~ s/^.+\/([a-zA-Z0-9_]+)\.pm$/$1/;
|
|
next if(exists $blacklist->{$mod});
|
|
my $rel = lc $mod;
|
|
my $full_mod = 'NGCP::Panel::Controller::API::'.$mod;
|
|
my $full_item_mod = 'NGCP::Panel::Controller::API::'.$mod.'Item';
|
|
|
|
my $role = $full_mod->config->{action}->{OPTIONS}->{AllowedRole};
|
|
if($role && ref $role eq "ARRAY") {
|
|
next unless grep { $user_roles{$_}; } @{ $role };
|
|
} elsif ($role) {
|
|
next unless $user_roles{$role};
|
|
}
|
|
|
|
my $allowed_ngcp_types = $full_mod->config->{allowed_ngcp_types} // [];
|
|
if (@{$allowed_ngcp_types}) {
|
|
next unless grep { /^\Q$c->config->{general}{ngcp_type}\E$/ }
|
|
@{$allowed_ngcp_types};
|
|
}
|
|
|
|
my $query_params = [];
|
|
if($full_mod->can('query_params')) {
|
|
$query_params = $full_mod->query_params;
|
|
}
|
|
my $actions = [];
|
|
if($c->user->read_only) {
|
|
foreach my $m(sort keys %{ $full_mod->config->{action} }) {
|
|
next unless $m =~ /^(GET|HEAD|OPTIONS)$/;
|
|
push @{ $actions }, $m;
|
|
}
|
|
} else {
|
|
$actions = [ sort keys %{ $full_mod->config->{action} } ];
|
|
}
|
|
my $uri = "/api/$rel/";
|
|
my $item_actions = [];
|
|
my $journal_resource_config = {};
|
|
if($full_item_mod->can('config')) {
|
|
if($c->user->read_only) {
|
|
foreach my $m(sort keys %{ $full_item_mod->config->{action} }) {
|
|
next unless $m =~ /^(GET|HEAD|OPTIONS)$/;
|
|
push @{ $item_actions }, $m;
|
|
}
|
|
} else {
|
|
foreach my $m(sort keys %{ $full_item_mod->config->{action} }) {
|
|
next unless $m =~ /^(GET|HEAD|OPTIONS|PUT|PATCH|DELETE)$/;
|
|
push @{ $item_actions }, $m;
|
|
}
|
|
}
|
|
if($full_item_mod->can('resource_name')) {
|
|
my @operations = ();
|
|
my $op_config = {};
|
|
my $resource_name = $full_item_mod->resource_name;
|
|
$journal_resource_config = NGCP::Panel::Utils::Journal::get_journal_resource_config($c->config,$resource_name);
|
|
if (exists $full_mod->config->{action}->{POST}) {
|
|
$op_config = NGCP::Panel::Utils::Journal::get_api_journal_op_config($c->config,$resource_name,NGCP::Panel::Utils::Journal::CREATE_JOURNAL_OP);
|
|
if ($op_config->{operation_enabled}) {
|
|
push(@operations,"create (<span>POST $uri</span>)");
|
|
}
|
|
}
|
|
my $item_action_config = $full_item_mod->config->{action};
|
|
if (exists $item_action_config->{PUT} || exists $item_action_config->{PATCH}) {
|
|
$op_config = NGCP::Panel::Utils::Journal::get_api_journal_op_config($c->config,$resource_name,NGCP::Panel::Utils::Journal::UPDATE_JOURNAL_OP);
|
|
if ($op_config->{operation_enabled}) {
|
|
if (exists $item_action_config->{PUT} && exists $item_action_config->{PATCH}) {
|
|
push(@operations,"update (<span>PUT/PATCH $uri"."id</span>)");
|
|
} elsif (exists $item_action_config->{PUT}) {
|
|
push(@operations,"update (<span>PUT $uri"."id</span>)");
|
|
} elsif (exists $item_action_config->{PATCH}) {
|
|
push(@operations,"update (<span>PATCH $uri"."id</span>)");
|
|
}
|
|
}
|
|
}
|
|
if (exists $item_action_config->{DELETE}) {
|
|
$op_config = NGCP::Panel::Utils::Journal::get_api_journal_op_config($c->config,$resource_name,NGCP::Panel::Utils::Journal::CREATE_JOURNAL_OP);
|
|
if ($op_config->{operation_enabled}) {
|
|
push(@operations,"delete (<span>DELETE $uri"."id</span>)");
|
|
}
|
|
}
|
|
$journal_resource_config->{operations} = \@operations;
|
|
$journal_resource_config->{format} = $op_config->{format};
|
|
$journal_resource_config->{uri} = 'api/' . $resource_name . '/id/' . NGCP::Panel::Utils::Journal::API_JOURNAL_RESOURCE_NAME . '/';
|
|
$journal_resource_config->{query_params} = ($full_item_mod->can('journal_query_params') ? $full_item_mod->journal_query_params : []);
|
|
$journal_resource_config->{sorting_cols} = NGCP::Panel::Utils::Journal::JOURNAL_FIELDS;
|
|
$journal_resource_config->{item_uri} = $journal_resource_config->{uri} . 'journalitemid';
|
|
if (length(NGCP::Panel::Utils::Journal::API_JOURNALITEMTOP_RESOURCE_NAME) > 0) {
|
|
$journal_resource_config->{recent_uri} = $journal_resource_config->{uri} . NGCP::Panel::Utils::Journal::API_JOURNALITEMTOP_RESOURCE_NAME;
|
|
}
|
|
}
|
|
}
|
|
|
|
my ($form) = $full_mod->get_form($c);
|
|
|
|
my $sorting_cols = [];
|
|
my ($explicit_order_cols, $explicit_order_cols_params);
|
|
if ($full_mod->can('order_by_cols')) {
|
|
($explicit_order_cols,$explicit_order_cols_params) = $full_mod->order_by_cols($c);
|
|
$sorting_cols = [ sort keys %$explicit_order_cols ];
|
|
$explicit_order_cols_params //= {};
|
|
}
|
|
if (!$explicit_order_cols || $explicit_order_cols_params->{columns_are_additional}) {
|
|
my $item_rs;
|
|
try {
|
|
$item_rs = $full_mod->item_rs($c, "");
|
|
}
|
|
if ($item_rs) {
|
|
if(ref $item_rs eq "ARRAY") {
|
|
$sorting_cols = [sort (@$sorting_cols, map { $_->{name} } @{ $item_rs })];
|
|
} else {
|
|
$sorting_cols = [sort (@$sorting_cols, $item_rs->result_source->columns)];
|
|
}
|
|
}
|
|
}
|
|
my ($form_fields,$form_fields_upload) = $form ? $self->get_collection_properties($form) : ([],[]);
|
|
|
|
my $documentation_sample = {} ;
|
|
my $documentation_sample_process = sub{
|
|
my $s = shift;
|
|
$s = to_json($s, {pretty => 1}) =~ s/(^\s*{\s*)|(\s*}\s*$)//rg =~ s/\n /\n/rg;
|
|
return $s;
|
|
};
|
|
if ($full_mod->can('documentation_sample')) {
|
|
$documentation_sample->{sample_orig}->{default} = $full_mod->documentation_sample;
|
|
$documentation_sample->{sample}->{default} = $documentation_sample_process->($documentation_sample->{sample_orig}->{default});
|
|
}
|
|
foreach my $action (qw/create update/){
|
|
my $method = 'documentation_sample_'.$action;
|
|
if ($full_mod->can($method)) {
|
|
$documentation_sample->{sample_orig}->{$action} = $full_mod->$method;
|
|
$documentation_sample->{sample}->{$action} = $documentation_sample_process->($documentation_sample->{sample_orig}->{$action});
|
|
}
|
|
}
|
|
|
|
|
|
my $entity_name = $mod;
|
|
if($entity_name eq 'Faxes') {
|
|
$entity_name = 'Fax';
|
|
} else {
|
|
$entity_name =~ s/ies$/y/;
|
|
$entity_name =~ s/s$//;
|
|
}
|
|
|
|
$c->stash->{collections}->{$rel} = {
|
|
name => $mod,
|
|
entity_name => $entity_name,
|
|
description => $full_mod->api_description,
|
|
fields => $form_fields,
|
|
uploads => $form_fields_upload,
|
|
config => $full_mod->config ,
|
|
( $full_item_mod && $full_item_mod->can('config') ) ? (item_config => $full_item_mod->config) : () ,
|
|
query_params => $query_params,
|
|
actions => $actions,
|
|
item_actions => $item_actions,
|
|
sorting_cols => $sorting_cols,
|
|
uri => $uri,
|
|
properties => ( $full_mod->can('properties') ? $full_mod->properties : {} ),#
|
|
%$documentation_sample,
|
|
journal_resource_config => $journal_resource_config,
|
|
};
|
|
|
|
}
|
|
|
|
if ($user_roles{subscriber} || $user_roles{subscriberadmin}) {
|
|
$c->stash(is_subscriber_api => 1);
|
|
} else {
|
|
$c->stash(is_admin_api => 1);
|
|
}
|
|
|
|
if ($response_type eq 'plain_json') {
|
|
my $body = {};
|
|
foreach my $rel(sort keys %{ $c->stash->{collections} }) {
|
|
my $r = $c->stash->{collections}->{$rel};
|
|
foreach my $k(qw/
|
|
actions item_actions fields sorting_cols
|
|
/) {
|
|
$body->{$rel}->{$k} = $r->{$k};
|
|
}
|
|
$body->{$rel}->{query_params} = [
|
|
map { $_->{param} } @{ $r->{query_params} }
|
|
];
|
|
}
|
|
$c->response->body(JSON::to_json($body, { pretty => 1 }));
|
|
$c->response->headers(HTTP::Headers->new(
|
|
Content_Language => 'en',
|
|
Content_Type => 'application/json',
|
|
));
|
|
} elsif ($response_type eq 'swagger') {
|
|
$c->detach('swagger');
|
|
} elsif ($response_type eq 'oldapidoc') {
|
|
$c->stash(template => 'api/root.tt');
|
|
$c->forward($c->view);
|
|
$c->response->headers(HTTP::Headers->new(
|
|
Content_Language => 'en',
|
|
Content_Type => 'application/xhtml+xml',
|
|
#$self->collections_link_headers,
|
|
));
|
|
} else {
|
|
die 'This should never happen.';
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
sub swagger :Private {
|
|
my ($self, $c) = @_;
|
|
|
|
my $collections = $c->stash->{collections};
|
|
my $user_role = $c->user->roles;
|
|
|
|
my $result = NGCP::Panel::Utils::API::generate_swagger_datastructure(
|
|
$collections,
|
|
$user_role,
|
|
);
|
|
|
|
my $headers = HTTP::Headers->new(Content_Language => 'en');
|
|
my $response;
|
|
if ($c->req->params->{swagger} eq 'yml') {
|
|
$headers->header(Content_Type => 'application/x-yaml');
|
|
$response = Dump($result);
|
|
}
|
|
else {
|
|
$headers->header(Content_Type => 'application/json');
|
|
$response = encode_json($result);
|
|
}
|
|
$c->response->headers($headers);
|
|
$c->response->body($response);
|
|
$c->response->code(200);
|
|
return;
|
|
}
|
|
|
|
sub swaggerui :Private {
|
|
my ($self, $c) = @_;
|
|
|
|
|
|
$c->stash(template => 'api/swaggerui.tt');
|
|
$c->forward($c->view);
|
|
# $c->response->headers(HTTP::Headers->new(
|
|
# Content_Language => 'en',
|
|
# Content_Type => 'application/xhtml+xml',
|
|
# #$self->collections_link_headers,
|
|
# ));
|
|
}
|
|
|
|
sub platforminfo :Path('/api/platforminfo') :CaptureArgs(0) {
|
|
my ($self, $c) = @_;
|
|
|
|
$c->response->content_type('application/json');
|
|
unless (uc $c->request->method eq 'GET') {
|
|
$c->response->status(HTTP_METHOD_NOT_ALLOWED);
|
|
$c->response->body(q());
|
|
return;
|
|
}
|
|
|
|
$c->stash->{ngcp_api_realm} = $c->request->env->{NGCP_API_REALM} // "";
|
|
$c->stash(template => 'api/platforminfo.tt');
|
|
$c->forward($c->view());
|
|
}
|
|
|
|
sub HEAD : Allow {
|
|
my ($self, $c) = @_;
|
|
$c->forward(qw(GET));
|
|
$c->response->body(q());
|
|
return;
|
|
}
|
|
|
|
sub OPTIONS : Allow {
|
|
my ($self, $c) = @_;
|
|
my $allowed_methods = $self->allowed_methods_filtered($c);
|
|
$c->response->headers(HTTP::Headers->new(
|
|
Allow => join(', ', @{ $allowed_methods }),
|
|
$self->collections_link_headers,
|
|
));
|
|
$c->response->content_type('application/json');
|
|
$c->response->body(JSON::to_json({ methods => $allowed_methods })."\n");
|
|
return;
|
|
}
|
|
|
|
sub collections_link_headers : Private {
|
|
my ($self) = @_;
|
|
|
|
my $colls = NGCP::Panel::Utils::API::get_collections_files;
|
|
|
|
# create Link header for each of the collections
|
|
my @links = ();
|
|
foreach my $mod(@$colls) {
|
|
# extract file base from path (e.g. Foo from lib/something/Foo.pm)
|
|
$mod =~ s/^.+\/([a-zA-Z0-9_]+)\.pm$/$1/;
|
|
my $rel = lc $mod;
|
|
$mod = 'NGCP::Panel::Controller::API::'.$mod;
|
|
my $dp = $mod->dispatch_path;
|
|
push @links, Link => '<'.$dp.'>; rel="collection http://purl.org/sipwise/ngcp-api/#rel-'.$rel.'"';
|
|
}
|
|
return @links;
|
|
}
|
|
|
|
sub invalid_user : Private {
|
|
my ($self, $c, $ssl_client_m_serial) = @_;
|
|
|
|
#$self->error($c, HTTP_FORBIDDEN, "Invalid certificate serial number '$ssl_client_m_serial'.");
|
|
$self->error($c, HTTP_FORBIDDEN, "Invalid user");
|
|
return;
|
|
}
|
|
|
|
sub field_to_json : Private {
|
|
my ($self, $field) = @_;
|
|
|
|
if ($field->$_isa('HTML::FormHandler::Field::Select')) {
|
|
return $self->field_to_select_options($field);
|
|
} # elsif { ... }
|
|
|
|
|
|
SWITCH: for ($field->type) {
|
|
/Float|Integer|Money|PosInteger|Minute|Hour|MonthDay|Year/ &&
|
|
return "Number";
|
|
/Boolean/ &&
|
|
return "Boolean";
|
|
/Repeatable/ &&
|
|
return "Array";
|
|
/\+NGCP::Panel::Field::Select/ &&
|
|
return $self->field_to_select_options($field);
|
|
/\+NGCP::Panel::Field::Regex/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::DateTime/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::Country/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::EmailList/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::Identifier/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::URI/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::IPAddress/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::E164/ &&
|
|
return "Object";
|
|
/Compound/ &&
|
|
return "Object";
|
|
/\+NGCP::Panel::Field::AliasNumber/ &&
|
|
return "Array";
|
|
/\+NGCP::Panel::Field::PbxGroupAPI/ &&
|
|
return "Array";
|
|
/\+NGCP::Panel::Field::PbxGroupMemberAPI/ &&
|
|
return "Array";
|
|
/\+NGCP::Panel::Field::Interval/ &&
|
|
return "Object";
|
|
/\+NGCP::Panel::Field::DatePicker/ &&
|
|
return "String";
|
|
/\+NGCP::Panel::Field::NumRangeAPI/ &&
|
|
return "String";
|
|
# usually {xxx}{id}
|
|
/\+NGCP::Panel::Field::/ &&
|
|
return "Number";
|
|
# default
|
|
return "String";
|
|
} # SWITCH
|
|
}
|
|
|
|
sub field_to_select_options : Private {
|
|
my ($self, $field) = @_;
|
|
return join('|',map {
|
|
my $value = $_->{value};
|
|
my $label = $_->{label};
|
|
my $s = defined $value ? "'".$value."'" : 'null';
|
|
if (defined $label && length($label)) {
|
|
if (!defined $value || (lc($value) ne lc($label))) {
|
|
$s.=' ('.$label.')';
|
|
}
|
|
}
|
|
$s;
|
|
} @{$field->options});
|
|
|
|
}
|
|
|
|
sub get_field_poperties :Private{
|
|
my ($self, $field) = @_;
|
|
my $name = $field->name;
|
|
|
|
return () if (
|
|
$field->type eq "Hidden" ||
|
|
$field->type eq "Button" ||
|
|
$field->type eq "Submit" ||
|
|
$field->type eq "AddElement" ||
|
|
$field->type eq "RmElement" ||
|
|
0);
|
|
my @types = ();
|
|
push @types, 'null' unless ($field->required || $field->validate_when_empty);
|
|
my $type;
|
|
if($field->type =~ /^\+NGCP::Panel::Field::/) {
|
|
if($field->type =~ /E164$/) {
|
|
$name = 'primary_number';
|
|
} elsif($field->type =~ /AliasNumber/) {
|
|
$name = 'alias_numbers';
|
|
} elsif($field->type =~ /PbxGroupAPI/) {
|
|
$name = 'pbx_group_ids';
|
|
} elsif($field->type =~ /Country$/) {
|
|
$name = 'country';
|
|
} elsif($field->type =~ /LnpCarrier$/) {
|
|
$name = 'carrier_id';
|
|
} elsif($field->type !~ /Regex|EmailList|Identifier|PosInteger|Interval|Select|DateTime|URI|IPAddress|DatePicker|ProfileNetwork|CFSimpleAPICompound|NumRangeAPI|IntegerList/) { # ...?
|
|
$name .= '_id';
|
|
}
|
|
}
|
|
my $enum;
|
|
push(@types, $self->field_to_json($field));
|
|
if ($field->$_isa('HTML::FormHandler::Field::Select')) {
|
|
$enum = $field->options;
|
|
}
|
|
my $desc = undef;
|
|
if($field->element_attr) {
|
|
$desc = $field->element_attr->{title}->[0];
|
|
}
|
|
unless (defined $desc && length($desc) > 0) {
|
|
$desc = $field->label;
|
|
}
|
|
unless (defined $desc && length($desc) > 0) {
|
|
$desc = 'to be described ...';
|
|
}
|
|
|
|
my $subfields;
|
|
if ($field->has_fields && scalar ($field->fields)) {
|
|
my ($firstsub) = $field->fields;
|
|
if ($field->isa('HTML::FormHandler::Field::Repeatable') && $firstsub) {
|
|
($subfields) = $self->get_collection_properties($firstsub, 1);
|
|
} elsif ($firstsub->type eq '+NGCP::Panel::Field::DataTable' && $name =~ /_id$/) {
|
|
# don't render subfields (only DataTable Field with Button)
|
|
} elsif ($firstsub->type eq '+NGCP::Panel::Field::DataTable' && $name =~ /^(country|timezone)$/) {
|
|
# also don't render subfields of country and timezone (they have no _id ending)
|
|
} elsif ($field->type eq 'String' && $name =~ /^(domain)$/) {
|
|
# another special case, special syntax of domain in subscribers
|
|
} else {
|
|
($subfields) = $self->get_collection_properties($field, 1);
|
|
}
|
|
}
|
|
|
|
return { name => $name, description => $desc, types => \@types, type_original => $field->type,
|
|
readonly => $field->readonly,
|
|
($enum ? (enum => $enum) : ()),
|
|
($subfields ? (subfields => $subfields) : ()),
|
|
};
|
|
}
|
|
|
|
sub get_collection_properties {
|
|
my ($self, $form, $is_nested) = @_;
|
|
|
|
my $renderlist = $form->form && !$is_nested
|
|
? $form->form->blocks->{fields}->{render_list}
|
|
: undef;
|
|
my %renderlist = defined $renderlist ? map { $_ => 1 } @{$renderlist} : ();
|
|
|
|
my @props = ();
|
|
my @uploads = ();
|
|
foreach my $f($form->fields) {
|
|
my $name = $f->name;
|
|
next if (defined $renderlist && !exists $renderlist{$name});
|
|
my $field_spec = $self->get_field_poperties($f);
|
|
next if !$field_spec;
|
|
push @props, $field_spec;
|
|
push @uploads, $field_spec if $f->type =~/Upload/;
|
|
if (my $spec = $f->element_attr->{implicit_parameter}) {
|
|
my $f_implicit = clone($f);
|
|
foreach my $field_attribute (keys %{$spec}){
|
|
$f_implicit->$field_attribute($spec->{$field_attribute});
|
|
}
|
|
push @props, $self->get_field_poperties($f_implicit);
|
|
}
|
|
}
|
|
@props = sort{$a->{name} cmp $b->{name}} @props;
|
|
|
|
return (\@props,\@uploads);
|
|
}
|
|
|
|
sub end : Private {
|
|
my ($self, $c) = @_;
|
|
|
|
#$self->log_response($c);
|
|
return 1;
|
|
}
|
|
|
|
1;
|
|
|
|
# vim: set tabstop=4 expandtab:
|