API tooling (#3033)

* Updates to API generation and tooling

- Added build script and build instructions for API files
- Updated `.gitignore` to keep node_module folders out, wherever they occur
- Rewrite existing YAML files to use new component system
- Compile rewritten YAML files
- Add more validation checks to the route builders and dispatchers
- Add a user-facing documentation page with interactive view using RapiDoc
- Add a generic 404 handler for missing routes under `/api`, which returns JSON instead of HTML
- Clean up spec output route slightly to make it valid OpenAPI 3.0.0

* Auto-fill API Key in docs

Co-authored-by: Cocoa <momijizukamori@gmail.com>
This commit is contained in:
momijizukamori 2023-01-04 01:42:09 -05:00 committed by GitHub
parent b9ddeb84fe
commit a0ca65ee1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 4477 additions and 97 deletions

3
.gitignore vendored
View File

@ -15,6 +15,9 @@ src/proxy/proxy
.vstags
.perl-cpm
# Ignore node_modules, wherever they occur
/**/node_modules/
# Ignore SCSS cache
.sass-cache
# Ignore compiled CSS

1
api/README.md Normal file
View File

@ -0,0 +1 @@
This folder contains the YAML files used to generate and validate [OpenAPI](https://openapis.org) routes for Dreamwidth, and to build the spec file supplied to end users. `src` contains the files you should edit - reusable components should go into `src\components\` and can then be referenced using JS Schema reference notation (eg, `$ref: components/schemas/username.yaml`). This cuts down on items that need to be retyped, and keeps descriptions of items consistent across different endpoints. `dist` contains the compiled YAML files that are used by the Perl endpoint controllers. Because YAML has no mechanism for file includes, there is unfortunately still a manual step required to rebuild the `dist` files when the `src` files are changed. First install node and then the necessary packages (`npm install` from inside this folder), and then run the `build.js` file (`node build.js`). This will compile the YAML files, and print any errors encountered along the way to the terminal.

36
api/build.js Normal file
View File

@ -0,0 +1,36 @@
const $RefParser = require("@apidevtools/json-schema-ref-parser");
const YAML = require('yaml');
const fs = require("fs");
const path = require("path");
async function* walk(dir) {
for await (const d of await fs.promises.opendir(dir)) {
const entry = path.join(dir, d.name);
if (d.isDirectory()) yield* walk(entry);
else if (d.isFile()) yield entry;
}
}
async function main() {
for await (const p of walk('src/')) {
let out_path = p.replace('src/', 'dist/')
$RefParser.dereference(p, (err, schema) => {
if (err) {
console.log(p)
console.error(err);
}
else {
// console.log(YAML.stringify(schema));
fs.writeFile(out_path, YAML.stringify(schema), err => {
if (err) {
console.error(err);
}
// file written successfully
});
}
});
}
}
main();

7
api/dist/comments/screening.yaml vendored Normal file
View File

@ -0,0 +1,7 @@
paths:
/comments/screening:
get:
description: Returns descriptions of all possible comment screening options.
responses:
"200":
description: A list of comment screening options and their descriptions.

8
api/dist/components/error.yaml vendored Normal file
View File

@ -0,0 +1,8 @@
type: object
properties:
error:
type: string
description: A description of the error encountered.
example: "Bad format for username. Errors: String is too long: 77/25."
success:
type: number

12
api/dist/components/errors/400.yaml vendored Normal file
View File

@ -0,0 +1,12 @@
description: Bad or missing request parameters.
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: A description of the error encountered.
example: "Bad format for username. Errors: String is too long: 77/25."
success:
type: number

View File

@ -0,0 +1,12 @@
description: Username specified does not exist.
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: A description of the error encountered.
example: "Bad format for username. Errors: String is too long: 77/25."
success:
type: number

21
api/dist/components/schemas/icon.yaml vendored Normal file
View File

@ -0,0 +1,21 @@
type: object
required:
- comment
- picid
- username
- url
- keywords
properties:
comment:
type: string
picid:
type: integer
username:
type: string
description: The name of the journal this icon belongs to.
url:
type: string
keywords:
type: array
items:
type: string

View File

@ -0,0 +1,5 @@
type: string
minLength: 3
maxLength: 25
pattern: ^[0-9A-Za-z_]+$
example: example

65
api/dist/icons.yaml vendored Normal file
View File

@ -0,0 +1,65 @@
paths:
"/users/{username}/icons/{picid}":
parameters:
- name: username
in: path
description: The username you want icon information for
required: true
schema:
type: string
minLength: 3
maxLength: 25
pattern: ^[0-9A-Za-z_]+$
example: example
- name: picid
in: path
description: The picid you want information for.
required: true
schema:
type: integer
get:
description: Returns a single icon for a specified picid and username
responses:
"200":
description: An icon with it's information
content:
application/json:
schema:
type: object
required:
- comment
- picid
- username
- url
- keywords
properties:
comment:
type: string
picid:
type: integer
username:
type: string
description: The name of the journal this icon belongs to.
url:
type: string
keywords:
type: array
items:
type: string
"400":
description: Bad or missing request parameters.
content:
application/json:
schema:
&a1
type: object
properties:
error:
type: string
description: A description of the error encountered.
example: "Bad format for username. Errors: String is too long: 77/25."
success:
type: number
"404":
description: No such username or icon.
schema: *a1

57
api/dist/icons_all.yaml vendored Normal file
View File

@ -0,0 +1,57 @@
paths:
"/users/{username}/icons":
parameters:
- name: username
in: path
description: The username you want icon information for
required: true
schema:
type: string
minLength: 3
maxLength: 25
pattern: ^[0-9A-Za-z_]+$
example: example
get:
description: Returns all icons for a specified username.
responses:
"200":
description: a list of icons
content:
application/json:
schema:
type: array
items:
type: object
required:
- comment
- picid
- username
- url
- keywords
properties:
comment:
type: string
picid:
type: integer
username:
type: string
description: The name of the journal this icon belongs to.
url:
type: string
keywords:
type: array
items:
type: string
"404":
description: Username specified does not exist.
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: A description of the error encountered.
example: "Bad format for username. Errors: String is too long: 77/25."
success:
type: number

7
api/dist/spec.yaml vendored Normal file
View File

@ -0,0 +1,7 @@
paths:
/spec:
get:
description: Returns the API specification
responses:
"200":
description: This API specification!

View File

@ -1,40 +0,0 @@
---
paths:
/users/{username}/icons:
parameters:
- name: username
in: path
description: The username you want icon information for
required: true
schema:
type: string
get:
description: Returns all icons for a specified username.
responses:
200:
description: a list of icons
content:
application/json:
schema:
type: array
items:
type: object
required:
- comment
- picid
- username
- url
- keywords
properties:
comment:
type: string
picid:
type: integer
username:
type: string
url:
type: string
keywords:
type: array
items:
type: string

112
api/package-lock.json generated Normal file
View File

@ -0,0 +1,112 @@
{
"name": "dreamwidth",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "dreamwidth",
"version": "1.0.0",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.9",
"yaml": "^2.1.3"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz",
"integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
},
"node_modules/@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/yaml": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.3.tgz",
"integrity": "sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg==",
"engines": {
"node": ">= 14"
}
}
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz",
"integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==",
"requires": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
},
"@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="
},
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="
},
"js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"requires": {
"argparse": "^2.0.1"
}
},
"yaml": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.3.tgz",
"integrity": "sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg=="
}
}
}

