From 0064cae9a06c3845fff479279859b839ba92661f Mon Sep 17 00:00:00 2001 From: Smaine Milianni Date: Thu, 13 Aug 2020 17:08:12 +0100 Subject: [PATCH] add Linkedin transport and option --- .../Resources/config/notifier_transports.php | 5 + .../Notifier/Bridge/LinkedIn/CHANGELOG.md | 7 + .../Bridge/LinkedIn/LinkedInOptions.php | 127 +++++++++++ .../Bridge/LinkedIn/LinkedInTransport.php | 115 ++++++++++ .../LinkedIn/LinkedInTransportFactory.php | 45 ++++ .../Notifier/Bridge/LinkedIn/README.md | 20 ++ .../LinkedIn/Share/AbstractLinkedInShare.php | 27 +++ .../Bridge/LinkedIn/Share/AuthorShare.php | 34 +++ .../LinkedIn/Share/LifecycleStateShare.php | 56 +++++ .../LinkedIn/Share/ShareContentShare.php | 84 ++++++++ .../Bridge/LinkedIn/Share/ShareMediaShare.php | 66 ++++++ .../Bridge/LinkedIn/Share/VisibilityShare.php | 58 +++++ .../Tests/LinkedInTransportFactoryTest.php | 50 +++++ .../LinkedIn/Tests/LinkedInTransportTest.php | 198 ++++++++++++++++++ .../Notifier/Bridge/LinkedIn/composer.json | 35 ++++ 15 files changed, 927 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index 222dec83ac..34255d79e5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -15,6 +15,7 @@ use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; use Symfony\Component\Notifier\Bridge\FreeMobile\FreeMobileTransportFactory; use Symfony\Component\Notifier\Bridge\GoogleChat\GoogleChatTransportFactory; use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; +use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; @@ -38,6 +39,10 @@ return static function (ContainerConfigurator $container) { ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.linkedin', LinkedInTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.telegram', TelegramTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md new file mode 100644 index 0000000000..0d994e934e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.2.0 +----- + + * Added the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php new file mode 100644 index 0000000000..7e915be886 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInOptions.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn; + +use Symfony\Component\Notifier\Bridge\LinkedIn\Share\AuthorShare; +use Symfony\Component\Notifier\Bridge\LinkedIn\Share\LifecycleStateShare; +use Symfony\Component\Notifier\Bridge\LinkedIn\Share\ShareContentShare; +use Symfony\Component\Notifier\Bridge\LinkedIn\Share\VisibilityShare; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; +use Symfony\Component\Notifier\Notification\Notification; + +/** + * @author Smaïne Milianni + * + * @experimental in 5.2 + */ +final class LinkedInOptions implements MessageOptionsInterface +{ + private $options = []; + + public function __construct(array $options = []) + { + $this->options = $options; + } + + public function toArray(): array + { + return $this->options; + } + + public function getRecipientId(): ?string + { + return null; + } + + public static function fromNotification(Notification $notification): self + { + $options = new self(); + $options->specificContent(new ShareContentShare($notification->getSubject())); + + if ($notification->getContent()) { + $options->specificContent(new ShareContentShare($notification->getContent())); + } + + $options->visibility(new VisibilityShare()); + $options->lifecycleState(new LifecycleStateShare()); + + return $options; + } + + public function contentCertificationRecord(string $contentCertificationRecord): self + { + $this->options['contentCertificationRecord'] = $contentCertificationRecord; + + return $this; + } + + public function firstPublishedAt(int $firstPublishedAt): self + { + $this->options['firstPublishedAt'] = $firstPublishedAt; + + return $this; + } + + public function lifecycleState(LifecycleStateShare $lifecycleStateOption): self + { + $this->options['lifecycleState'] = $lifecycleStateOption->lifecycleState(); + + return $this; + } + + public function origin(string $origin): self + { + $this->options['origin'] = $origin; + + return $this; + } + + public function ugcOrigin(string $ugcOrigin): self + { + $this->options['ugcOrigin'] = $ugcOrigin; + + return $this; + } + + public function versionTag(string $versionTag): self + { + $this->options['versionTag'] = $versionTag; + + return $this; + } + + public function specificContent(ShareContentShare $specificContent): self + { + $this->options['specificContent']['com.linkedin.ugc.ShareContent'] = $specificContent->toArray(); + + return $this; + } + + public function author(AuthorShare $authorOption): self + { + $this->options['author'] = $authorOption->author(); + + return $this; + } + + public function visibility(VisibilityShare $visibilityOption): self + { + $this->options['visibility'] = $visibilityOption->toArray(); + + return $this; + } + + public function getAuthor(): ?string + { + return $this->options['author'] ?? null; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php new file mode 100644 index 0000000000..4fbf958a7e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransport.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn; + +use Symfony\Component\Notifier\Bridge\LinkedIn\Share\AuthorShare; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Smaïne Milianni + * + * @experimental in 5.2 + * + * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#sharecontent + */ +final class LinkedInTransport extends AbstractTransport +{ + protected const PROTOCOL_VERSION = '2.0.0'; + protected const PROTOCOL_HEADER = 'X-Restli-Protocol-Version'; + protected const HOST = 'api.linkedin.com'; + + private $authToken; + private $accountId; + + public function __construct(string $authToken, string $accountId, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->authToken = $authToken; + $this->accountId = $accountId; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('linkedin://%s', $this->getEndpoint()); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof LinkedInOptions); + } + + /** + * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, \get_class($message))); + } + if ($message->getOptions() && !$message->getOptions() instanceof LinkedInOptions) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" for options.', __CLASS__, LinkedInOptions::class)); + } + + if (!($opts = $message->getOptions()) && $notification = $message->getNotification()) { + $opts = LinkedInOptions::fromNotification($notification); + $opts->author(new AuthorShare($this->accountId)); + } + + $endpoint = sprintf('https://%s/v2/ugcPosts', $this->getEndpoint()); + + $response = $this->client->request('POST', $endpoint, [ + 'auth_bearer' => $this->authToken, + 'headers' => [self::PROTOCOL_HEADER => self::PROTOCOL_VERSION], + 'json' => array_filter($opts ? $opts->toArray() : $this->bodyFromMessageWithNoOption($message)), + ]); + + if (201 !== $response->getStatusCode()) { + throw new TransportException(sprintf('Unable to post the Linkedin message: "%s".', $response->getContent(false)), $response); + } + + $result = $response->toArray(false); + + if (!$result['id']) { + throw new TransportException(sprintf('Unable to post the Linkedin message : "%s".', $result['error']), $response); + } + + return new SentMessage($message, (string) $this); + } + + private function bodyFromMessageWithNoOption(MessageInterface $message): array + { + return [ + 'specificContent' => [ + 'com.linkedin.ugc.ShareContent' => [ + 'shareCommentary' => [ + 'attributes' => [], + 'text' => $message->getSubject(), + ], + 'shareMediaCategory' => 'NONE', + ], + ], + 'visibility' => [ + 'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC', + ], + 'lifecycleState' => 'PUBLISHED', + 'author' => sprintf('urn:li:person:%s', $this->accountId), + ]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php new file mode 100644 index 0000000000..f13afa4723 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LinkedInTransportFactory.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Smaïne Milianni + * + * @experimental in 5.2 + */ +class LinkedInTransportFactory extends AbstractTransportFactory +{ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + $authToken = $this->getUser($dsn); + $accountId = $this->getPassword($dsn); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + if ('linkedin' === $scheme) { + return (new LinkedInTransport($authToken, $accountId, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + throw new UnsupportedSchemeException($dsn, 'linkedin', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['linkedin']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/README.md b/src/Symfony/Component/Notifier/Bridge/LinkedIn/README.md new file mode 100644 index 0000000000..67d30b863d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/README.md @@ -0,0 +1,20 @@ +LinkedIn Notifier +================= + +Provides LinkedIn integration for Symfony Notifier. + +DSN example +----------- + +``` +// .env file +LINKEDIN_DSN='linkedin://ACCESS_TOKEN:USER_ID@default' +``` + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php new file mode 100644 index 0000000000..6e6d2d5692 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AbstractLinkedInShare.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn\Share; + +/** + * @author Smaïne Milianni + * + * @experimental in 5.2 + */ +abstract class AbstractLinkedInShare +{ + protected $options = []; + + public function toArray(): array + { + return $this->options; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php new file mode 100644 index 0000000000..9161eb1b45 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/AuthorShare.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn\Share; + +/** + * @author Smaïne Milianni + * + * @experimental in 5.2 + */ +final class AuthorShare extends AbstractLinkedInShare +{ + public const PERSON = 'person'; + + private $author; + + public function __construct(string $value, string $organisation = self::PERSON) + { + $this->author = "urn:li:$organisation:$value"; + } + + public function author(): string + { + return $this->author; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php new file mode 100644 index 0000000000..98fd1a83e6 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/LifecycleStateShare.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn\Share; + +use Symfony\Component\Notifier\Exception\LogicException; + +/** + * @author Smaïne Milianni + * + * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#schema lifecycleState section + * + * @experimental in 5.2 + */ +final class LifecycleStateShare extends AbstractLinkedInShare +{ + public const DRAFT = 'DRAFT'; + public const PUBLISHED = 'PUBLISHED'; + public const PROCESSING = 'PROCESSING'; + public const PROCESSING_FAILED = 'PROCESSING_FAILED'; + public const DELETED = 'DELETED'; + public const PUBLISHED_EDITED = 'PUBLISHED_EDITED'; + + private const AVAILABLE_LIFECYCLE = [ + self::DRAFT, + self::PUBLISHED, + self::PROCESSING_FAILED, + self::DELETED, + self::PROCESSING_FAILED, + self::PUBLISHED_EDITED, + ]; + + private $lifecycleState; + + public function __construct(string $lifecycleState = self::PUBLISHED) + { + if (!\in_array($lifecycleState, self::AVAILABLE_LIFECYCLE)) { + throw new LogicException(sprintf('"%s" is not a valid value, available lifecycle are "%s".', $lifecycleState, implode(', ', self::AVAILABLE_LIFECYCLE))); + } + + $this->lifecycleState = $lifecycleState; + } + + public function lifecycleState(): string + { + return $this->lifecycleState; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php new file mode 100644 index 0000000000..2efbb08f65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareContentShare.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn\Share; + +use Symfony\Component\Notifier\Exception\LogicException; + +/** + * @author Smaïne Milianni + * + * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#sharecontent + * + * @experimental in 5.2 + */ +final class ShareContentShare extends AbstractLinkedInShare +{ + public const ARTICLE = 'ARTICLE'; + public const IMAGE = 'IMAGE'; + public const NONE = 'NONE'; + public const RICH = 'RICH'; + public const VIDEO = 'VIDEO'; + public const LEARNING_COURSE = 'LEARNING_COURSE'; + public const JOB = 'JOB'; + public const QUESTION = 'QUESTION'; + public const ANSWER = 'ANSWER'; + public const CAROUSEL = 'CAROUSEL'; + public const TOPIC = 'TOPIC'; + public const NATIVE_DOCUMENT = 'NATIVE_DOCUMENT'; + public const URN_REFERENCE = 'URN_REFERENCE'; + public const LIVE_VIDEO = 'LIVE_VIDEO'; + + public const ALL = [ + self::ARTICLE, + self::IMAGE, + self::NONE, + self::RICH, + self::VIDEO, + self::LEARNING_COURSE, + self::JOB, + self::QUESTION, + self::ANSWER, + self::CAROUSEL, + self::TOPIC, + self::NATIVE_DOCUMENT, + self::URN_REFERENCE, + self::LIVE_VIDEO, + ]; + + public function __construct(string $text, array $attributes = [], string $inferredLocale = null, ShareMediaShare $media = null, string $primaryLandingPageUrl = null, string $shareMediaCategory = self::NONE) + { + $this->options['shareCommentary'] = [ + 'attributes' => $attributes, + 'text' => $text, + ]; + + if (null !== $inferredLocale) { + $this->options['shareCommentary']['inferredLocale'] = $inferredLocale; + } + + if (null !== $media) { + $this->options['media'] = $media->toArray(); + } + + if (null !== $primaryLandingPageUrl) { + $this->options['primaryLandingPageUrl'] = $primaryLandingPageUrl; + } + + if ($shareMediaCategory) { + if (!\in_array($shareMediaCategory, self::ALL)) { + throw new LogicException(sprintf('"%s" is not valid option, available options are "%s".', $shareMediaCategory, implode(', ', self::ALL))); + } + + $this->options['shareMediaCategory'] = $shareMediaCategory; + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php new file mode 100644 index 0000000000..f277d13b6b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/ShareMediaShare.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn\Share; + +use Symfony\Component\Notifier\Exception\LogicException; + +/** + * @author Smaïne Milianni + * + * @see https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api#sharemedia + * + * @experimental in 5.2 + */ +class ShareMediaShare extends AbstractLinkedInShare +{ + public const LEARN_MORE = 'LEARN_MORE'; + public const APPLY_NOW = 'APPLY_NOW '; + public const DOWNLOAD = 'DOWNLOAD'; + public const GET_QUOTE = 'GET_QUOTE'; + public const SIGN_UP = 'SIGN_UP'; + public const SUBSCRIBE = 'SUBSCRIBE '; + public const REGISTER = 'REGISTER'; + + public const ALL = [ + self::LEARN_MORE, + self::APPLY_NOW, + self::DOWNLOAD, + self::GET_QUOTE, + self::SIGN_UP, + self::SUBSCRIBE, + self::REGISTER, + ]; + + public function __construct(string $text, array $attributes = [], string $inferredLocale = null, bool $landingPage = false, string $landingPageTitle = null, string $landingPageUrl = null) + { + $this->options['description'] = [ + 'text' => $text, + 'attributes' => $attributes, + ]; + + if ($inferredLocale) { + $this->options['description']['inferredLocale'] = $inferredLocale; + } + + if ($landingPage || $landingPageUrl) { + $this->options['landingPage']['landingPageUrl'] = $landingPageUrl; + } + + if (null !== $landingPageTitle) { + if (!\in_array($landingPageTitle, self::ALL)) { + throw new LogicException(sprintf('"%s" is not valid option, available options are "%s".', $landingPageTitle, implode(', ', self::ALL))); + } + + $this->options['landingPage']['landingPageTitle'] = $landingPageTitle; + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php new file mode 100644 index 0000000000..15883c5a3a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Share/VisibilityShare.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\LinkedIn\Share; + +use Symfony\Component\Notifier\Exception\LogicException; + +/** + * @author Smaïne Milianni + * + * @experimental in 5.2 + */ +final class VisibilityShare extends AbstractLinkedInShare +{ + public const MEMBER_NETWORK_VISIBILITY = 'MemberNetworkVisibility'; + public const SPONSORED_CONTENT_VISIBILITY = 'SponsoredContentVisibility'; + + public const CONNECTIONS = 'CONNECTIONS'; + public const PUBLIC = 'PUBLIC'; + public const LOGGED_IN = 'LOGGED_IN'; + public const DARK = 'DARK'; + + private const MEMBER_NETWORK = [ + self::CONNECTIONS, + self::PUBLIC, + self::LOGGED_IN, + ]; + + private const AVAILABLE_VISIBILITY = [ + self::MEMBER_NETWORK_VISIBILITY, + self::SPONSORED_CONTENT_VISIBILITY, + ]; + + public function __construct(string $visibility = self::MEMBER_NETWORK_VISIBILITY, string $value = 'PUBLIC') + { + if (!\in_array($visibility, self::AVAILABLE_VISIBILITY)) { + throw new LogicException(sprintf('"%s" is not a valid visibility, available visibility are "%s".', $visibility, implode(', ', self::AVAILABLE_VISIBILITY))); + } + + if (self::MEMBER_NETWORK_VISIBILITY === $visibility && !\in_array($value, self::MEMBER_NETWORK)) { + throw new LogicException(sprintf('"%s" is not a valid value, available value for visibility "%s" are "%s".', $value, $visibility, implode(', ', self::MEMBER_NETWORK))); + } + + if (self::SPONSORED_CONTENT_VISIBILITY === $visibility && self::DARK !== $value) { + throw new LogicException(sprintf('"%s" is not a valid value, available value for visibility "%s" is "%s".', $value, $visibility, self::DARK)); + } + + $this->options['com.linkedin.ugc.'.$visibility] = $value; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportFactoryTest.php new file mode 100644 index 0000000000..54373c7c90 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportFactoryTest.php @@ -0,0 +1,50 @@ +create(Dsn::fromString($dsn)); + $transport->setHost('testHost'); + + $this->assertSame('linkedin://testHost', (string) $transport); + } + + public function testSupportsLinkedinScheme(): void + { + $factory = new LinkedInTransportFactory(); + + $this->assertTrue($factory->supports(Dsn::fromString('linkedin://host/path'))); + $this->assertFalse($factory->supports(Dsn::fromString('somethingElse://host/path'))); + } + + public function testNonLinkedinSchemeThrows(): void + { + $factory = new LinkedInTransportFactory(); + + $this->expectException(UnsupportedSchemeException::class); + + $dsn = 'foo://login:pass@default'; + $factory->create(Dsn::fromString($dsn)); + } + + public function testIncompleteDsnMissingUserThrows(): void + { + $factory = new LinkedInTransportFactory(); + + $this->expectException(IncompleteDsnException::class); + + $factory->create(Dsn::fromString('somethingElse://host/path')); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportTest.php b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportTest.php new file mode 100644 index 0000000000..82ff0cde5d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/Tests/LinkedInTransportTest.php @@ -0,0 +1,198 @@ +assertSame(sprintf('linkedin://host.test'), (string) $this->getTransport()); + } + + public function testSupportsChatMessage(): void + { + $transport = $this->getTransport(); + + $this->assertTrue($transport->supports(new ChatMessage('testChatMessage'))); + $this->assertFalse($transport->supports($this->createMock(MessageInterface::class))); + } + + public function testSendNonChatMessageThrows(): void + { + $this->expectException(LogicException::class); + + $transport = $this->getTransport(); + + $transport->send($this->createMock(MessageInterface::class)); + } + + public function testSendWithEmptyArrayResponseThrows(): void + { + $this->expectException(TransportException::class); + + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(500); + $response->expects($this->once()) + ->method('getContent') + ->willReturn('[]'); + + $client = new MockHttpClient(static function () use ($response): ResponseInterface { + return $response; + }); + + $transport = $this->getTransport($client); + + $transport->send(new ChatMessage('testMessage')); + } + + public function testSendWithErrorResponseThrows(): void + { + $this->expectException(TransportException::class); + $this->expectExceptionMessage('testErrorCode'); + + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(400); + + $response->expects($this->once()) + ->method('getContent') + ->willReturn('testErrorCode'); + + $client = new MockHttpClient(static function () use ($response): ResponseInterface { + return $response; + }); + + $transport = $this->getTransport($client); + + $transport->send(new ChatMessage('testMessage')); + } + + public function testSendWithOptions(): void + { + $message = 'testMessage'; + + $response = $this->createMock(ResponseInterface::class); + + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(201); + + $response->expects($this->once()) + ->method('getContent') + ->willReturn(json_encode(['id' => '42'])); + + $expectedBody = json_encode([ + 'specificContent' => [ + 'com.linkedin.ugc.ShareContent' => [ + 'shareCommentary' => [ + 'attributes' => [], + 'text' => 'testMessage', + ], + 'shareMediaCategory' => 'NONE', + ], + ], + 'visibility' => [ + 'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC', + ], + 'lifecycleState' => 'PUBLISHED', + 'author' => 'urn:li:person:MyLogin', + ]); + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ( + $response, + $expectedBody + ): ResponseInterface { + $this->assertSame($expectedBody, $options['body']); + + return $response; + }); + $transport = $this->getTransport($client); + + $transport->send(new ChatMessage($message)); + } + + public function testSendWithNotification(): void + { + $message = 'testMessage'; + + $response = $this->createMock(ResponseInterface::class); + + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(201); + + $response->expects($this->once()) + ->method('getContent') + ->willReturn(json_encode(['id' => '42'])); + + $notification = new Notification($message); + $chatMessage = ChatMessage::fromNotification($notification); + + $expectedBody = json_encode([ + 'specificContent' => [ + 'com.linkedin.ugc.ShareContent' => [ + 'shareCommentary' => [ + 'attributes' => [], + 'text' => 'testMessage', + ], + 'shareMediaCategory' => 'NONE', + ], + ], + 'visibility' => [ + 'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC', + ], + 'lifecycleState' => 'PUBLISHED', + 'author' => 'urn:li:person:MyLogin', + ]); + + $client = new MockHttpClient(function (string $method, string $url, array $options = []) use ( + $response, + $expectedBody + ): ResponseInterface { + $this->assertSame($expectedBody, $options['body']); + + return $response; + }); + + $transport = $this->getTransport($client); + + $transport->send($chatMessage); + } + + public function testSendWithInvalidOptions(): void + { + $this->expectException(LogicException::class); + + $client = new MockHttpClient(function (string $method, string $url, array $options = []): ResponseInterface { + return $this->createMock(ResponseInterface::class); + }); + + $transport = $this->getTransport($client); + + $transport->send(new ChatMessage('testMessage', $this->createMock(MessageOptionsInterface::class))); + } + + public function getTransport($client = null) + { + return (new LinkedInTransport( + 'MyToken', + 'MyLogin', + $client ?? $this->createMock(HttpClientInterface::class) + ))->setHost('host.test'); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json b/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json new file mode 100644 index 0000000000..668336b88c --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/composer.json @@ -0,0 +1,35 @@ +{ + "name": "symfony/linkedin-notifier", + "type": "symfony-bridge", + "description": "Symfony LinkedIn Notifier Bridge", + "keywords": ["linkedin", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Smaïne Milianni", + "email": "smaine.milianni@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0", + "symfony/notifier": "^5.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\LinkedIn\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + } +}