knuckleswtf / scribe

Generate API documentation for humans from your Laravel codebase.✍
https://scribe.knuckles.wtf/laravel/
MIT License
1.75k stars 314 forks source link

whenLoaded not working on api resource when using factories #321

Closed denisgoryaynov closed 2 years ago

denisgoryaynov commented 3 years ago

I have the following code in my api resource UserResource:

public function toArray($request): array
{
    $user = $this->user;

    return [
        'id' => $user->id,
        'name' => $user->name,
        'created_at' => $user->created_at,
        'updated_at' => $user->updated_at,
        'organization' => new OrganizationResource($this->whenLoaded('organization')), // this doesn't work with factory
        'organization' => new OrganizationResource($user->organization) // this works with factory, but I want to load my relations conditionally from my controller
   ];
}

And the following code in my UserFactory:

public function definition(): array
{
    return [
        'name' => $this->faker->name(),
        'created_at' => $this->faker->dateTime(),
        'updated_at' => $this->faker->dateTime(),
        'organization_id' => Organization::factory(),
    ];
}

And this is the comment in my UserController:

* @apiResource 200 App\Http\Resources\UserResource
* @apiResourceModel App\Models\User with=organization

What I expect from my resource is the following result:

{
    "data": {
        "id": 1,
        "name": "3d858092-e094-3d4e-8087-3b18dbec35b5",
        "created_at": "2009-08-04T17:42:10.000000Z",
        "updated_at": "1972-05-11T03:41:39.000000Z",
        "organization": {
            "id": 1,
            "name": "Murazik, Sporer and Glover",
            "created_at": "2021-09-14T14:52:07.000000Z",
            "updated_at": "2021-09-14T14:52:07.000000Z"
        }
    }
}

But what happens instead is I get:

{
    "data": {
        "id": 1,
        "name": "3d858092-e094-3d4e-8087-3b18dbec35b5",
        "created_at": "2009-08-04T17:42:10.000000Z",
        "updated_at": "1972-05-11T03:41:39.000000Z"
        // Organization is missing here
    }
}

If i use $user->load('organization') on the $user object created by the factory I can successfully see that the relationship is present inside the relations field of the Eloquent Model and the UserResource successfully generates the expected output, but if I use $user->with('organization') on the $user object created by the factory the relations field is empty, so maybe it has something to do with the way factories load the relations in memory?

If I remove UseFactory from my User model scribe can successfully generate the expected documentation (by calling User::first() as I understand from the documentation).

My environment:

My Scribe config (minus the comments):

<?php

use Knuckles\Scribe\Extracting\Strategies;

return [

    'theme' => 'default',
    'title' => null,
    'description' => '',
    'base_url' => null,
    'routes' => [
        [
            'match' => [
                'prefixes' => ['api/*'],
                'domains' => ['*'],
                'versions' => ['v1'],
            ],
            'include' => [
            ],
            'exclude' => [
            ],
            'apply' => [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Accept' => 'application/json',
                ],
                'response_calls' => [
                    'methods' => ['GET'],
                    'config' => [
                        'app.env' => 'documentation',
                    ],
                    'queryParams' => [
                    ],
                    'bodyParams' => [
                    ],
                    'fileParams' => [
                    ],
                    'cookies' => [
                    ],
                ],
            ],
        ],
    ],
    'type' => 'laravel',
    'static' => [
        'output_path' => 'public/docs',
    ],
    'laravel' => [
        'add_routes' => false,
        'docs_url' => '/docs',
        'middleware' => [],
    ],

    'try_it_out' => [
        'enabled' => false,
        'base_url' => null,
        'use_csrf' => false,
        'csrf_url' => '/sanctum/csrf-cookie',
    ],
    'auth' => [
        'enabled' => true,
        'default' => true,
        'in' => 'bearer',
        'name' => 'key',
        'use_value' => env('SCRIBE_AUTH_KEY'),
        'placeholder' => '{YOUR_AUTH_KEY}',
        'extra_info' => 'You can retrieve your token by visiting your dashboard and clicking <b>Generate API token</b>.',
    ],
    'intro_text' => <<<INTRO
This documentation aims to provide all the information you need to work with our API.