19
api/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.9",
"yaml": "^2.1.3"
},
"name": "dreamwidth",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:json": "boats -i ./src/index.yml.njk -o ./build/${npm_package_name}.json",
"build:yaml": "boats -i ./src/index.yml.njk -o ./build/${npm_package_name}.yml",
"build": "npm run build:json && npm run build:yaml"
},
"keywords": [],
"author": "",
"description": "",
"private": true
}

View File

@ -0,0 +1,8 @@
type: object
properties:
error:
type: string
description: A description of the error encountered.
example: "Bad format for username. Errors: String is too long: 77/25."
success:
type: number

View File

@ -0,0 +1,5 @@
description: Bad or missing request parameters.
content:
application/json:
schema:
$ref: ../error.yaml

View File

@ -0,0 +1,5 @@
description: Username specified does not exist.
content:
application/json:
schema:
$ref: ../error.yaml

View File

@ -0,0 +1,21 @@
type: object
required:
- comment
- picid
- username
- url
- keywords
properties:
comment:
type: string
picid:
type: integer
username:
type: string
description: The name of the journal this icon belongs to.
url:
type: string
keywords:
type: array
items:
type: string

View File

@ -0,0 +1,5 @@
type: string
minLength: 3
maxLength: 25
pattern: "^[0-9A-Za-z_]+$"
example: example

