feat(routes): support multi-applications in config [breaking] (#34)

* chore: remove leftover console log

* chore: don't show a ring over dropdowns

* feat(routes): support multi-applications in config

* test: update snapshot
This commit is contained in:
Mazen Touati
2026-01-12 21:56:38 +01:00
committed by GitHub
parent 78d91a39c1
commit a090c484f8
35 changed files with 922 additions and 276 deletions

View File

@@ -15,6 +15,18 @@ return [
'prefix' => 'nimbus',
/*
|--------------------------------------------------------------------------
| Default Application
|--------------------------------------------------------------------------
|
| This value defines the default application that Nimbus will load when
| no application is specified in the request.
|
*/
'default_application' => 'main',
/*
|--------------------------------------------------------------------------
| Allowed Environments
@@ -31,144 +43,160 @@ return [
/*
|--------------------------------------------------------------------------
| Route Configuration
| Application Configurations
|--------------------------------------------------------------------------
|
| These options control how Nimbus identifies and registers application
| routes. The route configuration determines which endpoints will be
| analyzed, displayed, or interacted with by Nimbus.
*/
'routes' => [
/*
|--------------------------------------------------------------------------
| Route Prefix
|--------------------------------------------------------------------------
|
| The prefix used to discover and filter the routes for inclusion in Nimbus.
| Only routes whose URIs begin with this prefix will be loaded in the UI.
| Adjust this value if your API endpoints use a different root segment.
|
*/
'prefix' => 'api',
/*
|--------------------------------------------------------------------------
| Versioned Routes
|--------------------------------------------------------------------------
|
| Determines whether the routes identified by the prefix should be
| treated as versioned (for example: /api/v1/users). If enabled, Nimbus
| automatically detects version segments and handles them separately
| in the schema representation. Disable this if your routes are flat
| or non-versioned.
|
*/
'versioned' => false,
/*
|--------------------------------------------------------------------------
| API Base URL
|--------------------------------------------------------------------------
|
| This value defines the base URL that Nimbus will use when relaying
| API requests from the UI. It is useful in cases where your API is
| hosted on a different domain, port, or subpath than the UI itself.
|
| If left null, Nimbus will automatically use the same host and scheme
| as the incoming request that triggered the relay. This is the
| recommended default for most deployments where the API and UI share
| the same origin.
|
*/
'api_base_url' => null,
],
/*
|--------------------------------------------------------------------------
| Authentication Configuration
|--------------------------------------------------------------------------
|
| Defines how Nimbus authenticates API requests when interacting with your
| application routes. The authentication configuration determines which
| Laravel guard is used and how special authentication modes—such as
| “login as current user” or “impersonate user” are handled.
| Defines the different applications that Nimbus supports. Each application
| can have its own route, authentication, and headers configuration.
|
*/
'auth' => [
/*
|--------------------------------------------------------------------------
| Authentication Guard
|--------------------------------------------------------------------------
|
| The name of the Laravel authentication guard used for the api endpoints.
| This guard must be the guard used to authenticate the requests to
| the API endpoints from the prefix above (nimbus.routes.prefix).
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Special Authentication
|--------------------------------------------------------------------------
|
| These settings control Nimbus's advanced authentication modes,
| such as impersonation or logging in as the current user. Each mode
| modifies outgoing HTTP requests by injecting appropriate credentials
| or tokens before they are sent.
|
*/
'special' => [
'applications' => [
'main' => [
'name' => 'Main',
/*
|--------------------------------------------------------------------------
| Authentication Injector
| Route Configuration
|--------------------------------------------------------------------------
|
| Defines the injector class used to modify outgoing requests with
| authentication credentials. The class must implement the
| `SpecialAuthenticationInjectorContract` interface.
|
| Included implementations:
| - RememberMeCookieInjector::class:
| Forwards or generates a Laravel "remember me" cookie.
| - TymonJwtTokenInjector::class:
| Injects a Bearer token using the `tymon/jwt-aut` package.
|
| P.S. You may provide a custom implementation to support alternative authentication mechanisms.
| These options control how Nimbus identifies and registers application
| routes. The route configuration determines which endpoints will be
| analyzed, displayed, or interacted with by Nimbus.
*/
'injector' => \Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors\RememberMeCookieInjector::class,
'routes' => [
/*
|--------------------------------------------------------------------------
| Route Prefix
|--------------------------------------------------------------------------
|
| The prefix used to discover and filter the routes for inclusion in Nimbus.
| Only routes whose URIs begin with this prefix will be loaded in the UI.
| Adjust this value if your API endpoints use a different root segment.
|
*/
'prefix' => 'api',
/*
|--------------------------------------------------------------------------
| Versioned Routes
|--------------------------------------------------------------------------
|
| Determines whether the routes identified by the prefix should be
| treated as versioned (for example: /api/v1/users). If enabled, Nimbus
| automatically detects version segments and handles them separately
| in the schema representation. Disable this if your routes are flat
| or non-versioned.
|
*/
'versioned' => false,
/*
|--------------------------------------------------------------------------
| API Base URL
|--------------------------------------------------------------------------
|
| This value defines the base URL that Nimbus will use when relaying
| API requests from the UI. It is useful in cases where your API is
| hosted on a different domain, port, or subpath than the UI itself.
|
| If left null, Nimbus will automatically use the same host and scheme
| as the incoming request that triggered the relay. This is the
| recommended default for most deployments where the API and UI share
| the same origin.
|
*/
'api_base_url' => null,
],
/*
|--------------------------------------------------------------------------
| Authentication Configuration
|--------------------------------------------------------------------------
|
| Defines how Nimbus authenticates API requests when interacting with your
| application routes. The authentication configuration determines which
| Laravel guard is used and how special authentication modes—such as
| “login as current user” or “impersonate user” are handled.
|
*/
'auth' => [
/*
|--------------------------------------------------------------------------
| Authentication Guard
|--------------------------------------------------------------------------
|
| The name of the Laravel authentication guard used for the api endpoints.
| This guard must be the guard used to authenticate the requests to
| the API endpoints from the prefix above (nimbus.routes.prefix).
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Special Authentication
|--------------------------------------------------------------------------
|
| These settings control Nimbus's advanced authentication modes,
| such as impersonation or logging in as the current user. Each mode
| modifies outgoing HTTP requests by injecting appropriate credentials
| or tokens before they are sent.
|
*/
'special' => [
/*
|--------------------------------------------------------------------------
| Authentication Injector
|--------------------------------------------------------------------------
|
| Defines the injector class used to modify outgoing requests with
| authentication credentials. The class must implement the
| `SpecialAuthenticationInjectorContract` interface.
|
| Included implementations:
| - RememberMeCookieInjector::class:
| Forwards or generates a Laravel "remember me" cookie.
| - TymonJwtTokenInjector::class:
| Injects a Bearer token using the `tymon/jwt-aut` package.
|
| P.S. You may provide a custom implementation to support alternative authentication mechanisms.
*/
'injector' => \Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors\RememberMeCookieInjector::class,
],
],
/*
|--------------------------------------------------------------------------
| Global Headers
|--------------------------------------------------------------------------
|
| Define any global headers that should be applied to every Nimbus request.
| Each header may be defined as either:
| - A value from GlobalHeaderGeneratorTypeEnum::class, or
| - A raw primitive value (string, integer, or boolean).
|
| Example:
| 'headers' => [
| 'X-Request-ID' => GlobalHeaderGeneratorTypeEnum::UUID,
| 'X-App-Version' => '1.0.0',
| ],
|
*/
'headers' => [
/** @see \Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum */
],
],
],
/*
|--------------------------------------------------------------------------
| Global Headers
|--------------------------------------------------------------------------
|
| Define any global headers that should be applied to every Nimbus request.
| Each header may be defined as either:
| - A value from GlobalHeaderGeneratorTypeEnum::class, or
| - A raw primitive value (string, integer, or boolean).
|
| Example:
| 'headers' => [
| 'X-Request-ID' => GlobalHeaderGeneratorTypeEnum::UUID,
| 'X-App-Version' => '1.0.0',
| ],
|
*/
'headers' => [
/** @see \Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum */
],
];

View File

@@ -25,7 +25,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'flex h-9 items-center justify-between rounded-md border border-zinc-200 bg-transparent px-3 py-2 text-start text-sm whitespace-nowrap shadow-sm ring-offset-white focus-visible:ring-1 focus-visible:ring-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-zinc-500 dark:border-zinc-800 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300 dark:data-[placeholder]:text-zinc-400 [&>span]:truncate',
'flex h-9 items-center justify-between rounded-md border border-zinc-200 bg-transparent px-3 py-2 text-start text-sm whitespace-nowrap shadow-sm ring-offset-white focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-zinc-500 dark:border-zinc-800 dark:data-[placeholder]:text-zinc-400 [&>span]:truncate',
props.class,
)
"

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import {
AppSelect,
AppSelectContent,
AppSelectGroup,
AppSelectItem,
AppSelectLabel,
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import { useConfigStore } from '@/stores';
import { LayersIcon } from 'lucide-vue-next';
import { AcceptableValue } from 'reka-ui';
const configStore = useConfigStore();
const handleApplicationChange = (applicationKey: AcceptableValue) => {
if (applicationKey === null) {
return;
}
// We reload the page with the new application query parameter to trigger the backend switch.
const url = new URL(window.location.href);
url.searchParams.set('application', String(applicationKey));
window.location.href = url.toString();
};
</script>
<template>
<div class="w-full">
<AppSelect
:model-value="configStore.activeApplication || ''"
@update:model-value="handleApplicationChange"
>
<AppSelectTrigger
class="px-panel w-full border-none text-xs shadow-none focus:ring-0 active:ring-0"
>
<div class="flex items-center gap-2 overflow-hidden">
<LayersIcon class="text-muted-foreground size-3.5 shrink-0" />
<AppSelectValue
placeholder="Select an application"
class="truncate"
/>
</div>
</AppSelectTrigger>
<AppSelectContent>
<AppSelectGroup>
<AppSelectLabel>Available Applications</AppSelectLabel>
<AppSelectItem
v-for="(name, key) in configStore.applications"
:key="key"
:value="key"
class="text-xs"
>
{{ name }}
</AppSelectItem>
</AppSelectGroup>
</AppSelectContent>
</AppSelect>
</div>
</template>

View File

@@ -9,6 +9,7 @@ import {
AppSidebarMenu,
AppSidebarRail,
} from '@/components/base/sidebar';
import ApplicationSwitcher from '@/components/domain/RoutesExplorer/ApplicationSwitcher.vue';
import RouteExplorerHeader from '@/components/domain/RoutesExplorer/RouteExplorerHeader.vue';
import RouteExplorerVersionSelector from '@/components/domain/RoutesExplorer/RouteExplorerVersionSelector.vue';
import RoutesList from '@/components/domain/RoutesExplorer/RoutesList/RoutesList.vue';
@@ -16,7 +17,7 @@ import { RouteDefinition, RoutesGroup } from '@/interfaces/routes/routes';
import { useConfigStore } from '@/stores';
import { uniquePersistenceKey } from '@/utils/stores';
import { useStorage } from '@vueuse/core';
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
/*
* Props.
@@ -32,9 +33,22 @@ const props = defineProps<{
const search = useStorage(uniquePersistenceKey('routes-explorer-search-keyword'), '');
const versions = computed(() => Object.keys(props.routes || []));
const versions = computed(() => Object.keys(props.routes || {}));
const currentVersion = computed(() => versions.value[0]);
const currentVersion = ref('');
// Initialize or update current version when versions list changes (e.g. after project switch)
watch(
versions,
newVersions => {
if (newVersions.length > 0 && !newVersions.includes(currentVersion.value)) {
currentVersion.value = newVersions[0];
} else if (newVersions.length === 0) {
currentVersion.value = '';
}
},
{ immediate: true },
);
/*
* Computed.
@@ -77,6 +91,10 @@ const filteredRoutes = computed(() => {
*/
const configStore = useConfigStore();
const hasMultipleApplications = computed(
() => Object.keys(configStore.applications).length > 1,
);
</script>
<template>
@@ -89,11 +107,21 @@ const configStore = useConfigStore();
:disabled="routesInVersion.length === 0"
class="h-[calc(var(--toolbar-height)+1px)] w-full rounded-none border-0 border-b text-xs shadow-none focus:ring-0 focus-visible:ring-0"
/>
<RouteExplorerVersionSelector
v-if="configStore.isVersioned && versions.length"
v-model="currentVersion"
:versions="versions"
/>
<div class="h-sub-toolbar flex items-center overflow-hidden border-b">
<ApplicationSwitcher v-if="hasMultipleApplications" class="flex-1" />
<div
v-if="configStore.isVersioned && versions.length"
class="h-full w-[80px] shrink-0 border-l"
:class="{
'flex-1': !hasMultipleApplications,
}"
>
<RouteExplorerVersionSelector
v-model="currentVersion"
:versions="versions"
/>
</div>
</div>
</div>
<AppSidebarContent>
<AppSidebarGroup class="p-0">

View File

@@ -15,14 +15,19 @@ const model = defineModel<string>();
</script>
<template>
<AppSelect v-model="model" class="flex items-center text-sm">
<AppSelect v-model="model" class="flex items-center">
<AppSelectTrigger
class="h-sub-toolbar w-full rounded-none border-0 border-b shadow-none focus:ring-0 focus-visible:outline-none"
class="px-panel w-full rounded-none border-0 text-xs shadow-none focus:ring-0 focus-visible:outline-none active:ring-0"
>
<AppSelectValue placeholder="Select API Version" />
</AppSelectTrigger>
<AppSelectContent>
<AppSelectItem v-for="value in versions" :key="value" :value="value">
<AppSelectItem
v-for="value in versions"
:key="value"
:value="value"
class="text-xs"
>
{{ value }}
</AppSelectItem>
</AppSelectContent>

View File

@@ -21,6 +21,10 @@ export const useConfigStore = defineStore('config', () => {
const currentUser = window.Nimbus?.currentUser
? JSON.parse(window.Nimbus.currentUser as string)
: null;
const applications: Record<string, string> = window.Nimbus?.applications
? JSON.parse(window.Nimbus.applications as string)
: {};
const activeApplication = window.Nimbus?.activeApplication || null;
// Derived values
const isLoggedIn = currentUser !== null;
@@ -33,5 +37,7 @@ export const useConfigStore = defineStore('config', () => {
isVersioned,
isLoggedIn,
userId,
applications,
activeApplication,
};
});

View File

@@ -672,8 +672,6 @@ describe('SingleDumpRenderer', () => {
// Should show [] for empty array
const content = screen.getAllByTestId('collapsible-trigger')[1];
console.log(content);
expect(content.textContent).toContain('[]');
});
});

View File

@@ -47,6 +47,9 @@ const mockRoutesStore: {
const mockConfigStore = {
apiUrl: 'https://api.example.com',
headers: [],
applications: {},
isVersioned: false,
activeApplication: null,
};
const mockValueGeneratorStore = {

View File

@@ -29,6 +29,8 @@ describe('useConfigStore', () => {
currentUser: JSON.stringify({ id: 99 }),
routes: '',
routeExtractorException: null,
applications: JSON.stringify({ main: 'Main API' }),
activeApplication: 'main',
};
const store = useConfigStore();

View File

@@ -8,6 +8,8 @@ interface NimbusConfig {
isVersioned: boolean;
routeExtractorException: string | null;
currentUser: string | null;
applications: string | null;
activeApplication: string | null;
}
declare global {

View File

@@ -4,20 +4,20 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="{{ asset('/vendor/nimbus/favicon/favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="{{ asset('/vendor/nimbus/favicon/favicon.svg') }}" />
<link rel="shortcut icon" href="{{ asset('/vendor/nimbus/favicon/favicon.ico') }}" />
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('/vendor/nimbus/favicon/apple-touch-icon.png') }}" />
<meta name="apple-mobile-web-app-title" content="Nimbus" />
<link rel="icon" type="image/png" href="{{ asset('/vendor/nimbus/favicon/favicon-96x96.png') }}" sizes="96x96"/>
<link rel="icon" type="image/svg+xml" href="{{ asset('/vendor/nimbus/favicon/favicon.svg') }}"/>
<link rel="shortcut icon" href="{{ asset('/vendor/nimbus/favicon/favicon.ico') }}"/>
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('/vendor/nimbus/favicon/apple-touch-icon.png') }}"/>
<meta name="apple-mobile-web-app-title" content="Nimbus"/>
<title>{{ config('app.name', 'Laravel') }} - Nimbus</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet"/>
<script>
(function() {
(function () {
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
@@ -30,21 +30,24 @@
})();
</script>
@php
$config = \Illuminate\Support\Js::from([
'basePath' => rtrim(\Illuminate\Support\Str::start(config('nimbus.prefix'), '/'), '/'),
'routes' => isset($routes) ? json_encode($routes) : null,
'headers' => isset($headers) ? json_encode($headers) : null,
'routeExtractorException' => isset($routeExtractorException) ? json_encode($routeExtractorException) : null,
'isVersioned' => config('nimbus.routes.versioned'),
'apiBaseUrl' => config('nimbus.routes.api_base_url', request()->getSchemeAndHttpHost()),
'currentUser' => isset($currentUser) ? json_encode($currentUser) : null,
]);
/** @var \Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver $activeApplicationResolver */
$config = \Illuminate\Support\Js::from([
'basePath' => rtrim(\Illuminate\Support\Str::start(config('nimbus.prefix'), '/'), '/'),
'routes' => isset($routes) ? json_encode($routes) : null,
'headers' => isset($headers) ? json_encode($headers) : null,
'routeExtractorException' => isset($routeExtractorException) ? json_encode($routeExtractorException) : null,
'isVersioned' => $activeApplicationResolver->isVersioned(),
'apiBaseUrl' => $activeApplicationResolver->getApiBaseUrl(),
'currentUser' => isset($currentUser) ? json_encode($currentUser) : null,
'applications' => $activeApplicationResolver->getAvailableApplications(),
'activeApplication' => $activeApplicationResolver->getActiveApplicationKey(),
]);
$configTag = new \Illuminate\Support\HtmlString(<<<HTML
<script type="module">
window.Nimbus = {$config};
</script>
HTML);
$configTag = new \Illuminate\Support\HtmlString(<<<HTML
<script type="module">
window.Nimbus = {$config};
</script>
HTML);
@endphp
{{ $configTag }}
@@ -52,6 +55,6 @@
@vite(['resources/css/app.css', 'resources/js/app/app.ts'])
</head>
<body class="font-sans antialiased">
<div id="app"></div>
<div id="app"></div>
</body>
</html>

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Routes\Actions;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
@@ -20,9 +21,16 @@ class NimbusIndexController
Actions\BuildGlobalHeadersAction $buildGlobalHeadersAction,
Actions\BuildCurrentUserAction $buildCurrentUserAction,
Actions\DisableThirdPartyUiAction $disableThirdPartyUiAction,
ActiveApplicationResolver $activeApplicationResolver,
): Renderable|RedirectResponse {
$disableThirdPartyUiAction->execute();
if (request()->has('application')) {
return redirect()
->to(request()->fullUrlWithQuery(['application' => null]))
->withCookie(cookie()->forever(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME, request()->get('application')));
}
Vite::useBuildDirectory('/vendor/nimbus');
Vite::useHotFile(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'));
@@ -42,6 +50,7 @@ class NimbusIndexController
} catch (RouteExtractionException $routeExtractionException) {
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
'routeExtractorException' => $this->renderExtractorException($routeExtractionException),
'activeApplicationResolver' => $activeApplicationResolver,
]);
}
@@ -49,6 +58,7 @@ class NimbusIndexController
'routes' => $routes->toFrontendArray(),
'headers' => $buildGlobalHeadersAction->execute(),
'currentUser' => $buildCurrentUserAction->execute(),
'activeApplicationResolver' => $activeApplicationResolver,
]);
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Sunchayn\Nimbus\Modules\Config;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
class ActiveApplicationResolver
{
public const CURRENT_APPLICATION_COOKIE_NAME = 'nimbus:application';
/** @var array<string, mixed> */
protected readonly array $activeApplicationConfig;
protected readonly string $activeApplicationKey;
/**
* @throws MisconfiguredValueException
*/
public function __construct(
protected Repository $config,
protected Request $request
) {
$this->activeApplicationKey = $this->determineActiveApplicationKey();
$this->activeApplicationConfig = $this->getActiveApplicationConfig();
}
public function getActiveApplicationKey(): string
{
return $this->activeApplicationKey;
}
public function isVersioned(): bool
{
return $this->activeApplicationConfig['routes']['versioned'] ?? false;
}
public function getApiBaseUrl(): string
{
return $this->activeApplicationConfig['routes']['api_base_url'] ?? $this->request->getSchemeAndHttpHost();
}
public function getRoutesPrefix(): string
{
return $this->activeApplicationConfig['routes']['prefix'] ?? 'api';
}
public function getAuthGuard(): string
{
return $this->activeApplicationConfig['auth']['guard'] ?? 'web';
}
/** @return ?class-string<SpecialAuthenticationInjectorContract> */
public function getSpecialAuthInjector(): ?string
{
return $this->activeApplicationConfig['auth']['special']['injector'] ?? null;
}
/**
* @return array<string, mixed>
*/
public function getHeaders(): array
{
return $this->activeApplicationConfig['headers'] ?? [];
}
public function getAvailableApplications(): string
{
$applications = $this->config->get('nimbus.applications', []);
return (string) json_encode(
Arr::mapWithKeys(
$applications,
fn (array $config, string $key): array => [
$key => $config['name'] ?? $key,
],
),
);
}
/**
* @throws MisconfiguredValueException
*/
protected function determineActiveApplicationKey(): string
{
$applications = $this->config->get('nimbus.applications', []);
if ($applications === []) {
throw MisconfiguredValueException::becauseApplicationsAreNotDefined();
}
$applicationKey = $this->request->cookie(self::CURRENT_APPLICATION_COOKIE_NAME);
if (is_string($applicationKey) && array_key_exists($applicationKey, $applications)) {
return $applicationKey;
}
$applicationKey = $this->config->get('nimbus.default_application');
if (! array_key_exists($applicationKey, $applications)) {
throw MisconfiguredValueException::becauseDefaultApplicationIsInvalid($applicationKey);
}
return (string) $applicationKey;
}
/**
* @return array<string, mixed>
*/
protected function getActiveApplicationConfig(): array
{
return $this->config->get('nimbus.applications.'.$this->activeApplicationKey, []);
}
}

