atlassian-connect-core (empty) → 0.1.0.0
raw patch · 25 files changed
+1430/−0 lines, 25 filesdep +aesondep +atlassian-connect-descriptordep +basesetup-changed
Dependencies added: aeson, atlassian-connect-descriptor, base, base64-bytestring, bytestring, case-insensitive, cipher-aes, configurator, containers, cryptohash, hostname, http-client, http-media, http-types, jwt, mtl, network, network-api-support, network-uri, snap, snap-core, split, text, time, time-units, transformers
Files
- LICENSE +202/−0
- README.md +5/−0
- Setup.hs +2/−0
- atlassian-connect-core.cabal +128/−0
- resources/connect.cfg +13/−0
- src/Data/AesonHelpers.hs +37/−0
- src/Data/ConfigurationHelpers.hs +13/−0
- src/Data/EnvironmentHelpers.hs +21/−0
- src/Data/Padding.hs +20/−0
- src/Data/Padding/PKCS.hs +30/−0
- src/Data/Padding/Util.hs +10/−0
- src/Data/TimeUnitUTC.hs +16/−0
- src/Snap/AtlassianConnect.hs +78/−0
- src/Snap/AtlassianConnect/AtlassianTypes.hs +54/−0
- src/Snap/AtlassianConnect/Connect.hs +101/−0
- src/Snap/AtlassianConnect/Data.hs +39/−0
- src/Snap/AtlassianConnect/HostRequest.hs +152/−0
- src/Snap/AtlassianConnect/Instances.hs +21/−0
- src/Snap/AtlassianConnect/LifecycleResponse.hs +43/−0
- src/Snap/AtlassianConnect/PageToken.hs +100/−0
- src/Snap/AtlassianConnect/QueryStringHash.hs +121/−0
- src/Snap/AtlassianConnect/Routes.hs +99/−0
- src/Snap/AtlassianConnect/Tenant.hs +32/−0
- src/Snap/AtlassianConnect/TimeUnits.hs +23/−0
- src/Snap/Helpers.hs +70/−0
+ LICENSE view
@@ -0,0 +1,202 @@++ Apache License+ Version 2.0, January 2004+ http://www.apache.org/licenses/++ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION++ 1. Definitions.++ "License" shall mean the terms and conditions for use, reproduction,+ and distribution as defined by Sections 1 through 9 of this document.++ "Licensor" shall mean the copyright owner or entity authorized by+ the copyright owner that is granting the License.++ "Legal Entity" shall mean the union of the acting entity and all+ other entities that control, are controlled by, or are under common+ control with that entity. For the purposes of this definition,+ "control" means (i) the power, direct or indirect, to cause the+ direction or management of such entity, whether by contract or+ otherwise, or (ii) ownership of fifty percent (50%) or more of the+ outstanding shares, or (iii) beneficial ownership of such entity.++ "You" (or "Your") shall mean an individual or Legal Entity+ exercising permissions granted by this License.++ "Source" form shall mean the preferred form for making modifications,+ including but not limited to software source code, documentation+ source, and configuration files.++ "Object" form shall mean any form resulting from mechanical+ transformation or translation of a Source form, including but+ not limited to compiled object code, generated documentation,+ and conversions to other media types.++ "Work" shall mean the work of authorship, whether in Source or+ Object form, made available under the License, as indicated by a+ copyright notice that is included in or attached to the work+ (an example is provided in the Appendix below).++ "Derivative Works" shall mean any work, whether in Source or Object+ form, that is based on (or derived from) the Work and for which the+ editorial revisions, annotations, elaborations, or other modifications+ represent, as a whole, an original work of authorship. For the purposes+ of this License, Derivative Works shall not include works that remain+ separable from, or merely link (or bind by name) to the interfaces of,+ the Work and Derivative Works thereof.++ "Contribution" shall mean any work of authorship, including+ the original version of the Work and any modifications or additions+ to that Work or Derivative Works thereof, that is intentionally+ submitted to Licensor for inclusion in the Work by the copyright owner+ or by an individual or Legal Entity authorized to submit on behalf of+ the copyright owner. For the purposes of this definition, "submitted"+ means any form of electronic, verbal, or written communication sent+ to the Licensor or its representatives, including but not limited to+ communication on electronic mailing lists, source code control systems,+ and issue tracking systems that are managed by, or on behalf of, the+ Licensor for the purpose of discussing and improving the Work, but+ excluding communication that is conspicuously marked or otherwise+ designated in writing by the copyright owner as "Not a Contribution."++ "Contributor" shall mean Licensor and any individual or Legal Entity+ on behalf of whom a Contribution has been received by Licensor and+ subsequently incorporated within the Work.++ 2. Grant of Copyright License. Subject to the terms and conditions of+ this License, each Contributor hereby grants to You a perpetual,+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable+ copyright license to reproduce, prepare Derivative Works of,+ publicly display, publicly perform, sublicense, and distribute the+ Work and such Derivative Works in Source or Object form.++ 3. Grant of Patent License. Subject to the terms and conditions of+ this License, each Contributor hereby grants to You a perpetual,+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable+ (except as stated in this section) patent license to make, have made,+ use, offer to sell, sell, import, and otherwise transfer the Work,+ where such license applies only to those patent claims licensable+ by such Contributor that are necessarily infringed by their+ Contribution(s) alone or by combination of their Contribution(s)+ with the Work to which such Contribution(s) was submitted. If You+ institute patent litigation against any entity (including a+ cross-claim or counterclaim in a lawsuit) alleging that the Work+ or a Contribution incorporated within the Work constitutes direct+ or contributory patent infringement, then any patent licenses+ granted to You under this License for that Work shall terminate+ as of the date such litigation is filed.++ 4. Redistribution. You may reproduce and distribute copies of the+ Work or Derivative Works thereof in any medium, with or without+ modifications, and in Source or Object form, provided that You+ meet the following conditions:++ (a) You must give any other recipients of the Work or+ Derivative Works a copy of this License; and++ (b) You must cause any modified files to carry prominent notices+ stating that You changed the files; and++ (c) You must retain, in the Source form of any Derivative Works+ that You distribute, all copyright, patent, trademark, and+ attribution notices from the Source form of the Work,+ excluding those notices that do not pertain to any part of+ the Derivative Works; and++ (d) If the Work includes a "NOTICE" text file as part of its+ distribution, then any Derivative Works that You distribute must+ include a readable copy of the attribution notices contained+ within such NOTICE file, excluding those notices that do not+ pertain to any part of the Derivative Works, in at least one+ of the following places: within a NOTICE text file distributed+ as part of the Derivative Works; within the Source form or+ documentation, if provided along with the Derivative Works; or,+ within a display generated by the Derivative Works, if and+ wherever such third-party notices normally appear. The contents+ of the NOTICE file are for informational purposes only and+ do not modify the License. You may add Your own attribution+ notices within Derivative Works that You distribute, alongside+ or as an addendum to the NOTICE text from the Work, provided+ that such additional attribution notices cannot be construed+ as modifying the License.++ You may add Your own copyright statement to Your modifications and+ may provide additional or different license terms and conditions+ for use, reproduction, or distribution of Your modifications, or+ for any such Derivative Works as a whole, provided Your use,+ reproduction, and distribution of the Work otherwise complies with+ the conditions stated in this License.++ 5. Submission of Contributions. Unless You explicitly state otherwise,+ any Contribution intentionally submitted for inclusion in the Work+ by You to the Licensor shall be under the terms and conditions of+ this License, without any additional terms or conditions.+ Notwithstanding the above, nothing herein shall supersede or modify+ the terms of any separate license agreement you may have executed+ with Licensor regarding such Contributions.++ 6. Trademarks. This License does not grant permission to use the trade+ names, trademarks, service marks, or product names of the Licensor,+ except as required for reasonable and customary use in describing the+ origin of the Work and reproducing the content of the NOTICE file.++ 7. Disclaimer of Warranty. Unless required by applicable law or+ agreed to in writing, Licensor provides the Work (and each+ Contributor provides its Contributions) on an "AS IS" BASIS,+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or+ implied, including, without limitation, any warranties or conditions+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A+ PARTICULAR PURPOSE. You are solely responsible for determining the+ appropriateness of using or redistributing the Work and assume any+ risks associated with Your exercise of permissions under this License.++ 8. Limitation of Liability. In no event and under no legal theory,+ whether in tort (including negligence), contract, or otherwise,+ unless required by applicable law (such as deliberate and grossly+ negligent acts) or agreed to in writing, shall any Contributor be+ liable to You for damages, including any direct, indirect, special,+ incidental, or consequential damages of any character arising as a+ result of this License or out of the use or inability to use the+ Work (including but not limited to damages for loss of goodwill,+ work stoppage, computer failure or malfunction, or any and all+ other commercial damages or losses), even if such Contributor+ has been advised of the possibility of such damages.++ 9. Accepting Warranty or Additional Liability. While redistributing+ the Work or Derivative Works thereof, You may choose to offer,+ and charge a fee for, acceptance of support, warranty, indemnity,+ or other liability obligations and/or rights consistent with this+ License. However, in accepting such obligations, You may act only+ on Your own behalf and on Your sole responsibility, not on behalf+ of any other Contributor, and only if You agree to indemnify,+ defend, and hold each Contributor harmless for any liability+ incurred by, or claims asserted against, such Contributor by reason+ of your accepting any such warranty or additional liability.++ END OF TERMS AND CONDITIONS++ APPENDIX: How to apply the Apache License to your work.++ To apply the Apache License to your work, attach the following+ boilerplate notice, with the fields enclosed by brackets "[]"+ replaced with your own identifying information. (Don't include+ the brackets!) The text should be enclosed in the appropriate+ comment syntax for the file format. We also recommend that a+ file or class name and description of purpose be included on the+ same "printed page" as the copyright notice for easier+ identification within third-party archives.++ Copyright [yyyy] [name of copyright owner]++ Licensed under the Apache License, Version 2.0 (the "License");+ you may not use this file except in compliance with the License.+ You may obtain a copy of the License at++ http://www.apache.org/licenses/LICENSE-2.0++ Unless required by applicable law or agreed to in writing, software+ distributed under the License is distributed on an "AS IS" BASIS,+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+ See the License for the specific language governing permissions and+ limitations under the License.
+ README.md view
@@ -0,0 +1,5 @@+# Atlassian Connect Core (for Haskell)++This is a library that provides Atlassian Connect functionality for applications built on top of the+Snap Framework. We provide a snaplet so that you can integrate Atlassian Connect into your+applications.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ atlassian-connect-core.cabal view
@@ -0,0 +1,128 @@+-- Initial atlassian-connect-core.cabal generated by cabal init. For +-- further documentation, see http://haskell.org/cabal/users-guide/++-- The name of the package.+name: atlassian-connect-core++-- The package version. See the Haskell package versioning policy (PVP) +-- for standards guiding when and how versions should be incremented.+-- http://www.haskell.org/haskellwiki/Package_versioning_policy+-- PVP summary: +-+------- breaking API changes+-- | | +----- non-breaking API additions+-- | | | +--- code changes with no API change+version: 0.1.0.0++-- A short (one-line) description of the package.+synopsis: Atlassian Connect snaplet for the Snap Framework and helper code.++-- A longer description of the package.+description: This library allows you to quickly get an Atlassian Connect application running+ on top of the Snap Framework. It provides best practices and helper functions to get you moving+ quickly. Please read the rest of the documentation for more details.++homepage: https://bitbucket.org/ajknoll/atlassian-connect-core++-- The license under which the package is released.+license: Apache-2.0++-- The file containing the license text.+license-file: LICENSE++-- The package author(s).+author: Robert Massaioli++-- An email address to which users can send suggestions, bug reports, and +-- patches.+maintainer: rmassaioli@atlassian.com++-- A copyright notice.+copyright: (c) 2014 Robert Massaioli, Avi Knoll, Eero Kaukonen++category: Web, Snap++build-type: Simple++-- Extra files to be distributed with the package, such as examples or a +-- README.+extra-source-files: README.md++data-files: resources/connect.cfg++-- Constraint on the version of Cabal needed to build this package.+cabal-version: >=1.10++source-repository head+ type: git+ location: git@bitbucket.org:ajknoll/atlassian-connect-core.git++flag network-uri+ description: Get Network.URI from the network-uri package+ default: True++library+ -- Modules exported by the library.+ -- TODO need a better name for this+ -- TODO do page tokens even belong here? Should they even be a separate library per service?+ exposed-modules: Snap.AtlassianConnect+ , Snap.AtlassianConnect.HostRequest++ -- Modules included in this library but not exported.+ other-modules: Data.AesonHelpers+ , Data.ConfigurationHelpers+ , Data.EnvironmentHelpers+ , Data.Padding+ , Data.Padding.PKCS+ , Data.Padding.Util+ , Data.TimeUnitUTC+ , Paths_atlassian_connect_core+ , Snap.AtlassianConnect.Connect+ , Snap.AtlassianConnect.AtlassianTypes+ , Snap.AtlassianConnect.Data+ , Snap.AtlassianConnect.Instances+ , Snap.AtlassianConnect.LifecycleResponse+ , Snap.AtlassianConnect.PageToken+ , Snap.AtlassianConnect.QueryStringHash+ , Snap.AtlassianConnect.Routes+ , Snap.AtlassianConnect.Tenant+ , Snap.AtlassianConnect.TimeUnits+ , Snap.Helpers+ + -- LANGUAGE extensions used by modules in this package.+ -- other-extensions: + + -- Other library packages from which modules are imported.+ build-depends: base >=4.7 && <4.8+ , atlassian-connect-descriptor >= 0.2+ , jwt >= 0.3 && < 0.5+ , aeson > 0.7.0.3 && < 0.9+ , bytestring >= 0.9 && <= 0.11+ , base64-bytestring == 1.0.*+ , case-insensitive == 1.*+ , cipher-aes == 0.2.*+ , configurator == 0.2.*+ , containers == 0.5.*+ , hostname == 1.*+ , http-media == 0.4.*+ , mtl == 2.*+ , snap >= 0.13.3 && < 0.14+ , snap-core >= 0.9 && < 0.10+ , split == 0.2.*+ , text >= 0.11 && < 1.3+ , time >= 1.1 && < 1.5+ , time-units == 1.*+ , transformers >= 0.3 && < 0.5+ , http-client == 0.3.*+ , http-types == 0.8.*+ , network-api-support == 0.1.*+ , cryptohash == 0.11.*++ if flag(network-uri)+ build-depends: network-uri >= 2.6, network >= 2.6+ else+ build-depends: network-uri < 2.6, network < 2.6+ + -- Directories containing source files.+ hs-source-dirs: src+ + -- Base language which the package is written in.+ default-language: Haskell2010
+ resources/connect.cfg view
@@ -0,0 +1,13 @@+# Plugin Details+plugin_name = "My Atlassian Addon"+plugin_key = "com.myorgname.myaddonname"+base_url = "http://localhost:8080"++# Secret Key - Currently only used for page tokens. Please keep secure.+# A good way to generate a secret key is to run the following in your terminal:+# $ dd if=/dev/urandom bs=1000 count=1 2> /dev/null | md5+secret_key = "replaceme14daebcf59d7252692be9ab"++# How long until the page token times out. You will have to choose a setting+# appropriate for your application.+page_token_timeout_seconds = 32400 # ==> 9 hours => 9 * 60 * 60
+ src/Data/AesonHelpers.hs view
@@ -0,0 +1,37 @@+module Data.AesonHelpers+ ( baseOptions+ , stripFieldNamePrefix+ , dropAndSnakeCase+ ) where++import Data.Aeson.Types+import qualified Data.Char as C+import qualified Data.List as L+import Data.Maybe (fromMaybe)++baseOptions :: Options+baseOptions = defaultOptions+ { omitNothingFields = True+ }++stripFieldNamePrefix :: String -> String -> String+stripFieldNamePrefix pre = lowerFirst . try (L.stripPrefix pre)++dropAndSnakeCase :: String -> String -> String+dropAndSnakeCase toDrop = camelToSnakeCase . dropCondition toDrop . lowerFirst++lowerFirst :: String -> String+lowerFirst (x : xs) = C.toLower x : xs+lowerFirst [] = []++camelToSnakeCase :: String -> String+camelToSnakeCase input = case L.break C.isUpper input of+ (first, []) -> first+ (first, x : xs) -> first ++ ('_' : C.toLower x : camelToSnakeCase xs)++dropCondition :: String -> String -> String+dropCondition toDrop = L.reverse . try (L.stripPrefix . L.reverse $ toDrop) . L.reverse++try :: (a -> Maybe a) -> a -> a+try f v = fromMaybe v (f v)+
+ src/Data/ConfigurationHelpers.hs view
@@ -0,0 +1,13 @@+module Data.ConfigurationHelpers+ ( require+ ) where++import qualified Data.Configurator as DC+import qualified Data.Configurator.Types as DCT++require :: DCT.Configured a => DCT.Config -> DCT.Name -> String -> IO a+require config name errorMessage = do+ potentialValue <- DC.lookup config name+ case potentialValue of+ Nothing -> fail errorMessage+ Just x -> return x
+ src/Data/EnvironmentHelpers.hs view
@@ -0,0 +1,21 @@+module Data.EnvironmentHelpers+ ( getEnv+ , getEnvWithDefault+ , showMaybe+ ) where++import Data.List (find)+import Data.Maybe (fromMaybe)+import qualified System.Environment as E++getEnv :: String -> IO (Maybe String)+getEnv key = do+ env <- E.getEnvironment+ return . fmap snd . find ((==) key . fst) $ env++getEnvWithDefault :: String -> String -> IO String+getEnvWithDefault def = fmap (fromMaybe def) . getEnv++showMaybe :: Show a => Maybe a -> String+showMaybe Nothing = "[Not Available]"+showMaybe (Just x) = show x
+ src/Data/Padding.hs view
@@ -0,0 +1,20 @@+module Data.Padding+ ( zeroPad+ , zeroUnpad+ ) where++import Data.Padding.Util (requiredPadding)++import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as BC+import Data.Char (chr)++zeroPad :: Int -> B.ByteString -> B.ByteString+zeroPad boundary input = input `B.append` padding+ where+ padding = BC.replicate padLength (chr 0)+ padLength = requiredPadding inputLength boundary+ inputLength = B.length input++zeroUnpad :: B.ByteString -> B.ByteString+zeroUnpad = fst . BC.spanEnd (== chr 0)
+ src/Data/Padding/PKCS.hs view
@@ -0,0 +1,30 @@+-- TODO Replace with with the Crypto package+module Data.Padding.PKCS where++import Data.Padding.Util (requiredPadding)++import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as BC+import Data.Char (chr)+import Data.Word (Word8)++pkcs7Pad :: Word8 -> B.ByteString -> B.ByteString+pkcs7Pad blockBoundary input = input `B.append` padding+ where+ padding = BC.replicate paddingLength padCharacter+ padCharacter = chr . fromIntegral $ paddingLength+ paddingLength = requiredPadding inputLength (fromIntegral blockBoundary)+ inputLength = B.length input++pkcs7Unpad :: Word8 -> B.ByteString -> Maybe B.ByteString+pkcs7Unpad blockBoundary input = if validLength+ then Just (if lastChar >= blockBoundary then input else removedPadding)+ else Nothing+ where+ removedPadding = if B.length padding == fromIntegral lastChar+ then potentialGoodData+ else input+ (potentialGoodData, padding) = B.spanEnd (== lastChar) input+ inputLength = B.length input+ lastChar = B.last input+ validLength = inputLength `mod` fromIntegral blockBoundary == 0
+ src/Data/Padding/Util.hs view
@@ -0,0 +1,10 @@+module Data.Padding.Util+ ( requiredPadding+ ) where++requiredPadding :: Int -> Int -> Int+requiredPadding length' boundary =+ case length' `mod` boundary of+ 0 -> 0+ x -> boundary - x+
+ src/Data/TimeUnitUTC.hs view
@@ -0,0 +1,16 @@+module Data.TimeUnitUTC + ( timeUnitToDiffTime+ , diffTimeToTimeUnit+ ) where++import Data.Time.Units+import Data.Time.Clock++timeUnitToDiffTime :: TimeUnit a => a -> NominalDiffTime+timeUnitToDiffTime t = fromInteger $ toMicroseconds t `div` microsecondsPerSecond++diffTimeToTimeUnit :: NominalDiffTime -> Second+diffTimeToTimeUnit t = fromMicroseconds . floor $ t * fromInteger microsecondsPerSecond++microsecondsPerSecond :: Num a => a+microsecondsPerSecond = 10 ^ (6 :: Integer)
+ src/Snap/AtlassianConnect.hs view
@@ -0,0 +1,78 @@+{-|+Module : Snap.AtlassianConnect+Description : Defines a snaplet for the Atlassian Connect framework.+Copyright : (c) Robert Massioli, 2014+License : APACHE-2+Maintainer : rmassaioli@atlassian.com+Stability : experimental++This module provides an Snap Framework Snaplet for the Atlassian Connect framework. In order to use it in your+snap application you just need to run initConnectSnaplet in your initialiser like so:++> appConnect <- nestSnaplet "connect" connect (initConnectSnaplet myPluginDescriptor)++All you need to provide us with is a descriptor that you have generated using the+<http://hackage.haskell.org/package/atlassian-connect-descriptor atlassian-connect-descriptor library>.++It is important to note that certain configuration values can be overriden with Environment Variables because they may+be specific to the environment that you deployed the service to:++* Use CONNECT_BASE_URL to override the Base Url in the Addon Descriptor.+* Use CONNECT_SECRET_KEY to override the Page Token Secret Key.++To test that it has worked you can start your application and find your descriptor at: \/connect\/atlassian-connect.json++We will also automatically create a connect.cfg file for you in the snaplets/connect directory. If this does not happen+then delete the snaplets/connect directory and restart your application. Please have a look inside the connect.cfg directory+as there will be plugin specific setting that you will have to override. It is likely that you will want to override all+of those configuration options.+-}+module Snap.AtlassianConnect (+ -- * Connect Snaplet+ initConnectSnaplet+ , Connect(..)+ , HasConnect(..)+ , HostName+ -- * Atlassian Standard Types+ , UserKey+ , UserEmail+ , IssueKey+ , IssueId+ , ProjectKey+ , ProjectId+ , IssueSummary+ , UserDetails(..)+ , IssueDetails(..)+ -- * Lifecycle and Tenant+ , getLifecycleResponse+ , LifecycleResponse(..)+ , ClientKey+ , Tenant(..)+ , TenantKey+ , TenantWithUser+ -- * Page Tokens+ , PageToken(..) -- TODO make it so that you can query the token but nothing else+ , generateToken+ , generateTokenCurrentTime+ , encryptPageToken+ , decryptPageToken+ , defaultTimeoutSeconds+ -- * Routes Helpers+ , homeHandler+ -- * Connect Data Types+ , ConnectURI(..)+ , getURI+ , ConnectSecond(..)+ , getSecond+ ) where++import Snap.AtlassianConnect.AtlassianTypes+import Snap.AtlassianConnect.Connect+import Snap.AtlassianConnect.Data+import Snap.AtlassianConnect.Instances+import Snap.AtlassianConnect.LifecycleResponse+import Snap.AtlassianConnect.PageToken+import Snap.AtlassianConnect.Routes+import Snap.AtlassianConnect.Tenant+import Snap.AtlassianConnect.TimeUnits+
+ src/Snap/AtlassianConnect/AtlassianTypes.hs view
@@ -0,0 +1,54 @@+{-# LANGUAGE DeriveGeneric #-}++module Snap.AtlassianConnect.AtlassianTypes+ ( UserKey+ , UserEmail+ , IssueKey+ , IssueId+ , ProjectId+ , ProjectKey+ , IssueSummary+ , UserDetails(..)+ , IssueDetails(..)+ ) where++import qualified Data.ByteString as B+import qualified Data.Text as T+import GHC.Generics++-- | Represents an Atlassian User Key. This is guaranteed to be unique on any given tenant.+type UserKey = T.Text++-- | Represents an Atlassian User Email. This has no uniqueness guarantees; an email does not map to a unique user.+type UserEmail = B.ByteString -- TODO use a standard email type++-- | The Key of an Atlassian JIRA issue. They are usually in the format <ProjectKey>-<IssueNumber>. Please note+-- that issues can be moved between projects so this is not guaranteed to remain constant.+type IssueKey = T.Text++-- | The ID of an Atlassian JIRA issue. This id will never change and will always refer to the same issue. It+-- is an immutable reference to a JIRA issue.+type IssueId = Integer++-- | The user visible key of an Atlassian JIRA Project. Project Keys are not guaranteed to remain the same forever.+type ProjectKey = T.Text++-- | The project id of an Atlassian JIRA Project. The same project will always have the same project id. The project+-- id is immutable for a given project.+type ProjectId = Integer++-- | Represents an Atlassian JIRA Issue summary.+type IssueSummary = T.Text++-- | Represents the user details of an Atlassian JIRA user.+data UserDetails = UserDetails+ { userKey :: UserKey+ , userEmail :: UserEmail+ } deriving (Show, Generic)++-- | Represents simple issue details that every Atlassian JIRA issue will have.+data IssueDetails = IssueDetails+ { issueKey :: IssueKey+ , issueId :: IssueId+ , issueSummary :: IssueSummary+ } deriving (Show, Generic)
+ src/Snap/AtlassianConnect/Connect.hs view
@@ -0,0 +1,101 @@+{-# LANGUAGE OverloadedStrings #-}++module Snap.AtlassianConnect.Connect+ ( initConnectSnaplet+ ) where++-- Data file imports+import qualified Paths_atlassian_connect_core as PACC++-- Helper Imports+import Data.ConfigurationHelpers++-- Atlassian Connect Imports+import qualified Data.Connect.Descriptor as D+import Snap.AtlassianConnect.Data+import qualified Snap.AtlassianConnect.PageToken as PT+import qualified Snap.AtlassianConnect.Routes as CR+import qualified Snap.AtlassianConnect.TimeUnits as TU++-- Standard imports+import qualified Control.Monad as CM+import qualified Control.Monad.IO.Class as MI+import qualified Crypto.Cipher.AES as CCA+import qualified Data.ByteString.Char8 as BSC+import qualified Data.Configurator as DC+import qualified Data.Configurator.Types as DCT+import qualified Data.EnvironmentHelpers as DE+import Data.Maybe (fromMaybe)+import Data.Text+import Data.Time.Units+import qualified Network.HostName as HN+import qualified Network.URI as NU+import qualified Snap.Snaplet as SS++-- | Initialise the Atlassian Connect part of this application. This gives you an Atlassian Connect configuration that+-- comes with:+--+-- * An Atlassian Connect configuration file.+-- * The definition of what it means to be an Atlassian Connect Tenant.+-- * Default routes for your Atlassian Connect Descriptor. (With automatic configuration injection of Plugin Keys / Names)+-- * Page Token support+-- * Host Whitelist Support+--+-- In short, it is the easy way to get up and running with Atlassian Connect in your Snap applications.+initConnectSnaplet :: D.Plugin -> SS.SnapletInit b Connect+initConnectSnaplet plugin = SS.makeSnaplet "connect" "Atlassian Connect" (Just dataDir) $ do+ SS.addRoutes CR.connectRoutes+ configurationDirectory <- SS.getSnapletFilePath+ MI.liftIO . fmap (toConnect plugin) $ SS.loadAppConfig "connect.cfg" configurationDirectory >>= loadConnectConfig++dataDir :: IO FilePath+dataDir = CM.liftM (++"/resources") PACC.getDataDir++data ConnectConfig = ConnectConfig+ { ccSecretKey :: BSC.ByteString+ , ccPluginName :: Text+ , ccPluginKey :: Text+ , ccBaseUrl :: NU.URI+ , ccPageTokenTimeout :: TU.ConnectSecond+ , ccHostWhiteList :: [Text] -- TODO This host whitelist should be loaded from configuration+ }++toConnect :: D.Plugin -> ConnectConfig -> Connect+toConnect plugin conf = Connect+ { connectPlugin = plugin+ , connectAES = CCA.initAES $ ccSecretKey conf+ , connectPluginName = D.Name $ ccPluginName conf+ , connectPluginKey = D.PluginKey $ ccPluginKey conf+ , connectBaseUrl = ccBaseUrl conf+ , connectPageTokenTimeout = D.Timeout . TU.getSecond . ccPageTokenTimeout $ conf+ , connectHostWhitelist = ccHostWhiteList conf+ }++loadConnectConfig :: DCT.Config -> IO ConnectConfig+loadConnectConfig connectConf = do+ name <- require connectConf "plugin_name" "Missing plugin name in connect configuration file."+ key <- require connectConf "plugin_key" "Missing plugin key in connect configuration file."+ rawBaseUrl <- require connectConf "base_url" "Missing base url in connect configuration file."+ secret <- require connectConf "secret_key" "Missing secret key in connect configuration file."+ hostWhiteList <- validHosts+ let keyLength = BSC.length secret+ CM.when (keyLength /= 32) $ fail ("Expected Atlassian Connect secret_key to be 32 Hex Digits long but was actually: " ++ show keyLength)+ envBaseUrl <- DE.getEnv "CONNECT_BASE_URL"+ envSecretKey <- DE.getEnv "CONNECT_SECRET_KEY"+ let baseUrlToParse = fromMaybe rawBaseUrl envBaseUrl+ case NU.parseURI baseUrlToParse of+ Nothing -> fail("Could not parse the baseUrl in the configuration file: " ++ rawBaseUrl)+ Just baseUrl -> do+ pageTokenTimeoutInSeconds <- DC.lookupDefault PT.defaultTimeoutSeconds connectConf "page_token_timeout_seconds"+ return ConnectConfig+ { ccPluginName = name+ , ccPluginKey = key+ , ccBaseUrl = baseUrl+ , ccSecretKey = maybe secret BSC.pack envSecretKey+ , ccPageTokenTimeout = pageTokenTimeoutInSeconds+ , ccHostWhiteList = hostWhiteList+ }++validHosts :: IO[Text]+validHosts = fmap hosts HN.getHostName+ where hosts localhost = fmap pack (localhost : ["localhost", "jira-dev.com", "jira.com", "atlassian.net"])
+ src/Snap/AtlassianConnect/Data.hs view
@@ -0,0 +1,39 @@+module Snap.AtlassianConnect.Data+ ( Connect(..)+ , HasConnect(..)+ , HostName+ , DynamicDescriptorConfig(..)+ ) where++-- Connect Modules+import Data.Connect.Descriptor++-- Standard Modules+import qualified Crypto.Cipher.AES as CCA+import qualified Data.Text as T+import qualified Network.URI as NU++-- | The Atlassian Connect Snaplet Context+data Connect = Connect+ { connectAES :: CCA.AES -- ^ The AES key that will be used for Page Token support.+ , connectPageTokenTimeout :: Timeout -- ^ The Page Token Timeout (how long the token is valid for).+ , connectPlugin :: Plugin -- ^ The Atlassian Connect Descriptor for your Add-on.+ , connectPluginName :: Name Connect -- ^ The Name of your Atlassian Connect Add-on loaded from configuration.+ , connectPluginKey :: PluginKey -- ^ The Key of your Atlassian Connect Add-on loaded from configuration.+ , connectBaseUrl :: NU.URI -- ^ The URL that your Add-on will report as it's base URL. Especially useful if you are behind a load balancer.+ , connectHostWhitelist :: [HostName] -- ^ The suffixes in the hostname that should be allowed to be installed as Connect Add-ons.+ }++-- | Represents a URL hostname+type HostName = T.Text++-- | If a Snap application provides Atlassian Connect then it can impliment HasConnect to allow connect methods to be+-- called easily.+class HasConnect m where+ getConnect :: m Connect++data DynamicDescriptorConfig = DynamicDescriptorConfig+ { dcPluginName :: Name Plugin+ , dcPluginKey :: PluginKey+ , dcBaseUrl :: NU.URI+ }
+ src/Snap/AtlassianConnect/HostRequest.hs view
@@ -0,0 +1,152 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}++{-|+Module : Snap.AtlassianConnect.HostRequest+Description : Allows you to easily make requests from your Atlassian Connect addon to the Host Application (like Jira or Confluence).+Copyright : (c) Robert Massioli, 2014+License : APACHE-2+Maintainer : rmassaioli@atlassian.com+Stability : experimental++While writing Atlassian Connect applications you will often be in a situation where you want to query the Host Application+directly in a trusted way. This may be because:++* You are not making this request on behalf of a user. You are making it on behalf of your Addon. (Data ingestion etc.)+* You cannot trust the information that the Customer has given you. For example: don't trust the customer to tell you if they are an admin or not. Go directly to the Host application for that.++This module allows you to make HTTP requests to the host application and expect JSON responses. There are convinience+methods for GET, POST and PUT requests which make up the majority of the requests that you are going to make.++It is important to note that these requests are still restricted by the+<https://developer.atlassian.com/static/connect/docs/scopes/scopes.html scopes> that you have asked for in your+Atlassian Connect Descriptor.++For an example of usage: look at the httpGetRequest function.+-}+module Snap.AtlassianConnect.HostRequest (+ -- * Host (Product) Request Helpers+ hostRequest+ , hostGetRequest+ , hostPostRequest+ , hostPutRequest+ , StdMethod(..)+ , ProductErrorResponse(..)+ -- * Request Modifiers+ , addHeader+ , setPostParams+ , setBody+ , setBodyLazy+ , setJson+ ) where++import qualified Control.Monad.IO.Class as MI+import Control.Monad.State (get)+import Data.Aeson+import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as BC+import qualified Data.ByteString.Lazy as BL+import Data.Connect.Descriptor as CD+import qualified Data.Map as M+import Data.Monoid+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Time.Clock.POSIX as P+import Data.Time.Units (Minute)+import Data.TimeUnitUTC+import GHC.Generics+import Network.Api.Support+import Network.HTTP.Client+import Network.HTTP.Types+import Network.URI+import qualified Snap.AtlassianConnect.Data as AC+import qualified Snap.AtlassianConnect.Instances as AC+import qualified Snap.AtlassianConnect.QueryStringHash as QSH+import qualified Snap.AtlassianConnect.Tenant as AC+import qualified Snap.Snaplet as SS+import qualified Web.JWT as JWT++type HttpResponseCode = Int++-- | If you fail to properly communicate with the Host application then you will get a product error response back.+data ProductErrorResponse = ProductErrorResponse+ { perCode :: HttpResponseCode -- ^ The HTTP error code.+ , perMessage :: T.Text -- ^ The error message.+ } deriving (Show, Generic)++-- | This is a convinience method that calls 'hostRequest' as a GET method.+--+-- Here is an example of it being used to make a call for user details:+--+-- > jiraUserDetailsResponse <- hostGetRequest myConnectTenant "/rest/api/2/user" [("username", Just "admin")] mempty+--+-- This example assumes that you are using the OverloadedStrings LANGUAGE pragma and that you don't need to modify the request.+-- You might want to might want to modify the request for multiple reasons. Too add proxy details for example.+hostGetRequest :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostGetRequest = hostRequest GET++-- | This is a convinience method that calls 'hostRequest' as a POST method.+hostPostRequest :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostPostRequest = hostRequest POST++-- | This is a convinience method that calls 'hostRequest' as a PUT method.+hostPutRequest :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostPutRequest = hostRequest PUT++-- | As an Atlassian Connect application you will want to make requests of the Host Applicaiton (JIRA / Confluence)+-- so that you can get important information. This function lets you do so by doing most of the heavy lifting of+-- having to create a JWT token and a Query String Hash. It also asserts that you intended to get a JSON response.+-- You should use this method or the helper methods whenever you want to make requests of the host application.+hostRequest :: FromJSON a => StdMethod -> AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostRequest standardHttpMethod tenant productRelativeUrl queryParams requestModifications = do+ currentTime <- MI.liftIO P.getPOSIXTime+ pluginKey <- fmap AC.connectPluginKey get+ case generateJWTToken pluginKey currentTime (AC.sharedSecret tenant) standardHttpMethod productBaseUrl url of+ Nothing -> return . Left $ ProductErrorResponse 500 "Failed to generate a JWT token to make the request. The request was never made: server error."+ (Just signature) -> MI.liftIO $ runRequest defaultManagerSettings standardHttpMethod url+ ( addHeader ("Accept","application/json")+ <> addHeader ("Authorization", jwtPrefix `B.append` T.encodeUtf8 signature)+ <> requestModifications+ )+ (basicResponder responder)+ where+ jwtPrefix :: B.ByteString+ jwtPrefix = BC.pack "JWT "++ url = T.decodeUtf8 $ BC.pack productBaseUrlString `B.append` productRelativeUrl `B.append` renderQuery True queryParams+ productBaseUrlString = show productBaseUrl+ productBaseUrl = AC.getURI . AC.baseUrl $ tenant++generateJWTToken :: CD.PluginKey -> P.POSIXTime -> T.Text -> StdMethod -> URI -> T.Text -> Maybe T.Text+generateJWTToken pluginKey fromTime sharedSecret' method' ourURL requestURL = do+ queryStringHash <- QSH.createQueryStringHash method' ourURL requestURL+ return $ JWT.encodeSigned JWT.HS256 (JWT.secret sharedSecret') (createClaims pluginKey fromTime queryStringHash)++createClaims :: CD.PluginKey -> P.POSIXTime -> T.Text -> JWT.JWTClaimsSet+createClaims (CD.PluginKey pluginKey) fromTime queryStringHash = JWT.JWTClaimsSet+ { JWT.iss = JWT.stringOrURI pluginKey+ , JWT.iat = JWT.intDate fromTime+ , JWT.exp = JWT.intDate expiryTime+ , JWT.sub = Nothing+ , JWT.aud = Nothing+ , JWT.nbf = Nothing+ , JWT.jti = Nothing+ , JWT.unregisteredClaims = M.fromList [("qsh", String queryStringHash)] -- TODO fromJust is horrible. Remove it's use.+ }+ where+ expiryTime :: P.POSIXTime+ expiryTime = fromTime + timeUnitToDiffTime expiryPeriod++ -- Our default expiry period when talking to the host product directly+ expiryPeriod :: Minute+ expiryPeriod = 1++responder :: FromJSON a => Int -> BL.ByteString -> Either ProductErrorResponse a+responder 200 body = case eitherDecode body of+ Right jsonResponse -> Right jsonResponse+ Left err -> Left $ ProductErrorResponse 200 (T.pack $ "Could not parse the json response: " ++ show err)+responder responseCode body = Left $ ProductErrorResponse responseCode (T.decodeUtf8 . BL.toStrict $ body)++-- Wrapping this method for now. See: http://goo.gl/AZtRHZ+setPostParams :: [(B.ByteString, B.ByteString)] -> Endo Request+setPostParams = setParams
+ src/Snap/AtlassianConnect/Instances.hs view
@@ -0,0 +1,21 @@+module Snap.AtlassianConnect.Instances+ ( ConnectURI(..)+ , getURI+ ) where++import Control.Monad+import Data.Aeson.Types+import qualified Data.Text as T+import Network.URI++-- | An Atlassian Connect URI+newtype ConnectURI = CURI URI+ deriving(Eq, Show)++-- | Get a regular URI from an Atlassian Connect URI+getURI :: ConnectURI -> URI+getURI (CURI u) = u++instance FromJSON ConnectURI where+ parseJSON (String uriString) = maybe mzero return (fmap CURI . parseURI . T.unpack $ uriString)+ parseJSON _ = mzero
+ src/Snap/AtlassianConnect/LifecycleResponse.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE DeriveGeneric #-}+module Snap.AtlassianConnect.LifecycleResponse+ ( LifecycleResponse(..)+ , ClientKey+ , getLifecycleResponse+ ) where++import Control.Applicative ((<$>))+import qualified Data.Aeson as A+import Data.Aeson.Types+import Data.AesonHelpers+import qualified Data.Text as T+import GHC.Generics+import Snap.AtlassianConnect.Instances+import qualified Snap.Core as SC+import qualified Snap.Snaplet as SS++-- | Represents the 'client key' from the Atlassian Connect framework in the host product.+type ClientKey = T.Text++-- | When an Atlassian Connect plugin is installed or uninstalled this is the lifecycle response that your add-on+-- will be sent (in JSON). Use this data structure to handle lifecycle event data from the host application.+data LifecycleResponse = LifecycleResponseInstalled+ { lrKey :: T.Text+ , lrClientKey :: ClientKey+ , lrPublicKey :: T.Text+ , lrSharedSecret :: Maybe T.Text+ , lrServerVersion :: Maybe T.Text+ , lrPluginsVersion :: Maybe T.Text+ , lrBaseUrl :: ConnectURI+ , lrProductType :: Maybe T.Text+ , lrDescription :: Maybe T.Text+ , lrEventType :: Maybe T.Text+ } deriving (Eq, Show, Generic)++instance FromJSON LifecycleResponse where+ parseJSON = genericParseJSON baseOptions+ { fieldLabelModifier = stripFieldNamePrefix "lr"+ }++-- | A convenience method to get a Lifecycle Response from the response body.+getLifecycleResponse :: SS.Handler b a (Maybe LifecycleResponse)+getLifecycleResponse = A.decode <$> SC.readRequestBody (1024 * 10)
+ src/Snap/AtlassianConnect/PageToken.hs view
@@ -0,0 +1,100 @@+{-# LANGUAGE OverloadedStrings #-}+module Snap.AtlassianConnect.PageToken+ ( PageToken(..) -- TODO make it so that you can query the token but nothing else+ , generateToken+ , generateTokenCurrentTime+ , encryptPageToken+ , decryptPageToken+ , defaultTimeoutSeconds+ ) where++import qualified Control.Applicative as CA+import qualified Crypto.Cipher.AES as CCA+import Data.Aeson+import Data.Aeson.Types+import qualified Data.ByteString as DB+import qualified Data.ByteString.Base64 as B64+import qualified Data.ByteString.Lazy as DBL+import qualified Data.Padding as DP+import qualified Data.Text as DT+import Data.Time.Clock+import Data.Time.Units++import qualified Snap.AtlassianConnect.AtlassianTypes as CA+import qualified Snap.AtlassianConnect.Tenant as CT+import qualified Snap.AtlassianConnect.TimeUnits as TU++-- | The default timeout for a Page Token. It is likely that you will want to make this longer.+defaultTimeoutSeconds :: TU.ConnectSecond+defaultTimeoutSeconds = TU.CSecond $ 5 * 60++-- | Page Tokens are your way of providing a token to the client's browser and have them make trusted requests+-- back to the Atlassian Connect application without having to go through the host product.+data PageToken = PageToken+ { pageTokenHost :: CT.TenantKey -- ^ The Atlassian Cloud tenant that this token was generated for.+ , pageTokenUser :: Maybe CA.UserKey -- ^ The potential user that this token was generated for.+ , pageTokenTimestamp :: UTCTime -- ^ The time at which this token was generated.+ , pageTokenAllowInsecurePolling :: Bool -- ^ (Deprecated) This is currently unsupported. Don't use it.+ } deriving (Eq, Show)++instance ToJSON PageToken where+ toJSON (PageToken host potentialUser timestamp insecurePolling) = object $+ [ "h" .= host+ , "t" .= timestamp+ ]+ ++ user potentialUser+ ++ polling insecurePolling+ where+ user :: Maybe CA.UserKey -> [Pair]+ user (Just userKey) = [ "u" .= userKey ]+ user Nothing = []++ polling :: Bool -> [Pair]+ polling True = [ "p" .= DT.pack "1" ] -- Unfortunately we need to write out a true+ polling False = []++instance FromJSON PageToken where+ parseJSON (Object tokenData) = PageToken+ CA.<$> tokenData .: "h"+ CA.<*> tokenData .:? "u"+ CA.<*> tokenData .: "t"+ CA.<*> tokenData .:? "p" .!= False+ parseJSON _ = fail "The PageToken should contain a JSON object."++-- | Given a tentant, a potential user and a time generate a page token.+generateToken :: CT.TenantWithUser -> UTCTime -> PageToken+generateToken (tenant, userKey) timestamp = PageToken+ { pageTokenHost = CT.key tenant+ , pageTokenUser = userKey+ , pageTokenTimestamp = timestamp+ , pageTokenAllowInsecurePolling = False+ }++-- | Given a tenant and a potential user generate a token for the current time.+generateTokenCurrentTime :: CT.TenantWithUser -> IO PageToken+generateTokenCurrentTime ct = fmap (generateToken ct) getCurrentTime++-- In order to write the token out:+-- 1. Generate the token.+-- 1. Write the token out to json in the supported format.+-- 1. Base64 encode the json.+-- 1. AES ECB encrypt the string++-- | Given an AES context encrypt a page token into a bytestring.+encryptPageToken :: CCA.AES -> PageToken -> DB.ByteString+encryptPageToken aes pageToken = encryptedEncodedToken+ where+ tokenAsJson = encode pageToken+ tokenAsBase64 = B64.encode . DBL.toStrict $ tokenAsJson+ paddedBase64 = DP.zeroPad 16 tokenAsBase64+ encryptedToken = CCA.encryptECB aes paddedBase64+ encryptedEncodedToken = B64.encode encryptedToken++-- | Given an AES context decrypt a page token.+decryptPageToken :: CCA.AES -> DB.ByteString -> Either String PageToken+decryptPageToken aes input = do+ undecodedEncryptedToken <- B64.decode input+ let decryptedToken = CCA.decryptECB aes undecodedEncryptedToken+ (B64.decode . DP.zeroUnpad $ decryptedToken) >>= eitherDecode . DBL.fromStrict++-- TODO we should write unit tests to ensure that every combination of this encrypt and decrypt can be translated correctly.
+ src/Snap/AtlassianConnect/QueryStringHash.hs view
@@ -0,0 +1,121 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE CPP #-}+module Snap.AtlassianConnect.QueryStringHash+ ( createQueryStringHash+ , module Network.HTTP.Types+ ) where++import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.ByteString.Char8 as B+import qualified Crypto.Hash as SHA+import Network.HTTP.Types+import Network.URI+import Data.Function+import Data.Ord+import Data.Maybe+import Data.Monoid+import Data.List.Split+import Control.Applicative+import Data.List+++-- $setup+-- >>> import Data.Maybe+-- >>> import Data.Text++-- | Create a query string hash (required for the JWT claim set for an+-- Atlassian connect application) based on the HTTP method, the base URL+-- and the url to be used. This function will canonicalize the given URL+-- based on the rules outlined on <https://developer.atlassian.com/static/connect/docs/concepts/understanding-jwt.html#qsh>+--+-- >>> :{+-- let+-- baseUrl = fromMaybe nullURI $ parseURI "http://localhost:2990"+-- input = "http://localhost:2990/path/to/service?a=1&A=2&b=3&B=4" :: Text+-- in createQueryStringHash GET baseUrl input+-- :}+-- Just "70282c7cf82834bd5a3d6dacda1b4ccd5cf5860a63a1fa2fb86b64d576e6a1d5"+createQueryStringHash :: StdMethod -> URI -> T.Text -> Maybe T.Text+createQueryStringHash method baseUrl str =+ (TE.decodeUtf8 . SHA.digestToHexByteString . hsh) <$> toCanonicalUrl method baseUrl str++hsh :: T.Text -> SHA.Digest SHA.SHA256+hsh = SHA.hash . TE.encodeUtf8++-- TODO we ask for the method just so that we can run show method on it...I think we should give it+-- here in a different way and not mandate StdMethod.+toCanonicalUrl :: StdMethod -> URI -> T.Text -> Maybe T.Text+toCanonicalUrl method baseUrl input = do+ uri <- parseURI (T.unpack input)+ let path' = stripContextPath baseUrl uri+ let sqs = sortedQueryString uri+ return . T.pack $ intercalate "&" [show method, path', sqs]++sortedQueryString :: URI -> String+sortedQueryString = toCanonicalQueryString . parseQueryText . B.pack . uriQuery++stripContextPath :: URI -> URI -> String+stripContextPath baseUrl' uri = sep <> intercalate sep (u \\ b)+ where+ sep = "/"+ f = splitOn sep . uriPath+ b = f baseUrl'+ u = f uri++{-+ - See step 5 of Creating a Query Hash:+ - https://developer.atlassian.com/static/connect/docs/concepts/understanding-jwt.html+* Sort the query parameters primarily by their percent-encoded names and secondarily by their percent-encoded values+* Sorting is by codepoint: sort(["a", "A", "b", "B"]) => ["A", "B", "a", "b"]+* For each parameter append its percent-encoded name, the '=' character and then its percent-encoded value.+* In the case of repeated parameters append the ',' character and subsequent percent-encoded values.+* Ignore the jwt parameter, if present.+* Some particular values to be aware of:+ "+" is encoded as "%20",+ "*" as "%2A" and+ "~" as "~".+ (These values used for consistency with OAuth1.)+ This method needs to be functionally equivalent to: com.atlassian.jwt.core.HttpRequestCanonicalizer#canonicalizeQueryParameters from atlassian-jwt+-}+toCanonicalQueryString :: QueryText -> String+toCanonicalQueryString = T.unpack . render . joinQueryParams . groupAndSortQueryParams . ignoreJWTParam++type QueryParam = (T.Text, Maybe T.Text)++ignoreJWTParam :: [(T.Text, a)] -> [(T.Text, a)]+ignoreJWTParam = filter ((/= "jwt") . fst)++sortParamValues :: Ord b => [(a, b)] -> [(a, b)]+sortParamValues = sortBy (comparing snd)++sortParamKeys :: Ord a => [(a, b)] -> [(a, b)]+sortParamKeys = sortBy (comparing fst)++groupAndSortQueryParams :: [QueryParam] -> [[QueryParam]]+groupAndSortQueryParams = fmap sortParamValues . groupBy ((==) `on` fst) . sortParamKeys++joinQueryParams :: [[QueryParam]] -> [(T.Text, T.Text)]+joinQueryParams = catMaybes . fmap joinQueryParam++joinQueryParam :: [QueryParam] -> Maybe (T.Text, T.Text)+joinQueryParam [] = Nothing+joinQueryParam xs@(x : _) = return (fst x, T.intercalate sep . catMaybes . fmap snd $ xs)+ where+ sep = T.singleton ','++queryParamToString :: (T.Text, T.Text) -> T.Text+queryParamToString (key, value) = encode key <> T.singleton '=' <> encode value++render :: [(T.Text, T.Text)] -> T.Text+render = T.intercalate "&" . fmap queryParamToString++encode :: T.Text -> T.Text+encode = T.foldl' encodeChars T.empty+ where+ encodeChars :: T.Text -> Char -> T.Text+ encodeChars s c = case c of+ ' ' -> s <> "%20"+ '&' -> s <> "%26"+ '*' -> s <> "%2A"+ _ -> s `T.snoc` c
+ src/Snap/AtlassianConnect/Routes.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+module Snap.AtlassianConnect.Routes+ ( connectRoutes+ , homeHandler+ ) where++import Control.Applicative+import qualified Control.Arrow as ARO+import Control.Monad (mzero)+import Control.Monad.State.Class (get)+import qualified Data.Aeson as A+import qualified Data.ByteString.Char8 as BC+import qualified Data.CaseInsensitive as CI+import Data.Connect.Descriptor as D+import Data.List+import Data.List.Split (splitOn)+import qualified Data.Map.Lazy as ML+import Data.Maybe (catMaybes, fromMaybe)+import qualified Network.HTTP.Media as NM+import qualified Snap.AtlassianConnect.Data as CD+import qualified Snap.AtlassianConnect.LifecycleResponse as CL+import qualified Snap.Core as SC+import qualified Snap.Helpers as SH+import qualified Snap.Snaplet as SS++data MediaType = ApplicationJson | TextHtml deriving (Eq)++newtype OrdMediaType = OMT NM.MediaType deriving (Eq, Show)++instance Ord OrdMediaType where+ compare a b = compare (show a) (show b)++instance Show (MediaType) where+ show ApplicationJson = "application/json"+ show TextHtml = "text/html"++-- | In old versions of Atlassian Connect, if you queried the application root and your Accept headers wanted an+-- "application/json" response then they would return your Atlassian Connect descriptor and if the Accept headers+-- requested "text/html" then they returned your applications home page. This is a convenience method to enable that+-- behaviour to continue. If you use this method in your routes then you can make your home page behave in the same way.+-- This is not required for an Atlassian Connect application so feel free to just return home page content on your home+-- page and ignore this method.+homeHandler :: SS.Handler b CD.Connect () -> SS.Handler b CD.Connect ()+homeHandler sendHomePage = SC.method SC.GET handleGet <|> SH.respondWithError SH.badRequest "You can only GET the homepage."+ where+ handleGet = do+ potentialAcceptHeader <- SC.getHeader bsAccept <$> SC.getRequest+ case potentialAcceptHeader of+ Just acceptHeader -> fromMaybe unknownHeader (NM.mapAcceptMedia mediaTypeMap acceptHeader)+ Nothing -> mzero++ mediaTypeMap =+ [ (jsonMT, atlassianConnectHandler)+ , (textHtmlMT, sendHomePage)+ ]++ unknownHeader = SH.respondWithError SH.notFound "No response to a request with the provided Accept header."++ jsonMT = "application" NM.// "json"+ textHtmlMT = "text" NM.// "html"++bsAccept :: CI.CI BC.ByteString+bsAccept = CI.mk "Accept"++connectRoutes :: [(BC.ByteString, SS.Handler b CD.Connect ())]+connectRoutes = fmap (ARO.first BC.pack) simpleConnectRoutes++-- Handler b v a+-- b: lens from the base state to the current snaplets state (is the base state)+-- v: is the state of the current "view" snaplet (or simply, current state)+-- a: Monad return type+-- The MonadSnaplet type class distills the essence of the operations used with this pattern.+-- Its functions define fundamental methods for navigating snaplet trees.++simpleConnectRoutes :: [(String, SS.Handler b CD.Connect ())]+simpleConnectRoutes =+ [ ("/atlassian-connect.json" , atlassianConnectHandler)+ ]++atlassianConnectHandler :: SS.Handler b CD.Connect ()+atlassianConnectHandler = do+ connectData <- get+ let dc = genDescriptorExtrasFromConnect connectData+ SH.writeJson $ convertPluginDescriptor dc (CD.connectPlugin connectData)++genDescriptorExtrasFromConnect :: CD.Connect -> CD.DynamicDescriptorConfig+genDescriptorExtrasFromConnect connectData = CD.DynamicDescriptorConfig+ { CD.dcPluginName = case CD.connectPluginName connectData of D.Name t -> D.Name t+ , CD.dcPluginKey = CD.connectPluginKey connectData+ , CD.dcBaseUrl = CD.connectBaseUrl connectData+ }++convertPluginDescriptor :: CD.DynamicDescriptorConfig -> D.Plugin -> D.Plugin+convertPluginDescriptor dc plugin = plugin+ { D.pluginKey = CD.dcPluginKey dc+ , D.pluginName = Just . CD.dcPluginName $ dc+ , D.pluginBaseUrl = CD.dcBaseUrl dc+ }
+ src/Snap/AtlassianConnect/Tenant.hs view
@@ -0,0 +1,32 @@+{-# LANGUAGE DeriveGeneric #-}+module Snap.AtlassianConnect.Tenant+ ( TenantWithUser+ , Tenant(..)+ , TenantKey+ ) where++import Data.Aeson.Types+import qualified Data.Text as T+import GHC.Generics+import qualified Snap.AtlassianConnect.AtlassianTypes as CA+import Snap.AtlassianConnect.Instances++-- | When we get a tenant from Atlassian Connect we can also optionally reciever the user key that made the request.+-- This structure reflects that possibility.+type TenantWithUser = (Tenant, Maybe CA.UserKey)++instance FromJSON Tenant++-- | Represents a tenant key. The unique identifier for each Atlassian Connect tenant.+type TenantKey = T.Text++-- | Represents an Atlassian Cloud tenant. Your Atlassian Connect add-on can be installed into multiple Atlassian+-- Cloud tenants.+data Tenant = Tenant+ { tenantId :: Integer -- ^ Your identifier for this tenant.+ , key :: TenantKey -- ^ The unique identifier for this tenant accross Atlassian Connect.+ , publicKey :: T.Text -- ^ The public key for this atlassian connect application.+ , sharedSecret :: T.Text -- ^ The shared secret for this atlassian connect application. Used for JWT token generation.+ , baseUrl :: ConnectURI -- ^ The base url of the Atlassian Cloud host application (product).+ , productType :: T.Text -- ^ The type of product you have connected to in the Atlassian Cloud. (E.g. JIRA, Confluence)+ } deriving (Eq, Show, Generic)
+ src/Snap/AtlassianConnect/TimeUnits.hs view
@@ -0,0 +1,23 @@+module Snap.AtlassianConnect.TimeUnits+ ( ConnectSecond(..)+ , getSecond+ ) where++import qualified Data.Configurator.Types as DCT+import Data.Ratio (denominator, numerator)+import qualified Data.Time.Units as DTU++-- | Represents a second for Atlassian Connect+newtype ConnectSecond = CSecond DTU.Second+ deriving(Eq, Show)++-- | Gets the Data.Time.Units representation of this Atlassian Connect second.+getSecond :: ConnectSecond -> DTU.Second+getSecond (CSecond s) = s++instance DCT.Configured ConnectSecond where+ convert (DCT.Number x) = Just . CSecond . fromIntegral . rationalToInteger $ x+ convert _ = Nothing++rationalToInteger :: Rational -> Integer+rationalToInteger x = numerator x `div` denominator x
+ src/Snap/Helpers.hs view
@@ -0,0 +1,70 @@+{-# LANGUAGE DeriveGeneric #-}+module Snap.Helpers+ ( respondWith+ -- HTTP Codes+ , noContent+ , temporaryRedirect+ , badRequest+ , unauthorised+ , forbidden+ , notFound+ , internalServer+ , ok+ , serviceUnavaliable+ -- Responses with Http Codes+ , respondBadRequest+ , respondNotFound+ , respondInternalServer+ , respondNoContent+ -- Respond with an error+ , respondWithError+ , respondWithErrors+ -- Write Aeson into a response+ , writeJson+ ) where++import Data.Aeson+import qualified Data.ByteString.Char8 as BSC+import GHC.Generics+import qualified Snap.Core as SC++respondWith :: SC.MonadSnap m => Int -> m ()+respondWith = SC.modifyResponse . SC.setResponseCode++noContent, temporaryRedirect, badRequest, unauthorised, forbidden, notFound, internalServer, ok, serviceUnavaliable :: Int+ok = 200+noContent = 204+temporaryRedirect = 302+badRequest = 400+unauthorised = 401+forbidden = 403+notFound = 404+internalServer = 500+serviceUnavaliable = 503++respondBadRequest, respondNotFound, respondInternalServer, respondNoContent :: SC.MonadSnap m => m ()+respondBadRequest = respondWith badRequest+respondNotFound = respondWith notFound+respondInternalServer = respondWith internalServer+respondNoContent = respondWith noContent++respondWithError :: SC.MonadSnap m => Int -> String -> m ()+respondWithError errorCode response = respondWithErrors errorCode [response]++respondWithErrors :: SC.MonadSnap m => Int -> [String] -> m ()+respondWithErrors errorCode responses = do+ writeJson errorResponse+ respondWith errorCode+ where+ errorResponse = ErrorResponse responses++writeJson :: (SC.MonadSnap m, ToJSON a) => a -> m ()+writeJson a = do+ SC.modifyResponse . SC.setContentType . BSC.pack $ "application/json"+ SC.writeLBS $ encode a++data ErrorResponse = ErrorResponse+ { errorMessages :: [String]+ } deriving (Show, Generic)++instance ToJSON ErrorResponse