View File

@ -6,8 +6,8 @@ paths:
in: path
description: The username you want icon information for
required: true
schema:
type: string
schema:
$ref: components/schemas/username.yaml
- name: picid
in: path
description: The picid you want information for.
@ -21,20 +21,11 @@ paths:
description: An icon with it's information
content:
application/json:
schema:
type: object
properties:
comment:
type: string
picid:
type: integer
username:
type: string
url:
type: string
keywords:
type: array
items:
type: string
schema:
$ref: components/schemas/icon.yaml
404:
description: No such icon.
description: No such username or icon.
schema:
$ref: components/error.yaml
400:
$ref: components/errors/400.yaml

23
api/src/icons_all.yaml Normal file
View File

@ -0,0 +1,23 @@
---
paths:
/users/{username}/icons:
parameters:
- name: username
in: path
description: The username you want icon information for
required: true
schema:
$ref: components/schemas/username.yaml
get:
description: Returns all icons for a specified username.
responses:
200:
description: a list of icons
content:
application/json:
schema:
type: array
items:
$ref: components/schemas/icon.yaml
404:
$ref: components/errors/404-user.yaml

View File

@ -969,6 +969,7 @@ sub trans {
uri => "/v$ver$2",
role => 'api'
);
$ret //= DW::Routing->call( uri => "/internal/api/404" );
return $ret if defined $ret;
}

View File

@ -174,4 +174,22 @@ sub hash {
return $_[0]->{keyhash};
}
# Usage: get_one (user)
# Given a user, either return the first found key for them, or
# if they have no keys yet, generate one. Intended for use in
# situations where we have a logged in user and want to get a working API
# key for them, without forcing them to jump through the menu hoops themselves.
sub get_one {
my ( $self, $u ) = @_;
my $apikeys = $self->get_keys_for_user($u);
my $key;
if (defined($apikeys->[0])) {
$key = $apikeys ->[0];
} else {
$key = $self->new_for_user($u);
}
return $key;
}
1;

View File