View File

@@ -13,6 +13,10 @@ class MisconfiguredValueException extends Exception
public const INVALID_GUARD_INJECTOR_COMBINATION = 3;
public const INVALID_DEFAULT_APPLICATION = 4;
public const INVALID_APPLICATIONS = 5;
public static function becauseSpecialAuthenticationInjectorIsInvalid(): self
{
return new self(
@@ -39,4 +43,20 @@ class MisconfiguredValueException extends Exception
code: self::INVALID_GUARD_INJECTOR_COMBINATION,
);
}
public static function becauseDefaultApplicationIsInvalid(string $key): self
{
return new self(
message: sprintf("The default application `%s` doesn't have a matching configuration.", $key),
code: self::INVALID_DEFAULT_APPLICATION,
);
}
public static function becauseApplicationsAreNotDefined(): self
{
return new self(
message: 'There are no applications defined.',
code: self::INVALID_APPLICATIONS,
);
}
}

View File

@@ -3,8 +3,8 @@
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns;
use Illuminate\Container\Container;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Container\BindingResolutionException;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
@@ -14,10 +14,10 @@ trait UsesSpecialAuthenticationInjector
* @throws BindingResolutionException
* @throws MisconfiguredValueException
*/
public function getInjector(Container $container, Repository $configRepository): SpecialAuthenticationInjectorContract
public function getInjector(Container $container, ActiveApplicationResolver $activeApplicationResolver): SpecialAuthenticationInjectorContract
{
/** @var ?class-string $injectorClass */
$injectorClass = $configRepository->get('nimbus.auth.special.injector');
$injectorClass = $this->projectManager->getSpecialAuthInjector();
if ($injectorClass === null) {
throw MisconfiguredValueException::becauseSpecialAuthenticationInjectorIsInvalid();

View File

@@ -2,15 +2,16 @@
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Illuminate\Session\SessionManager;
use Illuminate\Session\Store;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns\UsesSpecialAuthenticationInjector;
/**
@@ -30,6 +31,7 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
public function __construct(
private readonly Request $relayRequest,
private readonly Container $container,
private readonly ActiveApplicationResolver $projectManager,
private readonly ConfigRepository $configRepository,
) {
$this->userProvider = $this->resolveUserProvider();
@@ -44,7 +46,7 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
}
return $this
->getInjector($this->container, $this->configRepository)
->getInjector($this->container, $this->projectManager)
->attach($pendingRequest, $user);
}
@@ -55,9 +57,7 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
{
$authManager = $this->container->get('auth');
$guardName = $this->configRepository->get('nimbus.auth.guard');
return $authManager->guard($guardName)->getProvider();
return $authManager->guard($this->projectManager->getAuthGuard())->getProvider();
}
/**
@@ -86,7 +86,7 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
$session->setId(id: $this->extractSessionIdFromCookie($sessionCookie));
$session->start();
$tokenPrefix = 'login_'.$this->configRepository->get('nimbus.auth.guard');
$tokenPrefix = 'login_'.($this->projectManager->getAuthGuard());
$userId = Arr::first(
$session->all(),

View File

@@ -2,11 +2,11 @@
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns\UsesSpecialAuthenticationInjector;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
@@ -20,7 +20,7 @@ class ImpersonateUserAuthorizationHandler implements AuthorizationHandler
public function __construct(
public readonly int $userId,
private readonly Container $container,
private readonly ConfigRepository $configRepository,
private readonly ActiveApplicationResolver $projectManager,
) {
if ($userId <= 0) {
throw InvalidAuthorizationValueException::becauseUserIsNotFound();
@@ -28,7 +28,7 @@ class ImpersonateUserAuthorizationHandler implements AuthorizationHandler
$this->userProvider = $this
->container->get('auth')
->guard(name: config('nimbus.auth.guard'))
->guard(name: $this->projectManager->getAuthGuard())
->getProvider();
}
@@ -45,7 +45,7 @@ class ImpersonateUserAuthorizationHandler implements AuthorizationHandler
}
return $this
->getInjector($this->container, $this->configRepository)
->getInjector($this->container, $this->projectManager)
->attach($pendingRequest, $user);
}
}

View File

@@ -2,7 +2,6 @@
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
@@ -15,6 +14,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
@@ -34,12 +34,12 @@ class RememberMeCookieInjector implements SpecialAuthenticationInjectorContract
public function __construct(
private readonly Request $relayRequest,
private readonly Container $container,
ConfigRepository $configRepository,
ActiveApplicationResolver $activeApplicationResolver,
) {
$this->encrypter = $this->container->get('encrypter');
$this->authGuard = $this->container->get('auth')->guard(
$configRepository->get('nimbus.auth.guard'),
$activeApplicationResolver->getAuthGuard(),
);
if (! $this->authGuard instanceof StatefulGuard) {

View File

@@ -2,11 +2,11 @@
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
@@ -18,7 +18,7 @@ class TymonJwtTokenInjector implements SpecialAuthenticationInjectorContract
* @throws MisconfiguredValueException
*/
public function __construct(
private readonly ConfigRepository $configRepository,
private readonly ActiveApplicationResolver $activeApplicationResolver,
private readonly Container $container,
) {
if (! class_exists(\Tymon\JWTAuth\JWTGuard::class)) {
@@ -27,7 +27,7 @@ class TymonJwtTokenInjector implements SpecialAuthenticationInjectorContract
$this->guard = $this
->container->make('auth')
->guard(name: $this->configRepository->get('nimbus.auth.guard'));
->guard(name: $this->activeApplicationResolver->getAuthGuard());
if (! $this->guard instanceof \Tymon\JWTAuth\JWTGuard) {
throw MisconfiguredValueException::becauseOfInvalidGuardInjectorCombination("Please use a `\Tymon\JWTAuth\JWTGuard` guard.");

View File

@@ -2,14 +2,14 @@
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum;
class BuildGlobalHeadersAction
{
public function __construct(
private readonly ConfigRepository $configRepository,
private readonly ActiveApplicationResolver $activeApplicationResolver,
) {}
/**
@@ -18,7 +18,7 @@ class BuildGlobalHeadersAction
public function execute(): array
{
/** @var array<array-key, mixed> $headers */
$headers = $this->configRepository->get('nimbus.headers');
$headers = $this->activeApplicationResolver->getHeaders();
return array_values(
Arr::map(

View File

@@ -2,11 +2,11 @@
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Psr\Log\LoggerInterface;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Routes\Collections\ExtractedRoutesCollection;
use Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
@@ -29,7 +29,7 @@ class ExtractRoutesAction
protected SchemaExtractor $schemaExtractor,
protected ExtractableRouteFactory $routeFactory,
protected IgnoredRoutesService $ignoredRoutesService,
protected Repository $config,
protected ActiveApplicationResolver $activeApplicationResolver,
protected LoggerInterface $logger,
) {}
@@ -40,7 +40,7 @@ class ExtractRoutesAction
*/
public function execute(array $routes): ExtractedRoutesCollection
{
$prefix = $this->config->get('nimbus.routes.prefix');
$prefix = $this->activeApplicationResolver->getRoutesPrefix();
$configs = collect($routes)
->filter(function (Route $route) use ($prefix): bool {
@@ -95,8 +95,8 @@ class ExtractRoutesAction
return new ExtractedRoute(
uri: Endpoint::fromRaw(
$route->uri(),
routesPrefix: $this->config->get('nimbus.routes.prefix'),
isVersioned: $this->config->get('nimbus.routes.versioned'),
routesPrefix: $this->activeApplicationResolver->getRoutesPrefix(),
isVersioned: $this->activeApplicationResolver->isVersioned(),
),
methods: $methods,
schema: $schema,

View File

@@ -0,0 +1,189 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Config;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Http\Request;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(ActiveApplicationResolver::class)]
class ActiveApplicationResolverUnitTest extends TestCase
{
private Repository|MockInterface $configMock;
private Request|MockInterface $requestMock;
protected function setUp(): void
{
parent::setUp();
$this->configMock = Mockery::mock(Repository::class);
$this->requestMock = Mockery::mock(Request::class);
}
public function test_it_throws_exception_if_no_applications_defined(): void
{
// Arrange
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn([]);
// Anticipate
$this->expectException(MisconfiguredValueException::class);
$this->expectExceptionMessage('There are no applications defined.');
// Act
new ActiveApplicationResolver($this->configMock, $this->requestMock);
}
public function test_it_throws_exception_if_default_application_is_invalid(): void
{
// Arrange
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn(['p1' => []]);
$this->requestMock->shouldReceive('cookie')->with(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME)->andReturn(null);
$this->configMock->shouldReceive('get')->with('nimbus.default_application')->andReturn('invalid');
// Anticipate
$this->expectException(MisconfiguredValueException::class);
$this->expectExceptionMessage("The default application `invalid` doesn't have a matching configuration.");
// Act
new ActiveApplicationResolver($this->configMock, $this->requestMock);
}
public function test_it_resolves_application_from_cookie(): void
{
// Arrange
$applications = [
'p1' => ['name' => 'Project 1'],
'p2' => ['name' => 'Project 2'],
];
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn($applications);
$this->requestMock->shouldReceive('cookie')->with(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME)->andReturn('p2');
$this->configMock->shouldReceive('get')->with('nimbus.applications.p2', [])->andReturn($applications['p2']);
// Act
$resolver = new ActiveApplicationResolver($this->configMock, $this->requestMock);
// Assert
$this->assertEquals('p2', $resolver->getActiveApplicationKey());
}
public function test_it_falls_back_to_default_if_cookie_is_invalid(): void
{
// Arrange
$applications = [
'p1' => ['name' => 'Project 1'],
];
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn($applications);
$this->requestMock->shouldReceive('cookie')->with(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME)->andReturn('invalid');
$this->configMock->shouldReceive('get')->with('nimbus.default_application')->andReturn('p1');
$this->configMock->shouldReceive('get')->with('nimbus.applications.p1', [])->andReturn($applications['p1']);
// Act
$resolver = new ActiveApplicationResolver($this->configMock, $this->requestMock);
// Assert
$this->assertEquals('p1', $resolver->getActiveApplicationKey());
}
public function test_it_provides_specialized_getters_with_defaults(): void
{
// Arrange
$applications = ['main' => []];
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn($applications);
$this->requestMock->shouldReceive('cookie')->with(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME)->andReturn(null);
$this->configMock->shouldReceive('get')->with('nimbus.default_application')->andReturn('main');
$this->configMock->shouldReceive('get')->with('nimbus.applications.main', [])->andReturn([]);
$this->requestMock->shouldReceive('getSchemeAndHttpHost')->andReturn('http://localhost');
$resolver = new ActiveApplicationResolver($this->configMock, $this->requestMock);
// Act & Assert
$this->assertFalse($resolver->isVersioned());
$this->assertEquals('http://localhost', $resolver->getApiBaseUrl());
$this->assertEquals('api', $resolver->getRoutesPrefix());
$this->assertEquals('web', $resolver->getAuthGuard());
$this->assertNull($resolver->getSpecialAuthInjector());
$this->assertEquals([], $resolver->getHeaders());
}
public function test_it_provides_specialized_getters_from_config(): void
{
// Arrange
$config = [
'routes' => [
'versioned' => true,
'api_base_url' => 'https://api.example.com',
'prefix' => 'v1',
],
'auth' => [
'guard' => 'api',
'special' => ['injector' => 'SomeInjector'],
],
'headers' => ['X-Test' => 'value'],
];
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn(['main' => $config]);
$this->requestMock->shouldReceive('cookie')->with(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME)->andReturn(null);
$this->configMock->shouldReceive('get')->with('nimbus.default_application')->andReturn('main');
$this->configMock->shouldReceive('get')->with('nimbus.applications.main', [])->andReturn($config);
$resolver = new ActiveApplicationResolver($this->configMock, $this->requestMock);
// Act & Assert
$this->assertTrue($resolver->isVersioned());
$this->assertEquals('https://api.example.com', $resolver->getApiBaseUrl());
$this->assertEquals('v1', $resolver->getRoutesPrefix());
$this->assertEquals('api', $resolver->getAuthGuard());
$this->assertEquals('SomeInjector', $resolver->getSpecialAuthInjector());
$this->assertEquals(['X-Test' => 'value'], $resolver->getHeaders());
}
public function test_it_returns_available_applications_as_json(): void
{
// Arrange
$applications = [
'p1' => ['name' => 'Project 1'],
'p2' => [], // Should fallback to key if name missing
];
$this->configMock->shouldReceive('get')->with('nimbus.applications', [])->andReturn($applications);
$this->requestMock->shouldReceive('cookie')->with(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME)->andReturn(null);
$this->configMock->shouldReceive('get')->with('nimbus.default_application')->andReturn('p1');
$this->configMock->shouldReceive('get')->with('nimbus.applications.p1', [])->andReturn($applications['p1']);
$resolver = new ActiveApplicationResolver($this->configMock, $this->requestMock);
// Act
$available = $resolver->getAvailableApplications();
// Assert
$this->assertJson($available);
$this->assertEquals(['p1' => 'Project 1', 'p2' => 'p2'], json_decode($available, true));
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Config\Exceptions;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(MisconfiguredValueException::class)]
class MisconfiguredValueExceptionUnitTest extends TestCase
{
public function test_because_special_authentication_injector_is_invalid(): void
{
// Act
$exception = MisconfiguredValueException::becauseSpecialAuthenticationInjectorIsInvalid();
// Assert
$this->assertEquals(
'The config value for `nimbus.auth.special.injector` MUST be a class string of type <'.SpecialAuthenticationInjectorContract::class.'>',
$exception->getMessage(),
);
$this->assertEquals(MisconfiguredValueException::SPECIAL_AUTHENTICATION_INJECTOR, $exception->getCode());
}
public function test_because_of_missing_dependency(): void
{
// Arrange
$dependency = 'some/package';
// Act
$exception = MisconfiguredValueException::becauseOfMissingDependency($dependency);
// Assert
$this->assertEquals(
'The config value for `nimbus.auth.special.injector` is an injector that requires the following dependency <some/package>',
$exception->getMessage(),
);
$this->assertEquals(MisconfiguredValueException::MISSING_DEPENDENCIES, $exception->getCode());
}
public function test_because_of_invalid_guard_injector_combination(): void
{
// Arrange
$suggestion = 'Try using another guard.';
// Act
$exception = MisconfiguredValueException::becauseOfInvalidGuardInjectorCombination($suggestion);
// Assert
$this->assertEquals(
"The config value for `nimbus.auth.guard` doesn't work with the selected injector. Try using another guard.",
$exception->getMessage(),
);
$this->assertEquals(MisconfiguredValueException::INVALID_GUARD_INJECTOR_COMBINATION, $exception->getCode());
}
public function test_because_default_application_is_invalid(): void
{
// Arrange
$key = 'missing-app';
// Act
$exception = MisconfiguredValueException::becauseDefaultApplicationIsInvalid($key);
// Assert
$this->assertEquals(
"The default application `missing-app` doesn't have a matching configuration.",
$exception->getMessage(),
);
$this->assertEquals(MisconfiguredValueException::INVALID_DEFAULT_APPLICATION, $exception->getCode());
}
public function test_because_applications_are_not_defined(): void
{
// Act
$exception = MisconfiguredValueException::becauseApplicationsAreNotDefined();
// Assert
$this->assertEquals(
'There are no applications defined.',
$exception->getMessage(),
);
$this->assertEquals(MisconfiguredValueException::INVALID_APPLICATIONS, $exception->getCode());
}
}

View File

@@ -5,6 +5,7 @@ namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\CurrentUserAuthorizationHandler;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Shared\HandlesRecallerCookies;
@@ -21,11 +22,10 @@ class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase
{
// Arrange
config([
'nimbus.auth.guard' => $guardName = fake()->word(),
'nimbus.auth.special.injector' => DummySpecialAuthenticationInjector::class,
]);
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) {
$mock->shouldReceive('getAuthGuard')->andReturn($guardName = fake()->word());
$mock->shouldReceive('getSpecialAuthInjector')->andReturn(DummySpecialAuthenticationInjector::class);
});
$dummyAuthenticatable = new DummyAuthenticatable(id: $userId = fake()->randomNumber());
$this->mockAuthManagerToUseDummyModel($userId, $dummyAuthenticatable, $guardName);

View File

@@ -5,6 +5,7 @@ namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\TestWith;
@@ -25,11 +26,10 @@ class ImpersonateUserAuthorizationHandlerFunctionalTest extends TestCase
{
// Arrange
config([
'nimbus.auth.guard' => $guardName = fake()->word(),
'nimbus.auth.special.injector' => DummySpecialAuthenticationInjector::class,
]);
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) {
$mock->shouldReceive('getAuthGuard')->andReturn($guardName = fake()->word());
$mock->shouldReceive('getSpecialAuthInjector')->andReturn(DummySpecialAuthenticationInjector::class);
});
$dummyAuthenticatable = new DummyAuthenticatable(id: $userId = fake()->randomNumber());
$this->mockAuthManagerToUseDummyModel($userId, $dummyAuthenticatable, $guardName);

View File

@@ -3,7 +3,6 @@
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Injectors;
use Illuminate\Auth\SessionGuard;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
@@ -12,6 +11,7 @@ use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors\RememberMeCookieInjector;
use Sunchayn\Nimbus\Tests\TestCase;
@@ -20,7 +20,7 @@ class RememberMeCookieInjectorUnitTest extends TestCase
{
private Container $containerMock;
private ConfigRepository $configMock;
private ActiveApplicationResolver|MockInterface $projectManagerMock;
private SessionGuard $authGuardMock;
@@ -33,7 +33,7 @@ class RememberMeCookieInjectorUnitTest extends TestCase
parent::setUp();
$this->containerMock = Mockery::mock(Container::class);
$this->configMock = Mockery::mock(ConfigRepository::class);
$this->projectManagerMock = Mockery::mock(ActiveApplicationResolver::class);
$this->authGuardMock = Mockery::mock(SessionGuard::class);
$this->userProviderMock = Mockery::mock(UserProvider::class);
$this->encrypterMock = Mockery::mock(Encrypter::class);
@@ -360,9 +360,8 @@ class RememberMeCookieInjectorUnitTest extends TestCase
private function instantiateInjector(Request $relayRequest): RememberMeCookieInjector
{
$this->configMock
->shouldReceive('get')
->with('nimbus.auth.guard')
$this->projectManagerMock
->shouldReceive('getAuthGuard')
->andReturn('web');
$authManagerMock = Mockery::mock(\Illuminate\Auth\AuthManager::class);
@@ -388,7 +387,7 @@ class RememberMeCookieInjectorUnitTest extends TestCase
return new RememberMeCookieInjector(
relayRequest: $relayRequest,
container: $this->containerMock,
configRepository: $this->configMock,
activeApplicationResolver: $this->projectManagerMock,
);
}

View File

@@ -2,13 +2,13 @@
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Injectors;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Client\PendingRequest;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors\TymonJwtTokenInjector;
use Sunchayn\Nimbus\Tests\TestCase;
@@ -16,7 +16,7 @@ use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(TymonJwtTokenInjector::class)]
class TymonJwtTokenInjectorUnitTest extends TestCase
{
private ConfigRepository $configMock;
private ActiveApplicationResolver|MockInterface $projectManagerMock;
private Container $containerMock;
@@ -24,7 +24,7 @@ class TymonJwtTokenInjectorUnitTest extends TestCase
{
parent::setUp();
$this->configMock = Mockery::mock(ConfigRepository::class);
$this->projectManagerMock = Mockery::mock(ActiveApplicationResolver::class);
$this->containerMock = Mockery::mock(Container::class);
}
@@ -44,7 +44,7 @@ class TymonJwtTokenInjectorUnitTest extends TestCase
// Act
new TymonJwtTokenInjector(
configRepository: $this->configMock,
activeApplicationResolver: $this->projectManagerMock,
container: $this->containerMock,
);
}

View File

@@ -2,8 +2,6 @@
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Actions;
use Illuminate\Config\Repository;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildGlobalHeadersAction;
@@ -23,15 +21,9 @@ class BuildGlobalHeadersActionFunctionalTest extends TestCase
'X-Custom-Header' => '::value::',
];
$this->mock(
Repository::class,
function (MockInterface $mock) use ($globalHeadersConfig) {
$mock
->shouldReceive('get')
->with('nimbus.headers')
->andReturn($globalHeadersConfig);
},
);
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (\Mockery\MockInterface $mock) use ($globalHeadersConfig) {
$mock->shouldReceive('getHeaders')->andReturn($globalHeadersConfig);
});
$action = resolve(BuildGlobalHeadersAction::class);
@@ -72,15 +64,9 @@ class BuildGlobalHeadersActionFunctionalTest extends TestCase
{
// Arrange
$this->mock(
Repository::class,
function (MockInterface $mock) {
$mock
->shouldReceive('get')
->with('nimbus.headers')
->andReturn([]);
},
);
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (\Mockery\MockInterface $mock) {
$mock->shouldReceive('getHeaders')->andReturn([]);
});
$action = resolve(BuildGlobalHeadersAction::class);

View File

@@ -2,7 +2,6 @@
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Services;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route as RouteFacade;
@@ -47,9 +46,9 @@ class RouteExtractorServiceFunctionalTest extends TestCase
{
// Anticipate
$this->mock(ConfigRepository::class, function (MockInterface $mock) {
$mock->shouldReceive('get')->with('nimbus.routes.prefix')->andReturn('api');
$mock->shouldReceive('get')->with('nimbus.routes.versioned')->andReturn(fake()->boolean());
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('getRoutesPrefix')->andReturn('api');
$mock->shouldReceive('isVersioned')->andReturn($this->isVersioned = fake()->boolean());
});
$routeFactoryMock = $this->mock(ExtractableRouteFactory::class, function (MockInterface $mock) {
@@ -205,19 +204,19 @@ class RouteExtractorServiceFunctionalTest extends TestCase
{
// Arrange
$config = $this->mock(ConfigRepository::class);
RouteFacade::post('/custom/test', fn () => response()->json(['test' => true]))
->name('custom.test');
$activeApplicationResolverMock = $this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class);
$routeExtractorService = resolve(ExtractRoutesAction::class);
$routes = RouteFacade::getRoutes()->getRoutes();
// Anticipate
$config->shouldReceive('get')->with('nimbus.routes.prefix')->andReturn('custom');
$config->shouldReceive('get')->with('nimbus.routes.versioned')->andReturnFalse();
$activeApplicationResolverMock->shouldReceive('getRoutesPrefix')->andReturn('custom');
$activeApplicationResolverMock->shouldReceive('isVersioned')->andReturn(false);
// Act
@@ -239,9 +238,9 @@ class RouteExtractorServiceFunctionalTest extends TestCase
{
// Anticipate
$this->mock(ConfigRepository::class, function (MockInterface $mock) {
$mock->shouldReceive('get')->with('nimbus.routes.prefix')->andReturn('api');
$mock->shouldReceive('get')->with('nimbus.routes.versioned')->andReturn(fake()->boolean());
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('getRoutesPrefix')->andReturn('api');
$mock->shouldReceive('isVersioned')->andReturn(fake()->boolean());
});
$dummyFailingException = new RuntimeException(message: $dummyFailingExceptionMessage = fake()->sentence());

View File

@@ -114,7 +114,7 @@ test("Dump and Die visualization sanity checklist", async ({ page }) => {
.click();
await expect(
page.locator("#reka-collapsible-content-v-113"),
page.locator("#reka-collapsible-content-v-119"),
).toMatchAriaSnapshot(`- text: "0: \\"/\\" (1) 1: \\"\\\\\\" (1)"`);
// dump #3 (runtime object)

View File

@@ -16,7 +16,6 @@ use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(NimbusIndexController::class)]
#[CoversClass(NimbusIndexController::class)]
class NimbusIndexTest extends TestCase
{
@@ -32,16 +31,20 @@ class NimbusIndexTest extends TestCase
parent::setUp();
// Mock Vite to prevent asset loading issues
Vite::shouldReceive('useBuildDirectory')->with('/vendor/nimbus')->once();
Vite::shouldReceive('useHotFile')->with(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'))->once();
Vite::shouldReceive('__invoke')->andReturn('<script src="/nimbus/app.js"></script>');
Vite::shouldReceive('useBuildDirectory')->with('/vendor/nimbus')->atMost()->once();
Vite::shouldReceive('useHotFile')->with(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'))->atMost()->once();
Vite::shouldReceive('__invoke')->andReturn('<script src="/nimbus/app.js"></script>')->atMost()->once();
}
#[DataProvider('indexRouteProvider')]
public function test_it_loads_view_correctly(string $uri): void
public function test_it_loads_view_correctly(string $uri, ?string $applicationCookie = null, string $expectedApplicationKey = 'main'): void
{
// Arrange
if ($applicationCookie) {
$this->withCookie(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME, $applicationCookie);
}
$disableThirdPartyUiActionSpy = $this->spy(DisableThirdPartyUiAction::class);
$ignoreRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$buildGlobalHeadersActionMock = $this->mock(BuildGlobalHeadersAction::class);
@@ -54,11 +57,13 @@ class NimbusIndexTest extends TestCase
$buildCurrentUserActionMock->shouldReceive('execute')->andReturn(['::current-user::']);
$extractedRoutesCollectionStub = new class extends ExtractedRoutesCollection
$extractedRoutesCollectionStub = new class($expectedApplicationKey) extends ExtractedRoutesCollection
{
public function __construct(private string $key) {}
public function toFrontendArray(): array
{
return ['::extracted-routes::'];
return ["routes for $this->key"];
}
};
@@ -74,12 +79,16 @@ class NimbusIndexTest extends TestCase
$response->assertViewIs('nimbus::app');
$response->assertViewHas('routes', ['::extracted-routes::']);
$response->assertViewHas('routes', ["routes for $expectedApplicationKey"]);
$response->assertViewHas('headers', ['::global-headers::']);
$response->assertViewHas('currentUser', ['::current-user::']);
$response->assertViewHas('activeApplicationResolver', function ($resolver) use ($expectedApplicationKey) {
return $resolver->getActiveApplicationKey() === $expectedApplicationKey;
});
$disableThirdPartyUiActionSpy->shouldHaveReceived('execute')->once();
$ignoreRoutesServiceSpy->shouldNotHaveReceived('execute');
@@ -87,12 +96,22 @@ class NimbusIndexTest extends TestCase
public static function indexRouteProvider(): Generator
{
yield 'index route handles route extraction exception' => [
yield 'index route with default application' => [
'uri' => '/',
'applicationCookie' => null,
'expectedApplicationKey' => 'main',
];
yield 'index route with application from cookie' => [
'uri' => '/',
'applicationCookie' => 'other',
'expectedApplicationKey' => 'other',
];
yield 'index route with catch all parameter' => [
'uri' => '/some/deep/path',
'applicationCookie' => null,
'expectedApplicationKey' => 'main',
];
}
@@ -183,4 +202,24 @@ class NimbusIndexTest extends TestCase
$response->assertViewHas(['routes', 'headers', 'currentUser']);
}
public function test_it_redirects_to_new_application(): void
{
// Arrange
$url = route('nimbus.index', ['application' => 'new-app']);
// Act
$response = $this->get($url);
// Assert
$response->assertRedirect(route('nimbus.index').'?');
$response->assertCookie(
\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME,
'new-app',
);
}
}

View File

@@ -13,20 +13,36 @@ class TestCase extends BaseTestCase
{
parent::setUp();
config([
'nimbus.prefix' => 'nimbus',
'nimbus.routes.prefix' => 'api',
'nimbus.routes.versioned' => false,
'nimbus.headers' => [
'x-request-id' => 'uuid',
'x-session-id' => 'uuid',
],
'force',
]);
Http::preventStrayRequests();
}
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
$app['config']->set('nimbus.prefix', 'nimbus');
$app['config']->set('nimbus.default_application', 'main');
$app['config']->set('nimbus.applications', [
'main' => [
'name' => 'Main Application',
'routes' => [
'prefix' => 'api',
'versioned' => false,
],
'headers' => [
'x-request-id' => 'uuid',
'x-session-id' => 'uuid',
],
],
'other' => [
'name' => 'Other Application',
'routes' => [
'prefix' => 'other-api',
'versioned' => true,
],
],
]);
}
protected function tearDown(): void
{
if ($container = Mockery::getContainer()) {

View File

@@ -90,10 +90,12 @@ The Route Explorer automatically discovers your Laravel API routes and organizes
![Routes](./assets/routes.png)
**Features:**
- Routes are grouped by resource (e.g., all user-related endpoints together).
- Each route shows its HTTP methods.
- Click any route to load it in the Request Builder.
- Routes are extracted based on your configured API prefix.
- **Search**: Quickly filter routes by endpoint path.
- **Application Switcher**: Switch between multiple API applications (e.g., Rest API, CMS API) if configured.
- **Version Selector**: If the active application is versioned, a version picker appears inline with the application name.
- **Resource Groups**: Routes are automatically grouped by resource (e.g., `users`, `products`).
- **HTTP Methods**: Each route explicitly shows its supported methods.
- **Quick Load**: Click any route to immediately load it into the Request Builder.
### Request Builder
@@ -359,16 +361,44 @@ return [
### Configuration Options
| Option | Description | Default | Example |
|--------|--------------|----------|----------|
| **`prefix`** | The URI segment under which Nimbus is accessible. | `'nimbus'` | `'api-client'` |
| **`allowed_envs`** | Environments where Nimbus is enabled. Avoid production for security reasons. | `['local', 'staging']` | `['testing', 'local']` |
| **`routes.prefix`** | The base path used to detect application routes. Only routes starting with this prefix are analyzed. | `'api'` | `'api/v1'` |
| **`routes.versioned`** | Enables version parsing for routes like `/api/v1/...`. | `false` | `true` |
| **`routes.api_base_url`** | The base URL used when Nimbus relays API requests from the UI. Useful when the API runs on a different domain or port. If set to null, Nimbus will default to the same host and scheme as the incoming request.| null | `http://127.0.0.1:8001` |
| **`auth.guard`** | The Laravel guard used for the API requests authentication. | `'api'` | `'web'` |
| **`auth.special.injector`** | Injector class used to attach authentication credentials to outgoing requests. Must implement `SpecialAuthenticationInjectorContract`. | `RememberMeCookieInjector::class` | `TymonJwtTokenInjector::class` |
| **`headers`** | Global headers applied to all outgoing requests. Supports static values or enum generators. | `[]` | `['x-request-id' => GlobalHeaderGeneratorTypeEnum::UUID]` |
| Option | Description | Default | Example |
|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------|-----------------------------------------------------------|
| **`prefix`** | The URI segment under which Nimbus is accessible. | `'nimbus'` | `'api-client'` |
| **`allowed_envs`** | Environments where Nimbus is enabled. Avoid production for security reasons. | `['local', 'staging']` | `['testing', 'local']` |
| **`default_application`** | The base default application to load when no other application is found in the storage. | n/a | `rest-api` |
| **`applications.*.routes.prefix`** | The base path used to detect application routes. Only routes starting with this prefix are analyzed. | `'api'` | `'api/v1'` |
| **`applications.*.routes.versioned`** | Enables version parsing for routes like `/api/v1/...`. | `false` | `true` |
| **`applications.*.routes.api_base_url`** | The base URL used when Nimbus relays API requests from the UI. Useful when the API runs on a different domain or port. If set to null, Nimbus will default to the same host and scheme as the incoming request. | null | `http://127.0.0.1:8001` |
| **`applications.*.auth.guard`** | The Laravel guard used for the API requests authentication. | `'api'` | `'web'` |
| **`applications.*.auth.special.injector`** | Injector class used to attach authentication credentials to outgoing requests. Must implement `SpecialAuthenticationInjectorContract`. | `RememberMeCookieInjector::class` | `TymonJwtTokenInjector::class` |
| **`headers`** | Global headers applied to all outgoing requests. Supports static values or enum generators. | `[]` | `['x-request-id' => GlobalHeaderGeneratorTypeEnum::UUID]` |
### Multi-Application Support
Nimbus allows you to define multiple distinct application in your configuration. This is ideal for projects with multiple APIs like a REST api + CMS APIs, or different microservices within the same monolith.
```php
'applications' => [
'main' => [
'name' => 'Public API',
'routes' => [
'prefix' => 'api/v1',
'versioned' => false,
],
],
'admin' => [
'name' => 'Admin API',
'routes' => [
'prefix' => 'api/admin',
'versioned' => true,
],
],
],
```
When multiple applications are defined, a Project Switcher appears in the sidebar, allowing you to quickly toggle between them.
![Applications Switcher](./assets/applications-switcher.png)
#### Special Authentication Modes

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 200 KiB