Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API documentation generator command (Postman & Markdown-mdx) #17

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions dependabot.yml → .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
version: 2
updates:

- package-ecosystem: "github-actions"
directory: "/"
schedule:
# Check for updates to GitHub Actions every week
interval: "weekly"
interval: "weekly"
12 changes: 4 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,15 @@ jobs:

- name: Get release info
id: query-release-info
uses: release-flow/keep-a-changelog-action@v2
uses: release-flow/keep-a-changelog-action@v3
with:
command: query
version: latest

- name: Publish to Github releases
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.query-release-info.outputs.release-notes }}
# TODO: Check PR https://github.com/softprops/action-gh-release/pull/304
# make_latest: ${{ $GITHUB_REF_NAME == 'main' && true || false }}
# TODO: Workaround for the above (semi-automatic workflow when non main releases):
# FIXME: See https://github.com/open-southeners/laravel-apiable/actions/runs/4016588356
# draft: ${{ $GITHUB_REF_NAME != 'main' && true || false }}
make_latest: ${{ $GITHUB_REF_NAME == 'main' && true || false }}
# prerelease: true
# files: '*.vsix'
# files: '*.vsix'
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer"
}
"php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer",
"php.version": "8.3.1"
}
26 changes: 19 additions & 7 deletions config/apiable.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,14 @@