@ -66,9 +66,12 @@ sub param {
# Creates a special instance of DW::API::Parameter object and
# adds it as the requestBody definition for the calling method
sub body {
my ( $self, @args ) = @_;
my $param = DW::API::Parameter->define_parameter(@args);
$self->{requestBody} = $param;
my ( $self, $config ) = @_;
$self->{requestBody}->{required} = $config->{required};
for my $ct ( keys( $config->{content} ) ) {
my $param = DW::API::Parameter->define_body( $config->{content}->{$ct}, $ct );
$self->{requestBody}{content}{$ct} = $param;
}
}
@ -197,12 +200,24 @@ sub TO_JSON {
$json->{parameters} = [ values %{ $self->{params} } ];
}
if ( defined $self->{requestBody} ) {
$json->{requestBody} = $self->{requestBody};
if ( defined $self->{requestBody}{required} && $self->{requestBody}{required} ) {
$json->{requestBody}{required} = $JSON::true;
}
else {
delete $json->{requestBody}{required};
}
}
my $responses = $self->{responses};
for my $key ( keys %{ $self->{responses} } ) {
$json->{responses}{$key} = { description => $responses->{$key}{desc} };
$json->{responses}{$key}{schema} = $responses->{$key}{schema}
if defined $responses->{$key}{schema};
for my $return_type ( keys %{ $self->{responses}{$key}{content} } ) {
$json->{responses}{$key}{content}{$return_type}{schema} = $responses->{$key}{content}{$return_type}{schema}
if defined $responses->{$key}{content}{$return_type}{schema};
}
}
return $json;

View File

@ -46,24 +46,37 @@ sub define_parameter {
}
elsif ( defined $args->{content} ) {
$parameter->{content} = $args->{content};
$parameter->{in} = 'requestBody';
}
bless $parameter, $class;
$parameter->_validate;
$parameter->_validate_json;
return $parameter;
}
sub define_body {
my ( $class, $args, $content ) = @_;
my $parameter = { in => 'requestBody', };
if ( defined $args->{schema} ) {
$parameter->{schema} = $args->{schema};
}
bless $parameter, $class;
if ( $content eq 'application/json' ) {
$parameter->_validate_json;
return $parameter;
}
}
# Usage: validate ( Parameter object )
# Does some simple validation checks for parameter objects
# Makes sure required fields are present, and that the
# location given is a valid one.
sub _validate {
sub _validate_json {
my $self = $_[0];
for my $field (@REQ_ATTRIBUTES) {
croak "$self is missing required field $field" unless defined $self->{$field};
}
my $location = $self->{in};
croak "$location isn't a valid parameter location" unless grep( $location, @LOCATIONS );
@ -73,13 +86,6 @@ sub _validate {
croak "Can only define one of content or schema!" if $has_schema && $has_content;
croak "Must define at least one of content or schema!" unless $has_content || $has_schema;
# requestBody is a special instance of Parameter and has stricter rules
if ( $location eq "requestBody" ) {
if ( not defined( keys %{ $self->{content} } ) ) {
croak "requestBody must have at least one content-type!";
}
}
# Run schema validators
DW::Controller::API::REST::schema($self) if ( defined $self->{schema} );
@ -102,8 +108,11 @@ sub TO_JSON {
in => $self->{in},
};
# Schema fields we need to force to be numeric
if ( defined $self->{schema} ) {
$json->{schema} = $self->{schema};
force_numeric( $json->{schema} );
}
elsif ( defined $self->{content} ) {
$json->{content} = $self->{content};
@ -111,13 +120,41 @@ sub TO_JSON {
# content type is just a hash, but we don't want to print the validator too
for my $content_type ( keys %{ $json->{content} } ) {
delete $json->{content}->{$content_type}{validator};
force_numeric( $json->{content}->{$content_type}{schema} );
}
}
if ( $self->{in} eq "requestBody" ) {
#remove some fields that requestBody doesn't need
delete $json->{in};
delete $json->{name};
delete $json->{description};
}
$json->{required} = $JSON::true if defined $self->{required} && $self->{required};
return $json;
}
sub force_numeric {
my $schema = $_[0];
my @numerics = ( 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems' );
if ( $schema->{type} eq 'object' ) {
for my $prop ( keys %{ $schema->{properties} } ) {
force_numeric( $schema->{properties}{$prop} );
}
}
elsif ( $schema->{type} eq 'array' ) {
force_numeric( $schema->{items} );
}
else {
foreach my $item (@numerics) {
$schema->{$item} += 0 if defined( $schema->{$item} );
}
}
}
1;

View File

@ -39,7 +39,7 @@ our %TYPE_REGEX = (
boolean => '(true|false)',
);
our %METHODS = ( get => 1, post => 1, delete => 1 );
our $API_PATH = "$ENV{LJHOME}/api/";
our $API_PATH = "$ENV{LJHOME}/api/dist/";
# Usage: path ( yaml_source_path, ver, hash_of_HTTP_handlers )
# Creates a new path object for use in DW::Controller::API::REST
@ -146,11 +146,14 @@ sub _dispatcher {
my $r = $rv->{r};
my $keystr = $r->header_in('Authorization');
$keystr =~ s/Bearer (\w+)/$1/;
my $apikey = DW::API::Key->get_key($keystr);
my $apikey;
if ( defined($keystr) ) {
$keystr =~ s/Bearer (\w+)/$1/;
$apikey = DW::API::Key->get_key($keystr);
}
# all paths require an API key except the spec (which informs users that they need a key and where to put it)
unless ( $apikey || $self->{path}{name} eq "/spec" ) {
unless ( defined($apikey) || $self->{path}{name} eq "/spec" ) {
$r->print( to_json( { success => 0, error => "Missing or invalid API key" } ) );
$r->status('401');
return;
@ -169,21 +172,24 @@ sub _dispatcher {
# check path-level parameters.
for my $param ( keys %{ $self->{path}{params} } ) {
_validate_param( $param, $self->{path}{params}{$param}, $r, $path_params, $args );
my $valid =
_validate_param( $param, $self->{path}{params}{$param}, $r, $path_params, $args );
return unless $valid;
}
my $method = lc $r->method;
my $handler = $self->{path}{methods}->{$method}->{handler};
my $method_self = $self->{path}{methods}->{$method};
# check method-level parameters
for my $param ( keys %{ $method_self->{params} } ) {
_validate_param( $param, $self->{params}{$param}, $r, $args );
my $valid = _validate_param( $param, $method_self->{params}{$param}, $r, undef, $args );
return unless $valid;
}
# if we accept a request body, validate that too.
if ( defined $method_self->{requestBody} ) {
_validate_body( $method_self->{requestBody}, $r, $args );
my $valid = _validate_body( $method_self->{requestBody}, $r, $args );
return unless $valid;
}
# some handlers need to know what version they are
@ -196,7 +202,7 @@ sub _dispatcher {
# Generic response for unimplemented API methods.
$r->print( to_json( { success => 0, error => "Not Implemented" } ) );
$r->status('501');
return;
return $r->OK;
}
}
@ -235,10 +241,14 @@ sub _validate_param {
unless ( defined $p ) {
$r->print( to_json( { success => 0, error => "Missing required parameter $param" } ) );
$r->status('400');
return;
return 0;
}
}
# non-required parameters may be undef without it being an error
# but we shouldn't try to validate them if they're undef.
return 1 unless ( defined $p );
# run the schema validator
my @errors = $pval->validate($p);
if (@errors) {
@ -246,10 +256,11 @@ sub _validate_param {
$r->print(
to_json( { success => 0, error => "Bad format for $param. Errors: $err_str" } ) );
$r->status('400');
return;
return 0;
}
$arg_obj->{$ploc}{$param} = $p;
return 1;
}
# Usage: _validate_body (requestBody config, request, arg object)
@ -263,9 +274,9 @@ sub _validate_param {
sub _validate_body {
my ( $config, $r, $arg_obj ) = @_;
my $preq = $config->{required};
my $content_type = lc $r->header_in('Content-Type');
$content_type =~ s/;.*//; # drop data that isn't the MIMEtype
my $p;
if ( $content_type eq 'application/json' ) {
@ -284,6 +295,9 @@ sub _validate_body {
}
$p = $upload_hash;
}
else {
warn "Unexpected content-type $content_type";
}
# make sure that required parameters are supplied
if ($preq) {
@ -291,21 +305,26 @@ sub _validate_body {
$r->print(
to_json( { success => 0, error => "Missing or badly formatted request!" } ) );
$r->status('400');
return;
return 0;
}
}
# non-required parameters may be undef without it being an error
# but we shouldn't try to validate them if they're undef.
#return 1 unless ( defined $p && defined($config->{content}->{$content_type}{validator}));
# run the schema validator
my @errors = $config->{content}->{$content_type}{validator}->validate($p);
my @errors = $config->{content}{$content_type}{validator}->validate($p);
if (@errors) {
my $err_str = join( ', ', map { $_->{message} } @errors );
$r->print(
to_json( { success => 0, error => "Bad format for request body. Errors: $err_str" } ) );
$r->status('400');
return;
return 0;
}
$arg_obj->{body} = $p;
return 1;
}
# Usage: schema ($object_ref)
@ -354,4 +373,77 @@ sub TO_JSON {
return $json;
}
sub params {
my $self = $_[0];
my $parameters = [ values %{ $self->{path}{params} } ];
return $parameters;
}
sub methods {
my $self = $_[0];
my $methods = $self->{path}{methods};
return $methods;
}
sub to_template {
my $self = $_[0];
my $parameters = [ values %{ $self->{path}{params} } ];
my $methods = $self->{path}{methods};
my $vars = {
params => $parameters,
methods => $methods
};
return DW::Template->render_template( 'api/path.tt', $vars, { no_sitescheme => 1 } );
}
DW::Routing->register_string( '/api', \&api_handler, app => 1 );
DW::Routing->register_string( '/api/', \&api_handler, app => 1 );
sub api_handler {
my ( $ok, $rv ) = controller();
return $rv unless $ok;
my $r = $rv->{r};
my $u = $rv->{u};
my $remote = $rv->{remote};
my %api = %API_DOCS;
my $paths = $api{1};
my $vars;
$vars->{paths} = $paths;
$vars->{key} = DW::API::Key->get_one($remote);
return DW::Template->render_template( 'api.tt', $vars );
}
DW::Routing->register_string( '/api/getkey', \&key_handler, app => 1 );
sub key_handler {
my ( $ok, $rv ) = controller();
return $rv unless $ok;
my $r = $rv->{r};
my $remote = $rv->{remote};
my $key = DW::API::Key->get_one($remote);
$r->status(200);
$r->content_type('text/plain; charset=utf-8');
$r->print( $key->{keyhash} );
return $r->OK;
}
DW::Routing->register_string( '/internal/api/404', \&api_404_handler, app => 1 );
sub api_404_handler {
my ( $ok, $rv ) = controller( anonymous => 1 );
return $rv unless $ok;
my $r = $rv->{r};
$r->status(404);
$r->content_type('application/json; charset=utf-8');
$r->print( to_json( { success => 0, error => "Not found." } ) );
return $r->OK;
}
1;

View File

@ -27,6 +27,7 @@ sub rest_get {
my ( $self, $args ) = @_;
my $u = LJ::load_user( $args->{path}{username} );
return $self->rest_error("404") unless defined $u;
# if we're given a picid, try to load that userpic
if ( defined( $args->{path}{picid} ) && $args->{path}{picid} ne "" ) {

View File

@ -42,22 +42,25 @@ sub _spec_20 {
my $security_defs =
{ "api_key" =>
{ "type" => "http", "scheme" => "Bearer", "bearerFormat" => "Bearer <api_key>" } };
{ "type" => "http", "scheme" => "bearer", "bearerFormat" => "Bearer <api_key>" } };
my @security = map {
{ $_ => [] }
} keys(%$security_defs);
my %spec = (
openapi => '3.0.0',
servers => (
servers => [
{
url => "$LJ::WEB_DOMAIN/api/v$ver"
},
),
],
info => {
title => "$LJ::SITENAME API",
description => "An OpenAPI-compatible API for $LJ::SITENAME",
version => $ver,
},
security => keys(%$security_defs),
security => \@security,
components => {
securitySchemes => $security_defs,
}

View File

@ -136,6 +136,8 @@ sub get_call_opts {
# APIs are versioned, so we only want to check for endpoints that match
# the version the user is requesting.
if ( $call_opts->role eq 'api' ) {
# return early if we weren't given an API version
return unless defined( $call_opts->apiver );
# check the static endpoints for this api version first
if ( exists $api_endpoints{ $call_opts->apiver } ) {

View File

@ -204,8 +204,8 @@ sub decode {
sub decode_unknown_type {
my ( $class, $what ) = @_;
# booleans get converted to undef for false and 1 for true
return $what ? 1 : undef if JSON::is_bool($what);
# booleans get converted to 0 for false and 1 for true
return $what ? 1 : 0 if JSON::is_bool($what);
# otherwise, stringify
return "$what";

3789
htdocs/js/vendor/rapidoc-min.js vendored Normal file

File diff suppressed because one or more lines are too long

39
views/api.tt Normal file
View File

@ -0,0 +1,39 @@
[%- sections.title = "API" -%]
[%- CALL dw.active_resource_group( "foundation" ) -%]
[%- dw.need_res( { group => "foundation" },
"js/vendor/rapidoc-min.js"
"js/components/jquery.collapse.js"
"stc/css/components/collapse.css"
"stc/api.css"
"stc/css/components/foundation-icons.css"
) -%]
<div class="alert-box secondary">This API, and it's documentation, are not yet fully finalized. We will do our best not to rename/remove routes listed here, but more options may be added, and there may be errors with existing routes. If you find an error or missing functionality, please report it at <a href="https://dw-beta.dreamwidth.org/15368.html">this entry</a>!</div>
<p>This is documentation for the Dreamwidth REST API. An API is a way of providing information that is easy for programs to access and use, but isn't always particularly friendly for humans. This document attempts to show what information can be requested, and how, in a slightly more human-readable format, and provide an interface for users to test various requests without having to deal with external programs or a commandline. A machine-readable version of this spec is available at <a href="[% site.root %]/api/v1/spec">/api/v1/spec</a>.</p>
<p>
For this API, users are identified with an API key rather than a username and password. The API key doesn't have access to all the same functions a logged in user to the site has, but it does have access to all the private information you have access to, and the ability to make posts as you, so protect it as you would your password. If you've accidentally shared it, you can delete a key from the management page. The API key that will be used for calls on this page is <b><span id="api_key">[% key.keyhash %]</span></b>. If you'd like to see all your API keys and manage them, go to <a href="[% site.root %]/manage/emailpost">the mobile post settings page</a>.</p>
<rapi-doc
spec-url="[% site.root %]/api/v1/spec"
layout="column"
render-style="view"
show-header='false'
show-info='false'
allow-server-selection = 'false'
allow-api-list-style-selection ='false'
allow-authentication="true"
id="apiDoc"
> </rapi-doc>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
const rapidocEl = document.getElementById('apiDoc');
rapidocEl.addEventListener('spec-loaded', (e) => {
const keyInputEl = document.getElementById('api_key');
rapidocEl.setApiKey('api_key',keyInputEl.innerText);
});
});
</script>