<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>
INTRO
    ,
    'example_languages' => [
        'javascript',
        'php'
    ],
    'postman' => [
        'enabled' => true,
        'overrides' => [
        ],
    ],
    'openapi' => [
        'enabled' => true,
        'overrides' => [
        ],
    ],
    'default_group' => 'Endpoints',
    'logo' => false,
    'faker_seed' => null,
    'strategies' => [
        'metadata' => [
            Strategies\Metadata\GetFromDocBlocks::class,
        ],
        'urlParameters' => [
            Strategies\UrlParameters\GetFromLaravelAPI::class,
            Strategies\UrlParameters\GetFromLumenAPI::class,
            Strategies\UrlParameters\GetFromUrlParamTag::class,
        ],
        'queryParameters' => [
            Strategies\QueryParameters\GetFromFormRequest::class,
            Strategies\QueryParameters\GetFromInlineValidator::class,
            Strategies\QueryParameters\GetFromQueryParamTag::class,
        ],
        'headers' => [
            Strategies\Headers\GetFromRouteRules::class,
            Strategies\Headers\GetFromHeaderTag::class,
        ],
        'bodyParameters' => [
            Strategies\BodyParameters\GetFromFormRequest::class,
            Strategies\BodyParameters\GetFromInlineValidator::class,
            Strategies\BodyParameters\GetFromBodyParamTag::class,
        ],
        'responses' => [
            Strategies\Responses\UseTransformerTags::class,
            Strategies\Responses\UseResponseTag::class,
            Strategies\Responses\UseResponseFileTag::class,
            Strategies\Responses\UseApiResourceTags::class,
            Strategies\Responses\ResponseCalls::class,
        ],
        'responseFields' => [
            Strategies\ResponseFields\GetFromResponseFieldTag::class,
        ],
    ],

    'fractal' => [
        'serializer' => null,
    ],
    'routeMatcher' => \Knuckles\Scribe\Matching\RouteMatcher::class,
    'database_connections_to_transact' => [config('database.default')]
];

Additional info:

shalvah commented 3 years ago

This is the code that loads relations in user factories. It calls the has$Relation method in the factory. I'm not sure how or what else it should be doing🤔 Should it also be calling load()?

https://github.com/knuckleswtf/scribe/blob/b4f78bfb949382491c12f1857c52cea433065f2d/src/Tools/Utils.php#L196-L200

shalvah commented 3 years ago

Also, the factories are by default persisted to the database, so it may not be a memory issue.

https://github.com/knuckleswtf/scribe/blob/b4f78bfb949382491c12f1857c52cea433065f2d/src/Extracting/Strategies/Responses/UseApiResourceTags.php#L188-L193

denisgoryaynov commented 3 years ago

Hi, after following you replies I tried to play around with the code and it seems that calling load($relations) line 189 in scribe/src/Extracting/Strategies/Responses/UseApiResourceTags.php does the trick:

try { 
     // Before
     // return $factory->create();

     //After
     return $factory->create()->load($relations); 
 } catch (Throwable $e) { 
     // If there was no working database, ->create() would fail. Try ->make() instead 
     return $factory->make(); 
 } 

But it doesn't seem to work when usign make() and leads to the command crashing. I also found out that with() doesn't work on models after they have been queried from database (even with production data), for example:

// In this case the relation with the organization object doesn't get loaded 
// and calling $this->whenLoaded('organization') doesn't return the organization
public function show(User $user): UserResource
{
    $user->with('organization');

    return new UserResource($user);
}

/* Result
{
    "data": {
        "id": 1,
        "name": "3d858092-e094-3d4e-8087-3b18dbec35b5",
        "created_at": "2009-08-04T17:42:10.000000Z",
        "updated_at": "1972-05-11T03:41:39.000000Z"
    }
}
*/

But when using load() the resource is created correctly:

// This works as intended
public function show(User $user): UserResource
{
    $user->load('organization');

    return new UserResource($user);
}

/* Result
{
    "data": {
        "id": 1,
        "name": "3d858092-e094-3d4e-8087-3b18dbec35b5",
        "created_at": "2009-08-04T17:42:10.000000Z",
        "updated_at": "1972-05-11T03:41:39.000000Z",
        "organization": {
            "id": 1,
            "name": "Murazik, Sporer and Glover",
            "created_at": "2021-09-14T14:52:07.000000Z",
            "updated_at": "2021-09-14T14:52:07.000000Z"
        }
    }
}
*/

So I suppose that this is just laravel handling relationships differently before/after query and that you are correct by saying that adding load() should be enough to fix this

johannesss commented 2 years ago

Also noticed this issue, good work finding a solution! Will this be implemented soon, or do you want me to create a PR? @shalvah

shalvah commented 2 years ago

@johannesss PRs welcome. Also not sure, but #417 might be related.