return [

/**
* Resource type model map.
*
* @see https://docs.opensoutheners.com/laravel-apiable/guide/#getting-started
*/
'resource_type_map' => [],

/**
* Default options for request query filters, sorts, etc.
*
* @see https://docs.opensoutheners.com/laravel-apiable/guide/requests.html
*/
'requests' => [
'validate' => ! ((bool) env('APIABLE_DEV_MODE', false)),

'validate_params' => false,

'filters' => [
Expand Down Expand Up @@ -54,4 +49,21 @@
'include_ids_on_attributes' => false,
],

/**
* Default options for responses like: normalize relations names, include allowed filters and sorts, etc.
*
* @see https://docs.opensoutheners.com/laravel-apiable/guide/documentation.html
*/
'documentation' => [

'markdown' => [
'base_path' => 'storage/exports/markdown',
],

'postman' => [
'base_path' => 'storage/exports',
],

],

];
12 changes: 11 additions & 1 deletion src/Attributes/AppendsQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@
namespace OpenSoutheners\LaravelApiable\Attributes;

use Attribute;
use OpenSoutheners\LaravelApiable\ServiceProvider;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class AppendsQueryParam extends QueryParam
{
public function __construct(public string $type, public array $attributes)
public function __construct(public string $type, public array $attributes, public string $description = '')
{
//
}

public function getTypeAsResource(): string
{
if (! str_contains($this->type, '\\')) {
return $this->type;
}

return ServiceProvider::getTypeForModel($this->type);
}
}
12 changes: 11 additions & 1 deletion src/Attributes/FieldsQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@
namespace OpenSoutheners\LaravelApiable\Attributes;

use Attribute;
use OpenSoutheners\LaravelApiable\ServiceProvider;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class FieldsQueryParam extends QueryParam
{
public function __construct(public string $type, public array $fields)
public function __construct(public string $type, public array $fields, public string $description = '')
{
//
}

public function getTypeAsResource(): string
{
if (! str_contains($this->type, '\\')) {
return $this->type;
}

return ServiceProvider::getTypeForModel($this->type);
}
}
63 changes: 61 additions & 2 deletions src/Attributes/FilterQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,71 @@
namespace OpenSoutheners\LaravelApiable\Attributes;

use Attribute;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use OpenSoutheners\LaravelApiable\Http\QueryParamValueType;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class FilterQueryParam extends QueryParam
{
public function __construct(public string $attribute, public int|array|null $type = null, public $values = '*')
{
public function __construct(
public string $attribute,
public int|array|null $type = null,
public string|array|QueryParamValueType $values = '*',
public string $description = ''
) {
//
}

public function getDataType(): QueryParamValueType|array
{
if ($this->values instanceof QueryParamValueType) {
return $this->values;
}

if (is_array($this->values)) {
return array_unique(
array_map(
fn ($value) => $this->assertDataType($value),
$this->values
)
);
}

return $this->assertDataType($this->values);
}

protected function assertDataType(mixed $value): QueryParamValueType
{
if (is_numeric($value)) {
return QueryParamValueType::Integer;
}

if ($this->isTimestamp($value)) {
return QueryParamValueType::Timestamp;
}

if (in_array($value, ['true', 'false'])) {
return QueryParamValueType::Boolean;
}

if (Str::isJson($value)) {
return QueryParamValueType::Object;
}

// TODO: Array like "param[0]=foo&param[1]=bar"...

return QueryParamValueType::String;
}

protected function isTimestamp(mixed $value): bool
{
try {
Carbon::parse($value);

return true;
} catch (\Exception $e) {
return false;
}
}
}
14 changes: 14 additions & 0 deletions src/Attributes/ForceAppendAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace OpenSoutheners\LaravelApiable\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class ForceAppendAttribute
{
public function __construct(public string|array $type, public string|array $attributes)
{
//
}
}
2 changes: 1 addition & 1 deletion src/Attributes/IncludeQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class IncludeQueryParam extends QueryParam
{
public function __construct(public string|array $relationships)
public function __construct(public string|array $relationships, public string $description = '')
{
//
}
Expand Down
2 changes: 1 addition & 1 deletion src/Attributes/QueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace OpenSoutheners\LaravelApiable\Attributes;

class QueryParam
abstract class QueryParam
{
//
}
3 changes: 2 additions & 1 deletion src/Attributes/SearchFilterQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
namespace OpenSoutheners\LaravelApiable\Attributes;

use Attribute;
use OpenSoutheners\LaravelApiable\Http\QueryParamValueType;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class SearchFilterQueryParam extends QueryParam
{
public function __construct(public string $attribute, public $values = '*')
public function __construct(public string $attribute, public string|array|QueryParamValueType $values = '*', public string $description = '')
{
//
}
Expand Down
2 changes: 1 addition & 1 deletion src/Attributes/SearchQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class SearchQueryParam extends QueryParam
{
public function __construct(public bool $allowSearch = true)
public function __construct(public bool $allowSearch = true, public string $description = '')
{
//
}
Expand Down
2 changes: 1 addition & 1 deletion src/Attributes/SortQueryParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class SortQueryParam extends QueryParam
{
public function __construct(public string $attribute, public ?int $direction = AllowedSort::BOTH)
public function __construct(public string $attribute, public ?int $direction = AllowedSort::BOTH, public string $description = '')
{
//
}
Expand Down
Empty file added src/Config.php
Empty file.
123 changes: 123 additions & 0 deletions src/Console/ApiableDocgenCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace OpenSoutheners\LaravelApiable\Console;

use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Routing\Route;
use Illuminate\Routing\RouteCollection;
use Illuminate\Routing\Router;
use Illuminate\Support\Str;
use OpenSoutheners\LaravelApiable\Documentation\Generator;

class ApiableDocgenCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'apiable:docgen
{--exclude= : Exclude routes containing the following list on their URI paths (comma separated)}
{--only= : Generate documentation only for routes containing the following on their URI paths}
{--markdown : Generate documentation in raw and reusable Markdown}
{--postman : Generate documentation as Postman collection}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate API documentation based on JSON:API endpoints.';

/**
* Create a new console command instance.
*
* @return void
*/
public function __construct(
protected Router $router,
protected Generator $generator,
protected Filesystem $filesystem,
protected array $files = [],
protected array $resources = [],
protected array $endpoints = []
) {
parent::__construct();
}

/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$exportFormat = $this->askForExportFormat();

$this->generator->generate();

// TODO: Auth event with Sanctum or Passport?
match ($exportFormat) {
'postman' => $this->exportEndpointsToPostman(),
'markdown' => $this->exportEndpointsToMarkdown(),
default => null
};

foreach ($this->files as $path => $content) {
$this->filesystem->ensureDirectoryExists(Str::beforeLast($path, '/'));

$this->filesystem->put($path, $content);
}

$this->info("Export successfully to {$exportFormat}");

return 0;
}

public function exportEndpointsToMarkdown()
{
$this->files = array_merge($this->files, $this->generator->toMarkdown());
}

// TODO: Update with new array data structure from fetchRoutes
protected function exportEndpointsToPostman(): void
{
$this->files[config('apiable.documentation.postman.base_path').'/documentation.postman_collection.json'] = $this->generator->toPostmanCollection();
}

protected function filterRoutesToDocument(RouteCollection $routes)
{
$filterOnlyBy = $this->option('only');

return array_filter(iterator_to_array($routes), function (Route $route) use ($filterOnlyBy) {
$hasBeenExcluded = Str::is(array_merge(explode(',', $this->option('exclude')), [
'_debugbar/*', '_ignition/*', 'nova-api/*', 'nova/*', 'nova',
]), $route->uri());

if ($hasBeenExcluded) {
return false;
}

if ($filterOnlyBy) {
return Str::is($filterOnlyBy, $route->uri());
}

return true;
});
}

protected function askForExportFormat()
{
$postman = $this->option('postman');
$markdown = $this->option('markdown');

$formatOptions = compact('postman', 'markdown');

if (empty($formatOptions)) {

Check failure on line 117 in src/Console/ApiableDocgenCommand.php

View workflow job for this annotation

GitHub Actions / PHPStan

Variable $formatOptions in empty() always exists and is not falsy.
$option = $this->askWithCompletion('Export API documentation using format', array_keys($formatOptions));
}

return head(array_keys(array_filter($formatOptions)));
}
}
Loading
Loading