REBOL [
    Title: "Etsy Client for REBOL"
    Date: 16-Sep-2012
    Author: "Christopher Ross-Gill"
    Version: 0.1.4
    Rights: [
        http://creativecommons.org/licenses/by-sa/3.0/
        "If you are using this commercially, please consider a donation."
    ]
    File: %etsy.r
    Purpose: "REBOL script to access and utilize the Etsy OAuth API"
    Usage: http://re-bol.com/etsy_api_tutorial.html
    Settings: [
        ; use if not working with 'do/args
        Consumer-Key: <consumer-key>
        Consumer-Secret: <consumer-secret>
        User-Store: <path-to-saved-users>
        Scope: []
        Sandbox: none
    ]
]

do http://reb4.me/r/etsy-http-hack
do http://reb4.me/r/altwebform
do http://reb4.me/r/altjson

etsy: context bind [
    as: func [
        [catch]
        "Set current user"
        name [string!] "Etsy user name"
        /local user
    ][
        either user: select users name [
            persona: make persona user
            persona/name
        ][
            either not error? user: try [register][
                persona/id: persona/name: name
                repend users [
                    name
                    new-line/skip/all third user true 2
                ]
                persona/name
            ][throw :user]
        ]
    ]

    save-users: func [
        "Saves authorized users"
        /to location [file! url!] "Alternate Storage Location"
    ][
        location: any [location settings/user-store]
        unless any [file? location url? location][
            make error! "No Storage Location Provided"
        ]
        save/header location new-line/skip/all users true 2 context [
            Title: "Etsy Authorized Users"
            Date: now/date
        ]
    ]

    authorized-users: func ["Lists authorized users"][extract users 2] 

    myself: echo: func ["Obtain User Profile" [catch]][
        unless persona/name error/credentials
        either attempt [
            result: send/with 'get %users/__SELF__ none
        ] load-result error/connection
    ]

    api-call: call: func [
        "Open API call"
        [catch]
        'method [word!]
        action [file!]
        /with "Includes Parameters with the request"
        params [none! object! block!]
        /raw "Returns unloaded response"
    ][
        if block? params [params: context params]
        either attempt [
            result: send/with :method :action :params
        ] either raw [[result]][load-result] error/connection
    ]

    api-call-raw: func [
        "Open API call, Not Parsed"
        [catch]
        'method [word!]
        action [file!]
        /with "Includes Parameters with the request"
        params [none! object! block!]
    ][
        if block? params [params: context params]
        either attempt [
            result: send/with :method :action :params
        ][result] error/connection
    ]

    listings: uses [
        "Show Recent Listings"
        limit: 5
        offset: 0
        min_price: max_price: none
    ][
        either attempt [
            result: send/with 'get %listings/active self
        ] load-result error/connection
    ]

    categories: func [[catch]][
        either attempt [
            result: send/with 'get %taxonomy/categories context [limit: 2]
        ] load-result error/connection
    ]

    create-listing: func [
        [catch]
        quantity [integer!]
        title [string!]
        description [string!]
        price [money!]
        category [integer!]
        who [string!]
        supply [none! logic!]
        when [string! integer! date!]
        /local listing result
    ][
        unless persona/name error/credentials

        listing: context [quantity: title: description: price: category_id: who_made: is_supply: when_made: none]

        listing/quantity: min 1 quantity
        listing/title: trim title
        listing/description: trim description
        listing/price: find/tail form price "$"
        listing/category_id: category
        listing/who_made: switch/default who [
            "collective" ["collective"]
            "someone_else" "someone else" ["someone_else"]
        ]["i_did"]
        listing/is_supply: either supply [1][0]
        listing/when_made: case/all [
            string? when ["made_to_order"]
            date? when [when: when/year]
            integer? when [
                any [
                    case [
                        when > 2009 ["2010_2012"]
                        when > 1999 ["2000_2009"]
                        when > 1992 ["1993_1999"]
                        when < 1993 ["before_1993"]
                    ]
                    "made_to_order"
                ]
            ]
        ]

        either attempt [
            result: send/with 'post %/listings listing
        ] load-result error/connection
    ]

]
context [ ; internals
    result: none
    load-result: [load-json result]

    ; settings
    settings: make context [
        consumer-key: consumer-secret: user-store: sandbox: none
        scope: []
    ] any [
        system/script/args
        system/script/header/settings
    ]

    etsy: either settings/sandbox [
        http://sandbox.openapi.etsy.com/v2/
    ][
        http://openapi.etsy.com/v2/
    ]

    users: any [attempt [load settings/user-store] []]

    persona: context [
        id: name: none
        token: secret: none
    ]

    ; helpers
    uses: func [[catch] proto [block!] spec [block!] /local header][
        unless string? header: take proto [make error! "No Header"]
        proto: context proto
        func compose [(header) [catch] args [block! object!]] compose/only [
            args: make (proto) args
            do bind (spec) args
        ]
    ]

    ; errors
    get-http-response: func ["HTTP Hack" port [none! port!]][
        port: any [port system/schemes/http]
        reform next parse do bind [any [response-line "No No Error Found"]] last second get in port/handler 'open none
    ]

    raise: use [error][
        unless in system/error 'etsy [system/error: make system/error [etsy: none]]

        error: system/error/etsy: context [
            code: 4466874 ; checksum http://www.etsy.com/
            type: "Etsy Error"
            message: none
        ]

        func [result [object! string! block!]][
            case [
                object? result [result: result/message]
                block? result [result: rejoin result]
            ]
            error/message: result
            throw make error! [etsy message]
        ]
    ]

    error: [
        credentials [raise "User must be authorized to use this application"]
        connection [raise get-http-response none]
    ]

    ; HTTP/OAUTH
    oauth!: context [
        oauth_callback: none
        oauth_consumer_key: settings/consumer-key
        oauth_token: oauth_nonce: none
        oauth_signature_method: "HMAC-SHA1"
        oauth_timestamp: none
        oauth_version: 1.0
        oauth_verifier: oauth_signature: none
    ]

    send: use [make-nonce timestamp sign][
        make-nonce: does [
            enbase/base checksum/secure join now/precise settings/consumer-key 64
        ]

        timestamp: func [/for date [date!]][
            date: any [date now]
            date: form any [
                attempt [to integer! difference date 1-Jan-1970/0:0:0]
                date - 1-Jan-1970/0:0:0 * 86400.0
            ]
            clear find/last date "."
            date
        ]

        sign: func [
            method [word!]
            lookup [url!]
            oauth [object! none!]
            params [object! none!]
            /local out
        ][
            out: copy ""

            oauth: any [oauth make oauth! []]
            oauth/oauth_nonce: make-nonce
            oauth/oauth_timestamp: timestamp
            oauth/oauth_token: persona/token

            params: make oauth any [params []]
            params: sort/skip third params 2

            oauth/oauth_signature: enbase/base checksum/secure/key rejoin [
                uppercase form method "&" url-encode form lookup "&"
                url-encode replace/all to-webform params "+" "%20"
            ] rejoin [
                settings/consumer-secret "&" any [persona/secret ""]
            ] 64

            foreach [header value] third oauth [
                if value [
                    repend out [", " form header {="} url-encode form value {"}]
                ]
            ]

            join "OAuth" next out
        ]

        send: func [
            [catch]
            method [word!] lookup [file!]
            /auth oauth [object!]
            /with params [object! none!]
        ][
            lookup: etsy/:lookup
            oauth: any [oauth make oauth! []]

            switch method [
                put delete [
                    params: make context compose [method: (uppercase form method)] any [params []]
                    method: 'post
                ]
            ]

            switch method [
                get [
                    method: compose/deep [
                        header [Authorization: (sign 'get lookup oauth params)]
                    ]
                    if params [
                        params: context sort/skip third params 2
                        append lookup to-webform/prefix params
                    ]
                ]
                post put delete [
                    method: compose/deep [
                        (method) (either params [to-webform params][""]) [
                            Authorization: (sign method lookup oauth params)
                            Content-Type: "application/x-www-form-urlencoded"
                        ]
                    ]
                ]
            ]

            lookup: attempt [read/custom lookup method]
        ]
    ]

    register: use [request-broker access-broker][
        request-broker: %oauth/request_token
        access-broker: %oauth/access_token

        func [
            /requester request [function!]
            /local response verifier verification-page
        ][
            request: any [:request :ask]
            set persona none

            response: load-webform send/auth/with 'post request-broker make oauth! [
                oauth_callback: "oob"
            ] context [
                scope: form settings/scope
            ]

            unless all [
                find response 'login_url
                string? response/login_url
                parse response/login_url ["http" opt "s" "://" to end]
                url? load response/login_url
            ][
                make error! "Handshake Response does not contain Login URL"
            ]

            persona/token: response/oauth_token
            persona/secret: response/oauth_token_secret

            browse response/login_url
            unless verifier: request "Enter your PIN from Etsy: " [
                make error! "Not a valid PIN"
            ]

            response: load-webform send/auth 'post access-broker make oauth! [
                oauth_verifier: trim/all verifier
            ]

            persona/token: response/oauth_token
            persona/secret: response/oauth_token_secret

            persona
        ]
    ]
]