Zapier integration (#491)

* create zapier app

* install sanctum

* move OAuthProviderController

* make `api-external` middleware

* add zapier endpoints

* add tests

* token management

* zapier event handler

* add policy

* use `slug` instead of `id`

* wip

* check policies

* change api prefix to `external`

* ui tweaks

* validate token abilities

* open zapier URL

* zapier ui tweaks

* update zap

* Fix linting

* Added sample endpoints + minor UI changes

* Run PHP code linter

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Boris Lepikhin
2024-08-12 02:14:02 -07:00
committed by GitHub
parent 7ad62fb3ea
commit 517bccc695
61 changed files with 5799 additions and 51 deletions

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Enums;
use Illuminate\Support\Arr;
enum AccessTokenAbility: string
{
case ManageIntegrations = 'manage-integrations';
case ListForms = 'list-forms';
case ListWorkspaces = 'list-workspaces';
public static function values(): array
{
return array_map(
fn (AccessTokenAbility $case) => $case->value,
static::cases()
);
}
public static function allowed(array $abilities): array
{
return Arr::where(
$abilities,
fn (string $ability) => in_array($ability, static::values())
);
}
}

View File

@@ -13,7 +13,6 @@ class FormZapierWebhookController extends Controller
*/
public function __construct()
{
// $this->middleware('subscribed');
$this->middleware('auth');
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use App\Http\Requests\Integration\Zapier\PollSubmissionRequest;
use App\Http\Requests\Zapier\CreateIntegrationRequest;
use App\Http\Requests\Zapier\DeleteIntegrationRequest;
use App\Integrations\Handlers\ZapierIntegration;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Tests\Helpers\FormSubmissionDataFactory;
class IntegrationController
{
use AuthorizesRequests;
public function store(CreateIntegrationRequest $request)
{
$form = $request->form();
$this->authorize('view', $form);
$form->integrations()
->create([
'integration_id' => 'zapier',
'status' => 'active',
'data' => [
'hook_url' => $request->input('hookUrl'),
],
]);
return response()->json();
}
public function destroy(DeleteIntegrationRequest $request)
{
$form = $request->form();
$this->authorize('view', $form);
$form
->integrations()
->where('data->hook_url', $request->input('hookUrl'))
->delete();
return response()->json();
}
public function poll(PollSubmissionRequest $request)
{
$form = $request->form();
$this->authorize('view', $form);
$lastSubmission = $form->submissions()->latest()->first();
if (!$lastSubmission) {
// Generate fake data when no previous submissions
$submissionData = (new FormSubmissionDataFactory($form))->asFormSubmissionData()->createSubmissionData();
}
return [ZapierIntegration::formatWebhookData($form, $submissionData ?? $lastSubmission->data)];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use App\Http\Requests\Zapier\ListFormsRequest;
use App\Http\Resources\Zapier\FormResource;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class ListFormsController
{
use AuthorizesRequests;
public function __invoke(ListFormsRequest $request)
{
$workspace = $request->workspace();
$this->authorize('view', $workspace);
return FormResource::collection(
$workspace->forms()->get()
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use App\Http\Resources\Zapier\WorkspaceResource;
use App\Models\Workspace;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
class ListWorkspacesController
{
use AuthorizesRequests;
public function __invoke()
{
$this->authorize('viewAny', Workspace::class);
return WorkspaceResource::collection(
Auth::user()->workspaces()->get()
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Integrations\Zapier;
use Illuminate\Support\Facades\Auth;
class ValidateAuthController
{
public function __invoke()
{
$user = Auth::user();
return [
'name' => $user->name,
'email' => $user->email,
];
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\OAuth;
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Resources\OAuthProviderResource;

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Enums\AccessTokenAbility;
use App\Http\Requests\CreateTokenRequest;
use App\Http\Resources\TokenResource;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\PersonalAccessToken;
class TokenController
{
use AuthorizesRequests;
public function index()
{
return TokenResource::collection(
Auth::user()->tokens()->get()
);
}
public function store(CreateTokenRequest $request)
{
$token = Auth::user()->createToken(
$request->input('name'),
AccessTokenAbility::allowed($request->input('abilities'))
);
return response()->json([
'token' => $token->plainTextToken,
]);
}
public function destroy(PersonalAccessToken $token)
{
$this->authorize('delete', $token);
$token->delete();
return response()->json();
}
}

View File

@@ -64,6 +64,11 @@ class Kernel extends HttpKernel
SelfHostedCredentialsMiddleware::class,
ImpersonationMiddleware::class,
],
'api-external' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
@@ -90,5 +95,8 @@ class Kernel extends HttpKernel
'pro-form' => \App\Http\Middleware\Form\ProForm::class,
'protected-form' => \App\Http\Middleware\Form\ProtectedForm::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
];
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateTokenRequest extends FormRequest
{
public function rules()
{
return [
'name' => [
'required',
'string',
],
'abilities' => [
'nullable',
'array'
]
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Integration\Zapier;
use App\Models\Forms\Form;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PollSubmissionRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'form_id' => [
'required',
Rule::exists(Form::getModel()->getTable(), 'slug'),
],
];
}
public function form(): Form
{
return Form::query()
->where('slug', $this->input('form_id'))
->firstOrFail();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Zapier;
use App\Models\Forms\Form;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateIntegrationRequest extends FormRequest
{
public function rules()
{
return [
'form_id' => [
'required',
Rule::exists(Form::getModel()->getTable(), 'slug'),
],
'hookUrl' => [
'required',
'url',
],
];
}
public function form(): Form
{
return Form::query()
->where('slug', $this->input('form_id'))
->firstOrFail();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Zapier;
use App\Models\Forms\Form;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DeleteIntegrationRequest extends FormRequest
{
public function rules()
{
return [
'form_id' => [
'required',
Rule::exists(Form::getModel()->getTable(), 'slug'),
],
'hookUrl' => [
'required',
'url',
],
];
}
public function form(): Form
{
return Form::query()
->where('slug', $this->input('form_id'))
->firstOrFail();
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Zapier;
use App\Models\Workspace;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ListFormsRequest extends FormRequest
{
public function rules()
{
return [
'workspace_id' => [
'required',
Rule::exists(Workspace::getModel()->getTable(), 'id'),
],
];
}
public function workspace(): Workspace
{
return Workspace::findOrFail($this->input('workspace_id'));
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \Laravel\Sanctum\PersonalAccessToken $resource
*/
class TokenResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
'abilities' => $this->resource->abilities,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Zapier;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \App\Models\Forms\Form $resource
*/
class FormResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->resource->slug,
'name' => $this->resource->title,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Zapier;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \App\Models\Workspace $resource
*/
class WorkspaceResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Integrations\Handlers;
use App\Models\Integration\FormIntegration;
use App\Events\Forms\FormSubmitted;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegrationsEvent;
use App\Service\Forms\FormSubmissionFormatter;
use App\Service\Forms\FormLogicConditionChecker;
@@ -34,7 +35,7 @@ abstract class AbstractIntegrationHandler
protected function logicConditionsMet(): bool
{
if (!$this->formIntegration->logic) {
if (!$this->formIntegration->logic || empty((array) $this->formIntegration->logic)) {
return true;
}
return FormLogicConditionChecker::conditionsMet(
@@ -58,27 +59,7 @@ abstract class AbstractIntegrationHandler
*/
protected function getWebhookData(): array
{
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))
->useSignedUrlForFiles()
->showHiddenFields();
$formattedData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$formattedData[$field['name']] = $field['value'];
}
$data = [
'form_title' => $this->form->title,
'form_slug' => $this->form->slug,
'submission' => $formattedData,
];
if ($this->form->is_pro && $this->form->editable_submissions) {
$data['edit_link'] = $this->form->share_url . '?submission_id=' . Hashids::encode(
$this->submissionData['submission_id']
);
}
return $data;
return self::formatWebhookData($this->form, $this->submissionData);
}
final public function run(): void
@@ -125,8 +106,40 @@ abstract class AbstractIntegrationHandler
return [];
}
public static function formatData(array $data): array
public static function formatWebhookData(Form $form, array $submissionData): array
{
$formatter = (new FormSubmissionFormatter($form, $submissionData))
->useSignedUrlForFiles()
->showHiddenFields();
// Old format - kept for retro-compatibility
$oldFormatData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$oldFormatData[$field['name']] = $field['value'];
}
// New format using ID
$formattedData = [];
foreach ($formatter->getFieldsWithValue() as $field) {
$formattedData[$field['id']] = [
'value' => $field['value'],
'name' => $field['name'],
];
}
$data = [
'form_title' => $form->title,
'form_slug' => $form->slug,
'submission' => $oldFormatData,
'data' => $formattedData,
'message' => 'Please do not use the `submission` field. It is deprecated and will be removed in the future.'
];
if ($form->is_pro && $form->editable_submissions) {
$data['edit_link'] = $form->share_url . '?submission_id=' . Hashids::encode(
$submissionData['submission_id']
);
}
return $data;
}
@@ -143,4 +156,12 @@ abstract class AbstractIntegrationHandler
'message' => $e->getMessage()
];
}
/**
* Used in FormIntegrationRequest to format integration
*/
public static function formatData(array $data): array
{
return $data;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Integrations\Handlers;
use App\Events\Forms\FormSubmitted;
use App\Models\Integration\FormIntegration;
use Exception;
class ZapierIntegration extends AbstractIntegrationHandler
{
public function __construct(
protected FormSubmitted $event,
protected FormIntegration $formIntegration,
protected array $integration
) {
parent::__construct($event, $formIntegration, $integration);
}
public static function getValidationRules(): array
{
return [];
}
public static function isOAuthRequired(): bool
{
return false;
}
protected function getWebhookUrl(): string
{
if (!isset($this->integrationData->hook_url)) {
throw new Exception('The webhook URL is missing');
}
return $this->integrationData->hook_url;
}
protected function shouldRun(): bool
{
return parent::shouldRun() && $this->getWebhookUrl();
}
}

View File

@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
@@ -16,6 +17,7 @@ class User extends Authenticatable implements JWTSubject
use Billable;
use HasFactory;
use Notifiable;
use HasApiTokens;
public const ROLE_ADMIN = 'admin';
public const ROLE_USER = 'user';

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Policies;
use App\Models\User;
use Laravel\Sanctum\PersonalAccessToken;
class PersonalAccessTokenPolicy
{
public function delete(User $user, PersonalAccessToken $token)
{
return $token->tokenable()->is($user);
}
}

View File

@@ -10,9 +10,11 @@ use App\Models\Workspace;
use App\Policies\FormPolicy;
use App\Policies\Integration\FormZapierWebhookPolicy;
use App\Policies\OAuthProviderPolicy;
use App\Policies\PersonalAccessTokenPolicy;
use App\Policies\TemplatePolicy;
use App\Policies\WorkspacePolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Sanctum\PersonalAccessToken;
class AuthServiceProvider extends ServiceProvider
{
@@ -27,6 +29,7 @@ class AuthServiceProvider extends ServiceProvider
FormZapierWebhook::class => FormZapierWebhookPolicy::class,
Template::class => TemplatePolicy::class,
OAuthProvider::class => OAuthProviderPolicy::class,
PersonalAccessToken::class => PersonalAccessTokenPolicy::class,
];
/**

View File

@@ -33,6 +33,10 @@ class RouteServiceProvider extends ServiceProvider
Route::middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::middleware('api-external')
->namespace($this->namespace)
->group(base_path('routes/api-external.php'));
});
}