diff --git a/.gitignore b/.gitignore index 0e18061..8e521dd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ build/** dist/** .idea atlassian-ide-plugin.xml +twistd.log +twistd.pid diff --git a/CHANGES.txt b/CHANGES.txt index 8530e82..2b801d7 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,47 @@ +version 0.5.0 - 2013-01-25 +========================== + +There is lots of new stuff here but this release is backwards compatible. The +REST interface operates on a different default port and runs completey in +paralell with the XML-RPC one. That said, REST is the way forward and all new +features will be added to that server. + +New: + + * An all-new REST interface that simplifies access to pyapns's functionality. + + The XML-RPC server will be depreciated over the coming years. Next year it + will be "depreciated" as in it will start giving off warnings and the year + after it will likely be entirely removed. + + * REST interface uses Apple's new "enhanced" notification format which has + the ability to send back errors in the event Apple severs the connection + to pyapns (which can happen for a number of reasons). + + * A disconnection log which will pair disconnection events with the + notifications at fault. This will allow you to remove offending tokens + and notifications. + + * The new Python client uses the very fine requests library which provides + several advantages over the old XML-RPC library principally: connection + keep-alive and reuse and connection pooling. + +Fixed Bugs: + + * Fixed #31/#26 - Token blacklisting - we will not blacklist tokens as it's + not the job of this daemon to persist that kind of state. We however now + give you the tools to blacklist tokens should you wish to do so. + + * Fixed #14 - Apple Dropping Connection - this can happen for any number + of reasons, by using the new REST service you can get an idea of why. + + * Fixed #10 - reprovisioning - while this issue isn't specifically to allow + reprovisioning, the reason @mikeytrw wanted the feature was to replace + certs, which is now possible. + + * Fixed #28 - sending multiple notifications after a disconnect fails - + simply fixed the way the deferred was handeled + version 0.4.0 - 2012-02-14 ========================== diff --git a/COPYING b/COPYING index 2b32a20..1e2e4be 100644 --- a/COPYING +++ b/COPYING @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2012 Samuel Sutch +Copyright (c) 2013 Samuel Sutch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9daf29f..cda4d87 100644 --- a/README.md +++ b/README.md @@ -4,353 +4,154 @@ pyapns A universal Apple Push Notification Service (APNS) provider. Features: - - -pyapns is an APNS provider that you install on your server and access through XML-RPC. To install you will need Python, [Twisted](http://pypi.python.org/pypi/Twisted) and [pyOpenSSL](http://pypi.python.org/pypi/pyOpenSSL). It's also recommended to install [python-epoll](http://pypi.python.org/pypi/python-epoll/) for best performance (if epoll is not available, like on Mac OS X, you may want to use another library, like [py-kqueue](http://pypi.python.org/pypi/py-kqueue/2.0.1)). If you like easy_install try (it should take care of the dependancies for you): + + * **REST interface** so you can start sending notifications with any language immediately + * Native Python API included + * Scalable, fast and easy to distribute behind a proxy + * Multi-application and dual environment support + * Disconnection log interface which provides reasons for recent connection issues + * Supports the full suite of APN gateway functionality + +### Quick Start + +Install and start the daemon: $ sudo easy_install pyapns - -pyapns is a service that runs persistently on your machine. To start it: + $ wget https://raw.github.com/samuraisam/pyapns/master/pyapns.tac + $ twistd -ny pyapns.tac + +Provision an app and send a notification: + + $ curl -d '{"certificate":"/path/to/certificate.pem"}' \ + http://localhost:8088/apps/com.example.app/sandbox + + $ curl -d '{"token": "le_token", \ + "payload": {"aps": {"alert": "Hello!"}}, \ + "identifier": "xxx", "expiry": 0}' \ + http://localhost:8088/apps/com.example.app/sandbox/notifications + +### About + +pyapns is a daemon that is installed on a server and designed to take the pain out of sending push notifications to Apple devices. Typically your applications you will have a thread that maintains an SSL socket to Apple. This can be error-prone, hard to maintain and plainly a burden your app servers should not have to deal with. + +Additionally, pyapns provides several features you just wouldn't get with other solutions such as the disconnection log which remembers which notifications and tokens caused disconnections with Apple - thus allowing your application layer to make decisions about whether or not th continue sending those types of notifications. This also works great as a debugging layer. + +pyapns supports sending notifications to multiple applications each with multiple environments. This is handy so you don't have to push around your APN certificates, just keep them all local to your pyapns installation. + +## The REST interface + +The rest interface is by default hosted on port `8088` and provides all of the functionality you'll need. Here are some basic things you need to know about how it works: + + * All functionality is underneath the `/apps` top level path + * Functionality specific to individual apps is available underneath the `/apps/{app id}/{environment}` path where `app id` is the provisioned app id and `environment` is the Apple environment (either "sandbox" or "production") + * You can get a list of provisioned apps: `GET /apps` + * Objects all have a `type` attribute that tells you which kind of object it is + * Successful responses will have a top level object with a `response` and `code` keys + * Unsuccessful responses will have a top level object with an `error` and `code` keys + +### Provisioning An App + +Before sending notifications to devices, you must first upload your certificate file to the server so pyapns can successfully make a connection to the APN gateway. The certificates must be a PEM encoded file. [This](http://stackoverflow.com/questions/1762555/creating-pem-file-for-apns) stackoverflow answer contains an easy way to accomplish that. + +You can upload the PEM and provision the app multiple ways: + + 1. Send the PEM file directly when provisioning the apps. Just read the whole PEM file into memory and include it as the `certificate` key: + + $ curl -d '{"certificate": "$(cat /path/to/cert.pem)"}' $HOST:$PORT/apps/com.example.myid/production + + 2. Upload the PEM file ahead of time to the same server as the pyapns daemon and provide the path to the certificate as the `certificate` key: + + $ curl -d '{"certificate": "/path/to/cert.pem"}' $HOST:$PORT/apps/com.example.myid/production + +Notice above that we are including in the URL the app id desired as well as the environment desired. They are the last and 2nd-to-last elements of the path, respectively. So for this url the app id is _com.example.myid_ and the environment is _production_. Any time you access functionality specific to these apps you'll be accessing it as a subpath of this full path. + +
+
GET /apps
+
+Returns a list of all provisioned apps + +Example Response +```json +{ + "response": [ + { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" + } + ], + "code": 200 +} +``` +
+
+#### GET _/apps/:app_id/environment_ + +Returns information about a provisioned app + +##### Example Response +```json +{ + "response": { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" + }, + "code": 200 +} +``` + +#### POST _/apps/:app_id/:environment_ + +Creates a newly provisioned app. You can POST multiple times to the same URL and it will merely re-provision the app, taking into account the new certificate and timeout. There may be more config values to provision in the future. + +###### Example Body: +```json +{ + "certificate": "certificate or path to certificate", + "timeout": 15 +} +``` +##### Example Response +```json +{ + "response": { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" + }, + "code": 201 +} +``` - $ twistd -r epoll web --class=pyapns.server.APNSServer --port=7077 +### Sending Notifications +###### Identifiers and Expiry -This will create a `twistd.pid` file in your current directory that can be used to kill the process. `twistd` is a launcher used for running network persistent network applications. It takes many more options that can be found by running `man twistd` or using a [web man page](http://linux.die.net/man/1/twistd). +#### Retrieving Feedback -To get started right away, use the included client: +#### Retrieving Disconnection Events - $ python - >>> from pyapns import configure, provision, notify - >>> configure({'HOST': 'http://localhost:7077/'}) - >>> provision('myapp', open('cert.pem').read(), 'sandbox') - >>> notify('myapp', 'hexlified_token_str', {'aps':{'alert': 'Hello!'}}) +### The Included Python API -### The Multi-Application Model -pyapns supports multiple applications. Before pyapns can send notifications, you must first provision the application with an Application ID, the environment (either 'sandbox' or 'production') and the certificate file. The `provision` method takes 4 arguments, `app_id`, `path_to_cert_or_cert`, `environment` and `timeout`. A connection is kept alive for each application provisioned for the fastest service possible. The application ID is an arbitrary identifier and is not used in communication with the APNS servers. +### Installing in Production -When a connection can not be made within the specified `timeout` a timeout error will be thrown by the server. This usually indicates that the wrong [type of] certification file is being used, a blocked port or the wrong environment. +To install in production, you will want a few things that aren't covered in the quickstart above: -Attempts to provision the same application id multiple times are ignored. + 1. Automated provisioning of apps. This is supported when the pyapns server is started up. + 2. Install [python-epoll](http://pypi.python.org/pypi/python-epoll/) and [ujson](http://pypi.python.org/pypi/ujson) for dramatically improved performance + 3. (optional) start multiple instances behind a reverse proxy like HAProxy or Nginx -### Sending Notifications -Calling `notify` will send the message immediately if a connection is already established. The first notification may be delayed a second while the server connects. `notify` takes `app_id`, `token_or_token_list` and `notification_or_notification_list`. Multiple notifications can be batched for better performance by using paired arrays of token/notifications. When performing batched notifications, the token and notification arrays must be exactly the same length. - -The full notification dictionary must be included as the notification: - - {'aps': { - 'sound': 'flynn.caf', - 'badge': 0, - 'message': 'Hello from pyapns :)' - } - } # etc... - -### Retrieving Inactive Tokens -Call `feedback` with the `app_id`. A list of tuples will be retrieved from the APNS server that it deems inactive. These are returned as a list of 2-element lists with a `Datetime` object and the token string. - -### XML-RPC Methods -These methods can be called on the server you started the server on. Be sure you are not including `/RPC2` in the URL. - -### provision - - Arguments - app_id String the application id for the provided - certification - cert String a path to a .pem file or the a - string with the entie file - environment String the APNS server to use - either - 'production' or 'sandbox' - timeout Integer timeout for connection attempts to - the APS servers - Returns - None - -### notify - - Arguments - app_id String the application id to send the - message to - tokens String or Array an Array of tokens or a single - token string - notifications String or Array an Array of notification - dictionaries or a single - notification dictionary - - Returns - None - -### feedback - - Arguments - app_id String the application id to retrieve - retrieve feedback for - - Returns - Array(Array(Datetime(time_expired), String(token)), ...) - - -### The Python API -pyapns also provides a Python API that makes the use of pyapns even simpler. The Python API must be configured before use but configuration files make it easier. The pyapns `client` module currently supports configuration from Django settings and Pylons config. To configure using Django, the following must be present in your settings file: - - PYAPNS_CONFIG = { - 'HOST': 'http://localhost:8077/', - 'TIMEOUT': 15, # OPTIONAL, host timeout in seconds - 'INITIAL': [ # OPTIONAL, see below - ('craigsfish', '/home/samsutch/craigsfish/apscert.pem', 'sandbox'), - ] - } - -Optionally, with Django settings, you can skip manual provisioning by including a list of `(name, path, environment)` tuples that are guaranteed to be provisioned by the time you call `notify` or `feedback`. - -Configuring for pylons is just as simple, but automatic provisioning isn't possible, in your configuration file include: - - pyapns_host = http://localhost:8077/ - pyapns_timeout = 15 - -For explanations of the configuration variables see the docs for `pyapns.client.configure`. - -Each of these functions can be called synchronously and asynchronously. To make them perform asynchronously simply supply a callback and pass `async=True` to the function. The request will then be made in another thread and your callback will be executed with the results. When calling asynchronously no value will be returned: - - def got_feedback(tuples): - trim_inactive_tokens(tuples) - feedback('myapp', async=True, callback=got_feedback) - -### `pyapns.client.configure(opts)` - - Takes a dictionary of options and configures the client. - Currently configurable options are 'HOST', 'TIMEOUT' and 'INITIAL' - the latter of which is only read once. - - Config Options: - HOST - A full host name with port, ending with a forward slash - TIMEOUT - An integer specifying how many seconds to timeout a - connection to the pyapns server (prevents deadlocking - the parent thread). - INITIAL - A List of tuples to be supplied to provision when - the first configuration happens. - -### `pyapns.client.provision(app_id, path_to_cert_or_cert, environment, timeout=15, async=False, callback=None, errback=None)` - - Provisions the app_id and initializes a connection to the APNS server. - Multiple calls to this function will be ignored by the pyapns daemon - but are still sent so pick a good place to provision your apps, optimally - once. - - Arguments: - app_id the app_id to provision for APNS - path_to_cert_or_cert absolute path to the APNS SSL cert or a - string containing the .pem file - environment either 'sandbox' or 'production' - timeout number of seconds to timeout connection - attempts to the APPLE APS SERVER - async pass something truthy to execute the request in a - background thread - callback a function to be executed with the result - errback a function to be executed with the error in case of an error - - Returns: - None - -### `pyapns.client.notify(app_id, tokens, notifications, async=False, callback=None, errback=None)` - - Sends push notifications to the APNS server. Multiple - notifications can be sent by sending pairing the token/notification - arguments in lists [token1, token2], [notification1, notification2]. - - Arguments: - app_id provisioned app_id to send to - tokens token to send the notification or a - list of tokens - notifications notification dict or a list of notification dicts - async pass something truthy to execute the request in a - background thread - callback a function to be executed with the result when done - errback a function to be executed with the error in case of an error - - Returns: - None - -### `pyapns.client.feedback(app_id, async=False, callback=None, errback=None)` - - Retrieves a list of inactive tokens from the APNS server and the times - it thinks they went inactive. - - Arguments: - app_id the app_id to query - async pass something truthy to execute the request in - a background thread - callback a function to be executed with the result when - feedbacks are done fetching - errback a function to be executed with the error if there - is one during the request - - Returns: - List of feedback tuples like [(datetime_expired, token_str), ...] - - -## The Ruby API - -###PYAPNS::Client -There's python in my ruby! - -The ruby gem can be installed from [here](https://github.com/krasio/pyapns_gem) - -This is a class used to send notifications, provision applications and -retrieve feedback using the Apple Push Notification Service. - -PYAPNS is a multi-application APS provider, meaning it is possible to send -notifications to any number of different applications from the same application -and same server. It is also possible to scale the client to any number -of processes and servers, simply balanced behind a simple web proxy. - -It may seem like overkill for such a bare interface - after all, the -APS service is rather simplistic. However, PYAPNS takes no shortcuts when it -comes to completeness/compliance with the APNS protocol and allows the -user many optimization and scaling vectors not possible with other libraries. -No bandwidth is wasted, connections are persistent and the server is -asynchronous therefore notifications are delivered immediately. - -PYAPNS takes after the design of 3rd party push notification service that -charge a fee each time you push a notification, and charge extra for so-called -'premium' service which supposedly gives you quicker access to the APS servers. -However, PYAPNS is free, as in beer and offers more scaling opportunities without -the financial draw. - -###Provisioning - -To add your app to the PYAPNS server, it must be `provisioned` at least once. -Normally this is done once upon the start-up of your application, be it a web -service, desktop application or whatever... It must be done at least once -to the server you're connecting to. Multiple instances of PYAPNS will have -to have their applications provisioned individually. To provision an application -manually use the `PYAPNS::Client#provision` method. - - require 'pyapns' - client = PYAPNS::Client.configure - client.provision :app_id => 'cf', :cert => '/home/ss/cert.pem', :env => 'sandbox', :timeout => 15 - -This basically says "add an app reference named 'cf' to the server and start -a connection using the certification, and if it can't within 15 seconds, -raise a `PYAPNS::TimeoutException` - -That's all it takes to get started. Of course, this can be done automatically -by using PYAPNS::ClientConfiguration middleware. `PYAPNS::Client` is a singleton -class that is configured using the class method `PYAPNS::Client#configure`. It -is sensibly configured by default, but can be customized by specifying a hash -See the docs on `PYAPNS::ClientConfiguration` for a list of available configuration -parameters (some of these are important, and you can specify initial applications) -to be configured by default. - -###Sending Notifications - -Once your client is configured, and application provisioned (again, these -should be taken care of before you write notification code) you can begin -sending notifications to users. If you're wondering how to acquire a notification -token, you've come to the wrong place... I recommend using google. However, -if you want to send hundreds of millions of notifications to users, here's how -it's done, one at a time... - -The `PYAPNS::Client#notify` is a sort of polymorphic method which can notify -any number of devices at a time. It's basic form is as follows: - - client.notify 'cf', 'long ass app token', {:aps=> {:alert => 'hello?'}} - -However, as stated before, it is sort of polymorphic: - - client.notify 'cf', ['token', 'token2', 'token3'], [alert, alert2, alert3] - - client.notify :app_id => 'cf', :tokens => 'mah token', :notifications => alertHash - - client.notify 'cf', 'token', PYAPNS::Notification('hello tits!') - -As you can see, the method accepts paralell arrays of tokens and notifications -meaning any number of notifications can be sent at once. Hashes will be automatically -converted to `PYAPNS::Notification` objects so they can be optimized for the wire -(nil values removed, etc...), and you can pass `PYAPNS::Notification` objects -directly if you wish. - -###Retrieving Feedback - -The APS service offers a feedback functionality that allows application servers -to retrieve a list of device tokens it deems to be no longer in use, and the -time it thinks they stopped being useful (the user uninstalled your app, better -luck next time...) Sounds pretty straight forward, and it is. Apple recommends -you do this at least once an hour. PYAPNS will return a list of 2-element lists -with the date and the token: - - feedbacks = client.feedback 'cf' - -###Asynchronous Calls - -PYAPNS::Client will, by default, perform no funny stuff and operate entirely -within the calling thread. This means that certain applications may hang when, -say, sending a notification, if only for a fraction of a second. Obviously -not a desirable trait, all `provision`, `feedback` and `notify` -methods also take a block, which indicates to the method you want to call -PYAPNS asynchronously, and it will be done so handily in another thread, calling -back your block with a single argument when finished. Note that `notify` and `provision` -return absolutely nothing (nil, for you rub--wait you are ruby developers!). -It is probably wise to always use this form of operation so your calling thread -is never blocked (especially important in UI-driven apps and asynchronous servers) -Just pass a block to provision/notify/feedback like so: - - PYAPNS::Client.instance.feedback do |feedbacks| - feedbacks.each { |f| trim_token f } - end - -###PYAPNS::ClientConfiguration -A middleware class to make `PYAPNS::Client` easy to use in web contexts - -Automates configuration of the client in Rack environments -using a simple confiuration middleware. To use `PYAPNS::Client` in -Rack environments with the least code possible `use PYAPNS::ClientConfiguration` -(no, really, in some cases, that's all you need!) middleware with an optional -hash specifying the client variables. Options are as follows: - - use PYAPNS::ClientConfiguration( - :host => 'http://localhost/' - :port => 7077, - :initial => [{ - :app_id => 'myapp', - :cert => '/home/myuser/apps/myapp/cert.pem', - :env => 'sandbox', - :timeout => 15 - }]) - -Where the configuration variables are defined: - - :host String the host where the server can be found - :port Number the port to which the client should connect - :initial Array OPTIONAL - an array of INITIAL hashes - - INITIAL HASHES: - - :app_id String the id used to send messages with this certification - can be a totally arbitrary value - :cert String a path to the certification or the certification file - as a string - :env String the environment to connect to apple with, always - either 'sandbox' or 'production' - :timoeut Number The timeout for the server to use when connecting - to the apple servers - -###PYAPNS::Notification -An APNS Notification - -You can construct notification objects ahead of time by using this class. -However unnecessary, it allows you to programmatically generate a Notification -like so: +#### Automated provisioning + +#### Production dependencies + +#### Example `supervisord` config - note = PYAPNS::Notification.new 'alert text', 9, 'flynn.caf', {:extra => 'guid'} - - -- or -- - note = PYAPNS::Notification.new 'alert text' - -These can be passed to `PYAPNS::Client#notify` the same as hashes +#### Multiple instances behind a reverse proxy diff --git a/example_conf.json b/example_conf.json index b435e6e..ab205c4 100644 --- a/example_conf.json +++ b/example_conf.json @@ -1,41 +1,17 @@ { + "host": "localhost", + "log_file_name": "pyapns.log", + "log_file_dir": ".", + "log_file_rotate_length": 1000000000, + "log_file_max_rotate": 10, "port": 7077, + "rest_port": 8088, "autoprovision": [ { - "app_id": "sandbox:com.ficture.ficturebeta", - "cert": "/Users/sam/dev/ficture/push_certs/development-com.ficture.ficturebeta.pem", + "app_id": "com.example.myapp", + "cert": "/path/to/cert.pem", "environment": "sandbox", "timeout": 15 - }, - { - "app_id": "production:com.ficture.ficturebeta", - "cert": "/Users/sam/dev/ficture/push_certs/production-com.ficture.ficturebeta.pem", - "environment": "production", - "timeout": 15 - }, - { - "app_id": "sandbox:com.ficture.ficturebeta2", - "cert": "/Users/sam/dev/ficture/push_certs/development-com.ficture.ficturebeta2.pem", - "environment": "sandbox", - "timeout": 15 - }, - { - "app_id": "production:com.ficture.ficturebeta2", - "cert": "/Users/sam/dev/ficture/push_certs/production-com.ficture.ficturebeta2.pem", - "environment": "production", - "timeout": 15 - }, - { - "app_id": "sandbox:com.ficture.ficturebeta3", - "cert": "/Users/sam/dev/ficture/push_certs/development-com.ficture.ficturebeta3.pem", - "environment": "sandbox", - "timeout": 15 - }, - { - "app_id": "production:com.ficture.ficturebeta3", - "cert": "/Users/sam/dev/ficture/push_certs/production-com.ficture.ficturebeta3.pem", - "environment": "production", - "timeout": 15 } ] } diff --git a/example_tac.tac b/example_tac.tac deleted file mode 100644 index 3e24e6b..0000000 --- a/example_tac.tac +++ /dev/null @@ -1,36 +0,0 @@ -# CONFIG FILE LOCATION -# relative to this file or absolute path - -config_file = 'example_conf.json' - -# you don't need to change anything below this line really - -import twisted.application, twisted.web, twisted.application.internet -import pyapns.server, pyapns._json -import os - -with open(os.path.abspath(config_file)) as f: - config = pyapns._json.loads(f.read()) - -application = twisted.application.service.Application("pyapns application") - -resource = twisted.web.resource.Resource() -service = pyapns.server.APNSServer() - -# get automatic provisioning -if 'autoprovision' in config: - for app in config['autoprovision']: - service.xmlrpc_provision(app['app_id'], app['cert'], app['environment'], - app['timeout']) - -# get port from config or 7077 -if 'port' in config: - port = config['port'] -else: - port = 7077 - -resource.putChild('', service) -site = twisted.web.server.Site(resource) - -server = twisted.application.internet.TCPServer(port, site) -server.setServiceParent(application) diff --git a/pyapns.tac b/pyapns.tac new file mode 100644 index 0000000..ff8b1bf --- /dev/null +++ b/pyapns.tac @@ -0,0 +1,96 @@ +# CONFIG FILE LOCATION +# relative to this file or absolute path + +config_file = '/path/to/config/pyapns_conf.json' + +# you don't need to change anything below this line really + +import twisted.application, twisted.web, twisted.application.internet +from twisted.python.logfile import LogFile +from twisted.python.log import ILogObserver, FileLogObserver +import pyapns.server, pyapns._json +import pyapns.rest_service, pyapns.model +import os + +config = {} + +if os.path.exists(os.path.abspath(config_file)): + with open(os.path.abspath(config_file)) as f: + config.update(pyapns._json.loads(f.read())) +else: + print 'No config file loaded. Alter the `config_file` variable at', \ + 'the top of this file to set one.' + +xml_service = pyapns.server.APNSServer() + +# get automatic provisioning +if 'autoprovision' in config: + for app in config['autoprovision']: + # for XML-RPC + xml_service.xmlrpc_provision(app['app_id'], app['cert'], + app['environment'], app['timeout']) + # for REST + pyapns.model.AppRegistry.put( + app['app_id'], app['environment'], app['cert'], + timeout=app['timeout'] + ) + +if 'log_file_name' in config: + log_file_name = config['log_file_name'] +else: + log_file_name = 'twistd.log' + +if 'log_file_dir' in config: + log_file_dir = config['log_file_dir'] +else: + log_file_dir = '.' + +if 'log_file_rotate_length' in config: + log_file_rotate_length = config['log_file_rotate_length'] +else: + log_file_rotate_length = 1000000 + +if 'log_file_mode' in config: + log_file_mode = config['log_file_mode'] +else: + log_file_mode = None + +if 'log_file_max_rotate' in config: + log_file_max_rotate = config['log_file_max_rotate'] +else: + log_file_max_rotate = None + +application = twisted.application.service.Application("pyapns application") +logfile = LogFile(log_file_name, log_file_dir, log_file_rotate_length, log_file_mode, log_file_max_rotate) +application.setComponent(ILogObserver, FileLogObserver(logfile).emit) + +if 'host' in config: + host = config['host'] +else: + host = '' + +# XML-RPC server support ------------------------------------------------------ + +if 'port' in config: + port = config['port'] +else: + port = 7077 + +resource = twisted.web.resource.Resource() +resource.putChild('', xml_service) + +site = twisted.web.server.Site(resource) + +server = twisted.application.internet.TCPServer(port, site, interface=host) +server.setServiceParent(application) + +# rest service support -------------------------------------------------------- +if 'rest_port' in config: + rest_port = config['rest_port'] +else: + rest_port = 8088 + +site = twisted.web.server.Site(pyapns.rest_service.default_resource) + +server = twisted.application.internet.TCPServer(rest_port, site, interface=host) +server.setServiceParent(application) diff --git a/pyapns/__init__.py b/pyapns/__init__.py index aa3eb86..50c443c 100644 --- a/pyapns/__init__.py +++ b/pyapns/__init__.py @@ -1 +1 @@ -from .client import notify, provision, feedback, configure __version__ = "0.4.0" __author__ = "Samuel Sutch" __license__ = "MIT" __copyright__ = "Copyrighit 2012 Samuel Sutch" \ No newline at end of file +from .client import notify, provision, feedback, configure __version__ = "0.5.0" __author__ = "Samuel Sutch" __license__ = "MIT" __copyright__ = "Copyrighit 2013 Samuel Sutch" \ No newline at end of file diff --git a/pyapns/_json.py b/pyapns/_json.py index dfaa881..57f051f 100644 --- a/pyapns/_json.py +++ b/pyapns/_json.py @@ -1,21 +1,34 @@ try: - import json + try: + import ujson # try for ujson first because it rocks and is fast as hell + class ujsonWrapper(object): + def dumps(self, obj, *args, **kwargs): + # ujson dumps method doesn't have separators keyword argument + if 'separators' in kwargs: + del kwargs['separators'] + return ujson.dumps(obj, *args, **kwargs) + + def loads(self, str, *args, **kwargs): + return ujson.loads(str, *args, **kwargs) + json = ujsonWrapper() + except ImportError: + import json except (ImportError, NameError): - try: - from django.utils import simplejson as json - except (ImportError, NameError): - import simplejson as json + try: + from django.utils import simplejson as json + except (ImportError, NameError): + import simplejson as json try: - json.dumps - json.loads + json.dumps + json.loads except AttributeError: - try: # monkey patching for python-json package - json.dumps = lambda obj, *args, **kwargs: json.write(obj) - json.loads = lambda str, *args, **kwargs: json.read(str) - except AttributeError: - raise ImportError('Could not load an apropriate JSON library ' - 'currently supported are simplejson, ' - 'python2.6+ json and python-json') + try: # monkey patching for python-json package + json.dumps = lambda obj, *args, **kwargs: json.write(obj) + json.loads = lambda str, *args, **kwargs: json.read(str) + except AttributeError: + raise ImportError('Could not load an apropriate JSON library ' + 'currently supported are simplejson, ' + 'python2.6+ json and python-json') loads = json.loads dumps = json.dumps diff --git a/pyapns/client.py b/pyapns/client.py index ca106a2..601c737 100644 --- a/pyapns/client.py +++ b/pyapns/client.py @@ -3,130 +3,324 @@ import httplib import functools from sys import hexversion +import requests +from pyapns import _json as json +from pyapns.model import Notification, DisconnectionEvent + + +class ClientError(Exception): + def __init__(self, message, response): + super(ClientError, self).__init__(message) + self.message = message + self.response = response + + +class Client(object): + @property + def connection(self): + con = getattr(self, '_connection', None) + if con is None: + con = self._connection = requests.Session() + return con + + def __init__(self, host='http://localhost', port=8088, timeout=20): + self.host = host.strip('/') + self.port = port + self.timeout = timeout + + def provision(self, app_id, environment, certificate, timeout=15): + """ + Tells the pyapns server that we want set up an app to receive + notifications from us. + + :param app_id: An app id, you can use anything but it's + recommended to just use the bundle identifier used in your app. + :type app_id: string + + :param environment: Which environment are you using? This value + must be either "production" or "sandbox". + :type environment: string + + :param certificate: A path to a encoded, password-free .pem file + on the pyapns host. This must be a path local to the host! You + can also read an entire .pem file in and send it in this value + as well. + :type certificate: string + + :returns: Dictionary-representation of the App record + :rtype: dict + """ + status, resp = self._request( + 'POST', 'apps/{}/{}'.format(app_id, environment), + data={'certificate': certificate, 'timeout': timeout} + ) + if status != 201: + raise ClientError('Unable to provision app id', resp) + return resp['response'] + + def notify(self, app_id, environment, notifications): + """ + Sends notifications to clients via the pyapns server. The + `app_id` and `environment` must be previously provisioned + values--either by using the :py:meth:`provision` method or + having been bootstrapped on the server. + + `notifications` is a list of notification dictionaries that all + must have the following keys: + + * `payload` is the actual notification dict to be jsonified + * `token` is the hexlified device token you scraped from + the client + * `identifier` is a unique id specific to this id. for this + you may use any value--pyapns will generate its own + internal ID to track it. The APN gateway only allows for + this to be 4 bytes. + * `expiry` is how long the notification should be retried + for if for some reason the apple servers can not contact + the device + + You can also construct a :py:class:`Notification` object--the + dict and class representations are interchangable here. + + :param app_id: Which app id to use + :type app_id: string + + :param environmenet: The environment for the app_id + :type environment: string + + :param notifications: A list of notification dictionaries + (see the discussion above) + :type notifications: list + + :returns: Empty response--this method doesn't return anything + :rtype: dict + """ + notes = [] + for note in notifications: + if isinstance(note, dict): + notes.append(Notification.from_simple(note)) + elif isinstance(note, Notification): + notes.append(note) + else: + raise ValueError('Unknown notification: {}'.format(repr(note))) + data = [n.to_simple() for n in notes] + + status, resp = self._request( + 'POST', 'apps/{}/{}/notifications'.format(app_id, environment), + data=data + ) + if status != 201: + raise ClientError('Could not send notifications', resp) + return resp['response'] + + def feedback(self, app_id, environment): + """ + Gets the from the APN feedback service. These are tokens that + Apple considers to be "dead" - that you should no longer attempt + to deliver to. + + Returns a list of dictionaries with the keys: + + * `timestamp` - the UTC timestamp when Apple determined the + token to be dead + * `token` - the hexlified version of the token + + :param app_id: Which app id to use + :type app_id: string + + :param environmenet: The environment for the app_id + :type environment: string + + :rtrype: list + """ + status, feedbacks = self._request( + 'GET', 'apps/{}/{}/feedback'.format(app_id, environment) + ) + if status != 200: + raise ClientError('Could not fetch feedbacks', feedbacks) + return feedbacks['response'] + + def disconnections(self, app_id, environment): + """ + Retrieves a list of the 5000 most recent disconnection events + recorded by pyapns. Each time apple severs the connection with + pyapns it will try to send back an error packet describing which + notification caused the error and the error that occurred. + + :param app_id: Which app id to use + :type app_id: string + + :param environmenet: The environment for the app_id + :type environment: string + + :rtype: list + """ + status, disconnects = self._request( + 'GET', 'apps/{}/{}/disconnections'.format(app_id, environment) + ) + if status != 200: + raise ClientError('Could not retrieve disconnections', disconnects) + ret = [] + for evt in disconnects['response']: + ret.append(DisconnectionEvent.from_simple(evt)) + return ret + + def _request(self, method, path, args=None, data=None): + url = '{}:{}/{}'.format(self.host, self.port, path) + kwargs = {'timeout': self.timeout} + if args is not None: + kwargs['params'] = args + if data is not None: + kwargs['data'] = json.dumps(data) + + func = getattr(self.connection, method.lower()) + resp = func(url, **kwargs) + if resp.headers['content-type'].startswith('application/json'): + resp_data = json.loads(resp.content) + else: + resp_data = None + return resp.status_code, resp_data + + +## OLD XML-RPC INTERFACE ------------------------------------------------------ OPTIONS = {'CONFIGURED': False, 'TIMEOUT': 20} + def configure(opts): - if not OPTIONS['CONFIGURED']: - try: # support for django - import django.conf - OPTIONS.update(django.conf.settings.PYAPNS_CONFIG) - OPTIONS['CONFIGURED'] = True - except: - pass if not OPTIONS['CONFIGURED']: - try: # support for programatic configuration - OPTIONS.update(opts) - OPTIONS['CONFIGURED'] = True - except: - pass - if not OPTIONS['CONFIGURED']: - try: # pylons support - import pylons.config - OPTIONS.update({'HOST': pylons.config.get('pyapns_host')}) - try: - OPTIONS.update({'TIMEOUT': int(pylons.config.get('pyapns_timeout'))}) + try: # support for django + import django.conf + OPTIONS.update(django.conf.settings.PYAPNS_CONFIG) + OPTIONS['CONFIGURED'] = True except: - pass # ignore, an optional value - OPTIONS['CONFIGURED'] = True - except: - pass - # provision initial app_ids - if 'INITIAL' in OPTIONS: - for args in OPTIONS['INITIAL']: - provision(*args) - return OPTIONS['CONFIGURED'] + pass + if not OPTIONS['CONFIGURED']: + try: # support for programatic configuration + OPTIONS.update(opts) + OPTIONS['CONFIGURED'] = True + except: + pass + if not OPTIONS['CONFIGURED']: + try: # pylons support + import pylons.config + OPTIONS.update({'HOST': pylons.config.get('pyapns_host')}) + try: + OPTIONS.update( + {'TIMEOUT': int(pylons.config.get('pyapns_timeout'))}) + except: + pass # ignore, an optional value + OPTIONS['CONFIGURED'] = True + except: + pass + # provision initial app_ids + if 'INITIAL' in OPTIONS: + for args in OPTIONS['INITIAL']: + provision(*args) + return OPTIONS['CONFIGURED'] + + +class UnknownAppID(Exception): + pass -class UnknownAppID(Exception): pass -class APNSNotConfigured(Exception): pass +class APNSNotConfigured(Exception): + pass + def reprovision_and_retry(func): - """ - Wraps the `errback` callback of the API functions, automatically trying to - re-provision if the app ID can not be found during the operation. If that's - unsuccessful, it will raise the UnknownAppID error. - """ - @functools.wraps(func) - def wrapper(*a, **kw): - errback = kw.get('errback', None) - if errback is None: - def errback(e): - raise e - def errback_wrapper(e): - if isinstance(e, UnknownAppID) and 'INITIAL' in OPTIONS: - try: - for initial in OPTIONS['INITIAL']: - provision(*initial) # retry provisioning the initial setup - func(*a, **kw) # and try the function once more - except Exception, new_exc: - errback(new_exc) # throwing the new exception - else: - errback(e) # not an instance of UnknownAppID - nothing we can do here - kw['errback'] = errback_wrapper - return func(*a, **kw) - return wrapper + """ + Wraps the `errback` callback of the API functions, automatically trying to + re-provision if the app ID can not be found during the operation. If that's + unsuccessful, it will raise the UnknownAppID error. + """ + @functools.wraps(func) + def wrapper(*a, **kw): + errback = kw.get('errback', None) + if errback is None: + def errback(e): + raise e + + def errback_wrapper(e): + if isinstance(e, UnknownAppID) and 'INITIAL' in OPTIONS: + try: + for initial in OPTIONS['INITIAL']: + provision( + *initial) # retry provisioning the initial setup + func(*a, **kw) # and try the function once more + except Exception, new_exc: + errback(new_exc) # throwing the new exception + else: + errback(e) # not an instance of UnknownAppID - nothing we can do here + kw['errback'] = errback_wrapper + return func(*a, **kw) + return wrapper + def default_callback(func): - @functools.wraps(func) - def wrapper(*a, **kw): - if 'callback' not in kw: - kw['callback'] = lambda c: c - return func(*a, **kw) - return wrapper + @functools.wraps(func) + def wrapper(*a, **kw): + if 'callback' not in kw: + kw['callback'] = lambda c: c + return func(*a, **kw) + return wrapper + @default_callback @reprovision_and_retry -def provision(app_id, path_to_cert, environment, timeout=15, async=False, +def provision(app_id, path_to_cert, environment, timeout=15, async=False, callback=None, errback=None): - args = [app_id, path_to_cert, environment, timeout] - f_args = ['provision', args, callback, errback] - if not async: - return _xmlrpc_thread(*f_args) - t = threading.Thread(target=_xmlrpc_thread, args=f_args) - t.daemon = True - t.start() + args = [app_id, path_to_cert, environment, timeout] + f_args = ['provision', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + @default_callback @reprovision_and_retry -def notify(app_id, tokens, notifications, async=False, callback=None, +def notify(app_id, tokens, notifications, async=False, callback=None, errback=None): - args = [app_id, tokens, notifications] - f_args = ['notify', args, callback, errback] - if not async: - return _xmlrpc_thread(*f_args) - t = threading.Thread(target=_xmlrpc_thread, args=f_args) - t.daemon = True - t.start() + args = [app_id, tokens, notifications] + f_args = ['notify', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + @default_callback @reprovision_and_retry def feedback(app_id, async=False, callback=None, errback=None): - args = [app_id] - f_args = ['feedback', args, callback, errback] - if not async: - return _xmlrpc_thread(*f_args) - t = threading.Thread(target=_xmlrpc_thread, args=f_args) - t.daemon = True - t.start() + args = [app_id] + f_args = ['feedback', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + def _xmlrpc_thread(method, args, callback, errback=None): - if not configure({}): - raise APNSNotConfigured('APNS Has not been configured.') - proxy = ServerProxy(OPTIONS['HOST'], allow_none=True, use_datetime=True, - timeout=OPTIONS['TIMEOUT']) - try: - parts = method.strip().split('.') - for part in parts: - proxy = getattr(proxy, part) - return callback(proxy(*args)) - except xmlrpclib.Fault, e: - if e.faultCode == 404: - e = UnknownAppID() - if errback is not None: - errback(e) - else: - raise e + if not configure({}): + raise APNSNotConfigured('APNS Has not been configured.') + proxy = ServerProxy(OPTIONS['HOST'], allow_none=True, use_datetime=True, + timeout=OPTIONS['TIMEOUT']) + try: + parts = method.strip().split('.') + for part in parts: + proxy = getattr(proxy, part) + return callback(proxy(*args)) + except xmlrpclib.Fault, e: + if e.faultCode == 404: + e = UnknownAppID() + if errback is not None: + errback(e) + else: + raise e ## -------------------------------------------------------------- @@ -135,29 +329,31 @@ def _xmlrpc_thread(method, args, callback, errback=None): ## -------------------------------------------------------------- def ServerProxy(url, *args, **kwargs): - t = TimeoutTransport() - t.timeout = kwargs.pop('timeout', 20) - kwargs['transport'] = t - return xmlrpclib.ServerProxy(url, *args, **kwargs) + t = TimeoutTransport() + t.timeout = kwargs.pop('timeout', 20) + kwargs['transport'] = t + return xmlrpclib.ServerProxy(url, *args, **kwargs) + class TimeoutTransport(xmlrpclib.Transport): - def make_connection(self, host): - if hexversion < 0x02070000: - conn = TimeoutHTTP(host) - conn.set_timeout(self.timeout) - else: - conn = TimeoutHTTPConnection(host) - conn.timeout = self.timeout - return conn + def make_connection(self, host): + if hexversion < 0x02070000: + conn = TimeoutHTTP(host) + conn.set_timeout(self.timeout) + else: + conn = TimeoutHTTPConnection(host) + conn.timeout = self.timeout + return conn + class TimeoutHTTPConnection(httplib.HTTPConnection): - def connect(self): - httplib.HTTPConnection.connect(self) - self.sock.settimeout(self.timeout) - + def connect(self): + httplib.HTTPConnection.connect(self) + self.sock.settimeout(self.timeout) + + class TimeoutHTTP(httplib.HTTP): - _connection_class = TimeoutHTTPConnection - - def set_timeout(self, timeout): - self._conn.timeout = timeout - \ No newline at end of file + _connection_class = TimeoutHTTPConnection + + def set_timeout(self, timeout): + self._conn.timeout = timeout diff --git a/pyapns/model.py b/pyapns/model.py new file mode 100644 index 0000000..6e54d39 --- /dev/null +++ b/pyapns/model.py @@ -0,0 +1,313 @@ +import struct +import datetime +import calendar +from collections import defaultdict, deque +from pyapns.server import APNSService, decode_feedback +from pyapns import _json as json + + +class NoSuchAppException(Exception): + pass + + +class AppRegistry(object): + # stored as [app_name][environment] = App() + apps = defaultdict(dict) + + @classmethod + def all_apps(cls): + for envs in cls.apps.values(): + for e in envs.values(): + yield e + + @classmethod + def get(cls, name, environment): + if name not in cls.apps or environment not in cls.apps[name]: + raise NoSuchAppException() + else: + return cls.apps[name][environment] + + @classmethod + def put(cls, name, environment, cert, **attrs): + app = App(name, environment, cert, **attrs) + cls.apps[name][environment] = app + return app + + +class App(object): + @property + def connection(self): + """ + The pyapns.server.APNSService object - a kind of lazy connection object + """ + r = getattr(self, '_connection', None) + if r is None: + r = self._connection = APNSService( + self.cert, self.environment, self.timeout, + on_failure_received=self._on_apns_error + ) + return r + + + def __init__(self, name, environment, cert_file, timeout=15): + self.name = name + self.environment = environment + self.cert = cert_file + self.timeout = timeout + self.disconnections_to_keep = 5000 + self.recent_notifications_to_keep = 10000 + + self.disconnections = deque(maxlen=self.disconnections_to_keep) + + self.recent_notification_idents = deque() # recent external notification idents + self.recent_notifications = {} # maps external idents to notifications + + self.internal_idents = {} # maps internal idents to external idents + self.ident_counter = 0 + + def notify(self, notifications): + """ + Send some notifications to devices using the APN gateway. + + * `notifications` is a list of `Notification` objects. + + * Returns a deferred that will be fired when the connection is + opened if the connection was previously closed, otherwise just + returns None + + If when sending a notification and the connection to the APN gateway + is lost, we will read back the error message sent by Apple (the + "enhanced" format) and try to figure out which notification is was + the offending one. If found, it will be appended to the list of recent + disconnections accessible with `disconnections()`. + + In order to figure out which notification was offensive in the case of + a disconnect, we must keep the last N notifications sent over the + socket, in right now that number is 10000. + + We will only store the most recent 5000 disconnections. You should + probably pull the notifications at least every day, perhaps more + frequently for high-volume installations. + """ + self.remember_recent_notifications(notifications) + return self.connection.write(encode_notifications(notifications)) + + def feedback(self): + """ + Gets a list of tokens that are identified by Apple to be invalid. + This clears the backlog of invalid tokens on Apple's servers so do + your best to not loose it! + """ + # this is for testing feedback parsing w/o draining your feedbacks + # from twisted.internet.defer import Deferred + # import struct + # d = Deferred() + # d.callback(struct.pack('!lh32s', 42, 32, 'e6e9cf3d0405ee61eac9552a5a17bff62a64a131d03a2e1638d06c25e105c1e5'.decode('hex'))) + d = self.connection.read() + def decode(raw_feedback): + feedbacks = decode_feedback(raw_feedback) + return [ + { + 'type': 'feedback', + 'timestamp': ( + float(calendar.timegm(ts.timetuple())) + + float(ts.microsecond) / 1e6 + ), + 'token': tok + } for ts, tok in feedbacks] + d.addCallback(decode) + return d + + def to_simple(self): + return { + 'name': self.name, + 'environment': self.environment, + 'certificate': self.cert, + 'timeout': self.timeout, + 'type': 'app' + } + + def _on_apns_error(self, raw_error): + self.remember_disconnection( + DisconnectionEvent.from_apn_wire_format(raw_error) + ) + + def get_next_ident(self): + """ + Available range is between 0x0 and 0xffff because the 'ident' field + of the APN packet is a ushort + """ + if self.ident_counter > 0xffff: + self.ident_count = 0 + else: + self.ident_counter += 1 + return self.ident_counter + + def remember_recent_notifications(self, notifications): + for note in reversed(notifications): + # check whether we already saw this notification, ignore if so + existing_note = self.recent_notifications.get(note.identifier, None) + if existing_note is not None: + # they have the same external ident so they can share the same interna + note.internal_identifier = existing_note.internal_identifier + continue + + # make room for a notification if the remembered notifications is full + if len(self.recent_notification_idents) >= self.recent_notifications_to_keep: + removed_ident = self.recent_notification_idents.popleft() + removed_note = self.recent_notifications.pop(removed_ident) + self.internal_idents.pop(removed_note.internal_identifier) + + # create a new internal identifier and map the notification to it + internal_ident = self.get_next_ident() + self.recent_notification_idents.append(note.identifier) + self.recent_notifications[note.identifier] = note + self.internal_idents[internal_ident] = note.identifier + note.internal_identifier = internal_ident + + def remember_disconnection(self, disconnection): + known_ident = self.internal_idents.get(disconnection.identifier, None) + if known_ident is not None and known_ident in self.recent_notifications: + disconnection.offending_notification = self.recent_notifications[known_ident] + self.disconnections.append(disconnection) + + +def encode_notifications(notifications): + return ''.join([n.to_apn_wire_format() for n in notifications]) + + +class Notification(object): + """ + A single notification being sent to the APN service. + + The fields are described as follows: + + * `payload` is the actual notification dict to be jsonified + * `token` is the hexlified device token you scraped from the client + * `identifier` is a unique id specific to this id. for this you + may use a UUID--but we will generate our own internal ID to track + it. The APN gateway only allows for this to be 4 bytes. + * `expiry` is how long the notification should be retried for if + for some reason the apple servers can not contact the device + """ + + __slots__ = ('token', 'payload', 'expiry', 'identifier', 'internal_identifier') + + def __init__(self, token=None, payload=None, expiry=None, identifier=None, + internal_identifier=None): + self.token = token + self.payload = payload + self.expiry = expiry + self.identifier = identifier + self.internal_identifier = internal_identifier + + @classmethod + def from_simple(cls, data, instance=None): + note = instance or cls() + note.token = data['token'] + note.payload = data['payload'] + note.expiry = int(data['expiry']) + note.identifier = data['identifier'] + return note + + def to_simple(self): + return { + 'type': 'notification', + 'expiry': self.expiry, + 'identifier': self.identifier, + 'payload': self.payload, + 'token': self.token + } + + def to_apn_wire_format(self): + fmt = '!BLLH32sH%ds' + structify = lambda t, i, e, p: struct.pack(fmt % len(p), 1, i, e, 32, + t, len(p), p) + binaryify = lambda t: t.decode('hex') + def binaryify(t): + try: + return t.decode('hex') + except TypeError, e: + raise ValueError( + 'token "{}" could not be decoded: {}'.format(str(t), str(e) + )) + + encoded_payload = json.dumps(self.payload, + separators=(',', ':')).encode('utf-8') + return structify(binaryify(self.token), self.internal_identifier, + self.expiry, encoded_payload) + + def __repr__(self): + return u''.format( + self.token, self.identifier, self.expiry, self.payload + ) + + +APNS_STATUS_CODES = { + 0: 'No errors encountered', + 1: 'Processing error', + 2: 'Missing device token', + 3: 'Missing topic', + 4: 'Missing payload', + 5: 'Invalid token size', + 6: 'Invalid topic size', + 7: 'Invalid payload size', + 8: 'Invalid token', + 10: 'Shutdown', + 255: 'None (unknown)' +} + + +class DisconnectionEvent(object): + __slots__ = ('code', 'offending_notification', 'timestamp', 'identifier') + + def __init__(self): + self.code = None + self.offending_notification = None + self.timestamp = None + self.identifier = None + + def to_simple(self): + return { + 'type': 'disconnection', + 'code': self.code, + 'internal_identifier': self.identifier, + 'offending_notification': ( + self.offending_notification.to_simple() + if self.offending_notification is not None else None + ), + 'timestamp': ( + float(calendar.timegm(self.timestamp.timetuple())) + + float(self.timestamp.microsecond) / 1e6 + ), + 'verbose_message': APNS_STATUS_CODES[self.code] + } + + @classmethod + def from_simple(cls, data): + evt = cls() + evt.code = data['code'] + evt.identifier = data['internal_identifier'] + evt.timestamp = datetime.datetime.utcfromtimestamp(data['timestamp']) + if 'offending_notification' in data: + evt.offending_notification = \ + Notification.from_simple(data['offending_notification']) + return evt + + @classmethod + def from_apn_wire_format(cls, packet): + fmt = '!Bbl' + cmd, code, ident = struct.unpack(fmt, packet) + + evt = cls() + evt.code = code + evt.timestamp = datetime.datetime.utcnow() + evt.identifier = ident + return evt + + def __repr__(self): + return ''.format( + self.identifier, APNS_STATUS_CODES[self.code], + self.offending_notification + ) + diff --git a/pyapns/rest_service.py b/pyapns/rest_service.py new file mode 100644 index 0000000..8adb863 --- /dev/null +++ b/pyapns/rest_service.py @@ -0,0 +1,190 @@ +import calendar +from twisted.web.resource import Resource, NoResource +from twisted.web.server import NOT_DONE_YET +from twisted.python import log +from pyapns.model import AppRegistry, NoSuchAppException, Notification +from pyapns import _json as json + + +PRODUCTION = 'production' +SANDBOX = 'sandbox' +ENVIRONMENTS = (PRODUCTION, SANDBOX) + + +class ErrorResource(Resource): + isLeaf = True + + def __init__(self, code, message, **attrs): + Resource.__init__(self) + self.code = code + self.message = message + self.attrs = attrs + + def render(self, request): + request.setResponseCode(self.code) + request.setHeader('content-type', 'application/json; charset=utf-8') + return json.dumps({ + 'code': self.code, + 'message': self.message, + 'type': 'error', + 'args': self.attrs + }) + + +def json_response(data, request, status_code=200): + request.setResponseCode(status_code) + request.setHeader('content-type', 'application/json; charset=utf-8') + return json.dumps({'code': status_code, 'response': data}) + + +# to handle /apps/ +class AppRootResource(Resource): + def getChild(self, name, request): + if name == '': + return self + else: + return AppResource(name) + + def render_GET(self, request): + apps = [app.to_simple() for app in AppRegistry.all_apps()] + return json_response(apps, request) + + +# to handle /apps// +class AppResource(Resource): + def __init__(self, app_name): + Resource.__init__(self) + self.app_name = app_name + + def getChild(self, name, request): + if name in ENVIRONMENTS: + return AppEnvironmentResource(self.app_name, name) + else: + return ErrorResource( + 404, 'Environment must be either `production` or `sandbox`', + environment=name, app=self.app_name + ) + + +# to handle /apps///()? +class AppEnvironmentResource(Resource): + def __init__(self, app_name, environment): + Resource.__init__(self) + self.app_name = app_name + self.environment = environment + + def getChild(self, name, request): + if name == '': + return self + + try: + app = AppRegistry.get(self.app_name, self.environment) + except NoSuchAppException: + return ErrorResource( + 404, 'No app registered under that name and environment', + name=self.app_name, + environment=name + ) + else: + if name == 'feedback': + return FeedbackResource(app) + elif name == 'notifications': + return NotificationResource(app) + elif name == 'disconnections': + return DisconnectionLogResource(app) + else: + return ErrorResource( + 404, 'Unknown resource', app=self.app_name, + environment=self.environment + ) + + def render_GET(self, request): + try: + app = AppRegistry.get(self.app_name, self.environment) + except NoSuchAppException: + return ErrorResource( + 404, 'No app registered under that name and environment', + name=self.app_name, + environment=self.environment + ).render(request) + else: + return json_response(app.to_simple(), request) + + def render_POST(self, request): + j = json.loads(request.content.read()) + if 'certificate' not in j: + return ErrorResource( + 400, '`certificate` is a required key. It must be either a ' + 'path to a .pem file or the contents of the pem itself' + ).render(request) + + kwargs = {} + if 'timeout' in j: + kwargs['timeout'] = int(j['timeout']) + + app = AppRegistry.put(self.app_name, self.environment, + j['certificate'], **kwargs) + + return json_response(app.to_simple(), request, 201) + + +class AppEnvResourceBase(Resource): + def __init__(self, app): + self.app = app + + +class NotificationResource(AppEnvResourceBase): + isLeaf = True + + def render_POST(self, request): + notifications = json.loads(request.content.read()) + is_list = isinstance(notifications, list) + if is_list: + is_all_dicts = len(notifications) == \ + sum(1 if (isinstance(el, dict) + and 'payload' in el + and 'token' in el + and 'identifier' in el + and 'expiry' in el) + else 0 for el in notifications) + else: + is_all_dicts = False + + if not is_list or not is_all_dicts: + return ErrorResource( + 400, 'Notifications must be a list of dictionaries in the ' + 'proper format: [' + '{"payload": {...}, "token": "...", "identifier": ' + '"...", "expiry": 30}]' + ).render(request) + + # returns a deferred but we're not making the client wait + self.app.notify([Notification.from_simple(n) for n in notifications]) + + return json_response({}, request, 201) + + +class DisconnectionLogResource(AppEnvResourceBase): + isLeaf = True + + def render_GET(self, request): + response = json_response([d.to_simple() for d in self.app.disconnections], + request) + self.app.disconnections.clear() + return response + + +class FeedbackResource(AppEnvResourceBase): + isLeaf = True + + def render_GET(self, request): + def on_done(feedbacks): + request.write(json_response(feedbacks, request)) + request.finish() + d = self.app.feedback() + d.addCallback(on_done) + return NOT_DONE_YET + + +default_resource = Resource() +default_resource.putChild('apps', AppRootResource()) diff --git a/pyapns/server.py b/pyapns/server.py index a4ec928..d7792fb 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -7,7 +7,7 @@ from OpenSSL import SSL, crypto from twisted.internet import reactor, defer from twisted.internet.protocol import ( - ReconnectingClientFactory, ClientFactory, Protocol, ServerFactory) + ReconnectingClientFactory, ClientFactory, Protocol, ServerFactory) from twisted.internet.ssl import ClientContextFactory from twisted.application import service from twisted.protocols.basic import LineReceiver @@ -22,329 +22,372 @@ FEEDBACK_SERVER_SANDBOX_HOSTNAME = "feedback.sandbox.push.apple.com" FEEDBACK_SERVER_HOSTNAME = "feedback.push.apple.com" FEEDBACK_SERVER_PORT = 2196 +FEEDBACK_SERVER_PORT = 2196 +MAX_CONNECTION_TIME = datetime.timedelta(minutes=60) + +app_ids = {} # {'app_id': APNSService()} -app_ids = {} # {'app_id': APNSService()} class StringIO(_StringIO): - """Add context management protocol to StringIO - ie: http://bugs.python.org/issue1286 - """ - - def __enter__(self): - if self.closed: - raise ValueError('I/O operation on closed file') - return self - - def __exit__(self, exc, value, tb): - self.close() + """Add context management protocol to StringIO + ie: http://bugs.python.org/issue1286 + """ + + def __enter__(self): + if self.closed: + raise ValueError('I/O operation on closed file') + return self + + def __exit__(self, exc, value, tb): + self.close() + class IAPNSService(Interface): """ Interface for APNS """ - + def write(self, notification): """ Write the notification to APNS """ - + def read(self): """ Read from the feedback service """ class APNSClientContextFactory(ClientContextFactory): - def __init__(self, ssl_cert_file): - if 'BEGIN CERTIFICATE' not in ssl_cert_file: - log.msg('APNSClientContextFactory ssl_cert_file=%s' % ssl_cert_file) - else: - log.msg('APNSClientContextFactory ssl_cert_file={FROM_STRING}') - self.ctx = SSL.Context(SSL.SSLv3_METHOD) - if 'BEGIN CERTIFICATE' in ssl_cert_file: - cer = crypto.load_certificate(crypto.FILETYPE_PEM, ssl_cert_file) - pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, ssl_cert_file) - self.ctx.use_certificate(cer) - self.ctx.use_privatekey(pkey) - else: - self.ctx.use_certificate_file(ssl_cert_file) - self.ctx.use_privatekey_file(ssl_cert_file) - - def getContext(self): - return self.ctx + def __init__(self, ssl_cert_file): + if 'BEGIN CERTIFICATE' not in ssl_cert_file: + log.msg( + 'APNSClientContextFactory ssl_cert_file=%s' % ssl_cert_file) + else: + log.msg('APNSClientContextFactory ssl_cert_file={FROM_STRING}') + self.ctx = SSL.Context(SSL.TLSv1_METHOD) + if 'BEGIN CERTIFICATE' in ssl_cert_file: + cer = crypto.load_certificate(crypto.FILETYPE_PEM, ssl_cert_file) + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, ssl_cert_file) + self.ctx.use_certificate(cer) + self.ctx.use_privatekey(pkey) + else: + self.ctx.use_certificate_file(ssl_cert_file) + self.ctx.use_privatekey_file(ssl_cert_file) + + def getContext(self): + return self.ctx class APNSProtocol(Protocol): - def connectionMade(self): - log.msg('APNSProtocol connectionMade') - self.factory.addClient(self) - - def sendMessage(self, msg): - log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg)) - return self.transport.write(msg) - - def connectionLost(self, reason): - log.msg('APNSProtocol connectionLost') - self.factory.removeClient(self) + onFailureReceived = None + + def connectionMade(self): + log.msg('APNSProtocol connectionMade') + self.factory.addClient(self) + + def sendMessage(self, msg): + log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg)) + return self.transport.write(msg) + + def dataReceived(self, data): + log.msg('data received after sendMessage data=%s' % data) + if callable(self.onFailureReceived): + self.onFailureReceived(data) + + def connectionLost(self, reason): + log.msg('APNSProtocol connectionLost') + self.factory.removeClient(self) class APNSFeedbackHandler(LineReceiver): - MAX_LENGTH = 1024*1024 - - def connectionMade(self): - log.msg('feedbackHandler connectionMade') + MAX_LENGTH = 1024 * 1024 + + def connectionMade(self): + log.msg('feedbackHandler connectionMade') - def rawDataReceived(self, data): - log.msg('feedbackHandler rawDataReceived %s' % binascii.hexlify(data)) - self.io.write(data) - - def lineReceived(self, data): - log.msg('feedbackHandler lineReceived %s' % binascii.hexlify(data)) - self.io.write(data) + def rawDataReceived(self, data): + log.msg('feedbackHandler rawDataReceived %s' % binascii.hexlify(data)) + self.io.write(data) - def connectionLost(self, reason): - log.msg('feedbackHandler connectionLost %s' % reason) - self.deferred.callback(self.io.getvalue()) - self.io.close() + def lineReceived(self, data): + log.msg('feedbackHandler lineReceived %s' % binascii.hexlify(data)) + self.io.write(data) + + def connectionLost(self, reason): + log.msg('feedbackHandler connectionLost %s' % reason) + self.deferred.callback(self.io.getvalue()) + self.io.close() class APNSFeedbackClientFactory(ClientFactory): - protocol = APNSFeedbackHandler - - def __init__(self): - self.deferred = defer.Deferred() - - def buildProtocol(self, addr): - p = self.protocol() - p.factory = self - p.deferred = self.deferred - p.io = StringIO() - p.setRawMode() - return p - - def startedConnecting(self, connector): - log.msg('APNSFeedbackClientFactory startedConnecting') - - def clientConnectionLost(self, connector, reason): - log.msg('APNSFeedbackClientFactory clientConnectionLost reason=%s' % reason) - ClientFactory.clientConnectionLost(self, connector, reason) - - def clientConnectionFailed(self, connector, reason): - log.msg('APNSFeedbackClientFactory clientConnectionFailed reason=%s' % reason) - ClientFactory.clientConnectionLost(self, connector, reason) + protocol = APNSFeedbackHandler + + def __init__(self): + self.deferred = defer.Deferred() + + def buildProtocol(self, addr): + p = self.protocol() + p.factory = self + p.deferred = self.deferred + p.io = StringIO() + p.setRawMode() + return p + + def startedConnecting(self, connector): + log.msg('APNSFeedbackClientFactory startedConnecting') + + def clientConnectionLost(self, connector, reason): + log.msg('APNSFeedbackClientFactory clientConnectionLost reason=%s' % + reason) + ClientFactory.clientConnectionLost(self, connector, reason) + + def clientConnectionFailed(self, connector, reason): + log.msg('APNSFeedbackClientFactory clientConnectionFailed reason=%s' % + reason) + ClientFactory.clientConnectionLost(self, connector, reason) class APNSClientFactory(ReconnectingClientFactory): - protocol = APNSProtocol - - def __init__(self): - self.clientProtocol = None - self.deferred = defer.Deferred() - self.deferred.addErrback(log_errback('APNSClientFactory __init__')) - - def addClient(self, p): - self.clientProtocol = p - self.deferred.callback(p) - - def removeClient(self, p): - self.clientProtocol = None - self.deferred = defer.Deferred() - self.deferred.addErrback(log_errback('APNSClientFactory removeClient')) - - def startedConnecting(self, connector): - log.msg('APNSClientFactory startedConnecting') - - def buildProtocol(self, addr): - self.resetDelay() - p = self.protocol() - p.factory = self - return p - - def clientConnectionLost(self, connector, reason): - log.msg('APNSClientFactory clientConnectionLost reason=%s' % reason) - ReconnectingClientFactory.clientConnectionLost(self, connector, reason) - - def clientConnectionFailed(self, connector, reason): - log.msg('APNSClientFactory clientConnectionFailed reason=%s' % reason) - ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + protocol = APNSProtocol + + def __init__(self): + self.clientProtocol = None + self.onFailureReceived = None + self.deferred = defer.Deferred() + self.deferred.addErrback(log_errback('APNSClientFactory __init__')) + + def addClient(self, p): + p.onFailureReceived = self.onFailureReceived + self.clientProtocol = p + self.deferred.callback(p) + + def removeClient(self, p): + self.clientProtocol = None + self.deferred = defer.Deferred() + self.deferred.addErrback(log_errback('APNSClientFactory removeClient')) + + def startedConnecting(self, connector): + log.msg('APNSClientFactory startedConnecting') + + def buildProtocol(self, addr): + self.resetDelay() + p = self.protocol() + p.factory = self + return p + + def clientConnectionLost(self, connector, reason): + log.msg('APNSClientFactory clientConnectionLost reason=%s' % reason) + ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + + def clientConnectionFailed(self, connector, reason): + log.msg('APNSClientFactory clientConnectionFailed reason=%s' % reason) + ReconnectingClientFactory.clientConnectionLost(self, connector, reason) class APNSService(service.Service): - """ A Service that sends notifications and receives - feedback from the Apple Push Notification Service - """ - - implements(IAPNSService) - clientProtocolFactory = APNSClientFactory - feedbackProtocolFactory = APNSFeedbackClientFactory - - def __init__(self, cert_path, environment, timeout=15): - log.msg('APNSService __init__') - self.factory = None - self.environment = environment - self.cert_path = cert_path - self.raw_mode = False - self.timeout = timeout - - def getContextFactory(self): - return APNSClientContextFactory(self.cert_path) - - def write(self, notifications): - "Connect to the APNS service and send notifications" - if not self.factory: - log.msg('APNSService write (connecting)') - server, port = ((APNS_SERVER_SANDBOX_HOSTNAME - if self.environment == 'sandbox' - else APNS_SERVER_HOSTNAME), APNS_SERVER_PORT) - self.factory = self.clientProtocolFactory() - context = self.getContextFactory() - reactor.connectSSL(server, port, self.factory, context) - - client = self.factory.clientProtocol - if client: - return client.sendMessage(notifications) - else: - d = self.factory.deferred - timeout = reactor.callLater(self.timeout, - lambda: d.called or d.errback( - Exception('Notification timed out after %i seconds' % self.timeout))) - def cancel_timeout(r): - try: timeout.cancel() - except: pass - return r - - d.addCallback(lambda p: p.sendMessage(notifications)) - d.addErrback(log_errback('apns-service-write')) - d.addBoth(cancel_timeout) - return d - - def read(self): - "Connect to the feedback service and read all data." - log.msg('APNSService read (connecting)') - try: - server, port = ((FEEDBACK_SERVER_SANDBOX_HOSTNAME - if self.environment == 'sandbox' - else FEEDBACK_SERVER_HOSTNAME), FEEDBACK_SERVER_PORT) - factory = self.feedbackProtocolFactory() - context = self.getContextFactory() - reactor.connectSSL(server, port, factory, context) - factory.deferred.addErrback(log_errback('apns-feedback-read')) - - timeout = reactor.callLater(self.timeout, - lambda: factory.deferred.called or factory.deferred.errback( - Exception('Feedbcak fetch timed out after %i seconds' % self.timeout))) - def cancel_timeout(r): - try: timeout.cancel() - except: pass - return r - - factory.deferred.addBoth(cancel_timeout) - except Exception, e: - log.err('APNService feedback error initializing: %s' % str(e)) - raise - return factory.deferred + """ A Service that sends notifications and receives + feedback from the Apple Push Notification Service + """ + + implements(IAPNSService) + clientProtocolFactory = APNSClientFactory + feedbackProtocolFactory = APNSFeedbackClientFactory + + def __init__(self, cert_path, environment, timeout=15, on_failure_received=lambda x:x): + log.msg('APNSService __init__') + self.factory = None + self.factory_connect_time = None + self.environment = environment + self.cert_path = cert_path + self.raw_mode = False + self.timeout = timeout + self.on_failure_received = on_failure_received + + def getContextFactory(self): + return APNSClientContextFactory(self.cert_path) + + def write(self, notifications): + "Connect to the APNS service and send notifications" + if self.factory: + conn_time = datetime.datetime.now() - self.factory_connect_time + if conn_time > MAX_CONNECTION_TIME: + log.msg('APNSService write (disconnecting based on max connection time)') + self.factory.clientProtocol.transport.loseConnection() + self.factory.stopTrying() + self.factory = None + + if not self.factory: + log.msg('APNSService write (connecting)') + server, port = ((APNS_SERVER_SANDBOX_HOSTNAME + if self.environment == 'sandbox' + else APNS_SERVER_HOSTNAME), APNS_SERVER_PORT) + self.factory = self.clientProtocolFactory() + self.factory.onFailureReceived = self.on_failure_received + context = self.getContextFactory() + reactor.connectSSL(server, port, self.factory, context) + self.factory_connect_time = datetime.datetime.now() + + client = self.factory.clientProtocol + if client: + return client.sendMessage(notifications) + else: + d = self.factory.deferred + timeout = reactor.callLater(self.timeout, + lambda: d.called or d.errback( + Exception('Notification timed out after %i seconds' % self.timeout))) + + def cancel_timeout(r): + try: + timeout.cancel() + except: + pass + return r + + def got_protocol(p): + p.onFailureReceived = self.on_failure_received + p.sendMessage(notifications) + return p + + d.addCallback(got_protocol) + d.addErrback(log_errback('apns-service-write')) + d.addBoth(cancel_timeout) + return d + + def read(self): + "Connect to the feedback service and read all data." + log.msg('APNSService read (connecting)') + try: + server, port = ((FEEDBACK_SERVER_SANDBOX_HOSTNAME + if self.environment == 'sandbox' + else FEEDBACK_SERVER_HOSTNAME), FEEDBACK_SERVER_PORT) + factory = self.feedbackProtocolFactory() + context = self.getContextFactory() + reactor.connectSSL(server, port, factory, context) + factory.deferred.addErrback(log_errback('apns-feedback-read')) + + timeout = reactor.callLater(self.timeout, + lambda: factory.deferred.called or factory.deferred.errback( + Exception('Feedbcak fetch timed out after %i seconds' % self.timeout))) + + def cancel_timeout(r): + try: + timeout.cancel() + except: + pass + return r + + factory.deferred.addBoth(cancel_timeout) + except Exception, e: + log.err('APNService feedback error initializing: %s' % str(e)) + raise + return factory.deferred class APNSServer(xmlrpc.XMLRPC): - def __init__(self): - self.app_ids = app_ids - self.use_date_time = True - self.useDateTime = True - xmlrpc.XMLRPC.__init__(self, allowNone=True) - - def apns_service(self, app_id): - if app_id not in app_ids: - raise xmlrpc.Fault(404, 'The app_id specified has not been provisioned.') - return self.app_ids[app_id] - - def xmlrpc_provision(self, app_id, path_to_cert_or_cert, environment, timeout=15): - """ Starts an APNSService for the this app_id and keeps it running - - Arguments: - app_id the app_id to provision for APNS - path_to_cert_or_cert absolute path to the APNS SSL cert or a - string containing the .pem file - environment either 'sandbox' or 'production' - timeout seconds to timeout connection attempts - to the APNS server - Returns: - None - """ - - if environment not in ('sandbox', 'production'): - raise xmlrpc.Fault(401, 'Invalid environment provided `%s`. Valid ' - 'environments are `sandbox` and `production`' % ( - environment,)) - if not app_id in self.app_ids: - # log.msg('provisioning ' + app_id + ' environment ' + environment) - self.app_ids[app_id] = APNSService(path_to_cert_or_cert, environment, timeout) - - def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): - """ Sends push notifications to the Apple APNS server. Multiple - notifications can be sent by sending pairing the token/notification - arguments in lists [token1, token2], [notification1, notification2]. - - Arguments: - app_id provisioned app_id to send to - token_or_token_list token to send the notification or a list of tokens - aps_dict_or_list notification dicts or a list of notifications - Returns: - None - """ - d = self.apns_service(app_id).write( - encode_notifications( - [t.replace(' ', '') for t in token_or_token_list] - if (type(token_or_token_list) is list) - else token_or_token_list.replace(' ', ''), - aps_dict_or_list)) - if d: - def _finish_err(r): - # so far, the only error that could really become of this - # request is a timeout, since APNS simply terminates connectons - # that are made unsuccessfully, which twisted will try endlessly - # to reconnect to, we timeout and notifify the client - raise xmlrpc.Fault(500, 'Connection to the APNS server could not be made.') - return d.addCallbacks(lambda r: None, _finish_err) - - def xmlrpc_feedback(self, app_id): - """ Queries the Apple APNS feedback server for inactive app tokens. Returns - a list of tuples as (datetime_went_dark, token_str). - - Arguments: - app_id the app_id to query - Returns: - Feedback tuples like (datetime_expired, token_str) - """ - - return self.apns_service(app_id).read().addCallback( - lambda r: decode_feedback(r)) + def __init__(self): + self.app_ids = app_ids + self.use_date_time = True + self.useDateTime = True + xmlrpc.XMLRPC.__init__(self, allowNone=True) + + def apns_service(self, app_id): + if app_id not in app_ids: + raise xmlrpc.Fault( + 404, 'The app_id specified has not been provisioned.') + return self.app_ids[app_id] + + def xmlrpc_provision(self, app_id, path_to_cert_or_cert, environment, timeout=15): + """ Starts an APNSService for the this app_id and keeps it running + + Arguments: + app_id the app_id to provision for APNS + path_to_cert_or_cert absolute path to the APNS SSL cert or a + string containing the .pem file + environment either 'sandbox' or 'production' + timeout seconds to timeout connection attempts + to the APNS server + Returns: + None + """ + + if environment not in ('sandbox', 'production'): + raise xmlrpc.Fault(401, 'Invalid environment provided `%s`. Valid ' + 'environments are `sandbox` and `production`' % ( + environment,)) + if not app_id in self.app_ids: + # log.msg('provisioning ' + app_id + ' environment ' + environment) + self.app_ids[app_id] = APNSService( + path_to_cert_or_cert, environment, timeout) + + def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): + """ Sends push notifications to the Apple APNS server. Multiple + notifications can be sent by sending pairing the token/notification + arguments in lists [token1, token2], [notification1, notification2]. + + Arguments: + app_id provisioned app_id to send to + token_or_token_list token to send the notification or a list of tokens + aps_dict_or_list notification dicts or a list of notifications + Returns: + None + """ + d = self.apns_service(app_id).write( + encode_notifications( + [t.replace(' ', '') for t in token_or_token_list] + if (type(token_or_token_list) is list) + else token_or_token_list.replace(' ', ''), + aps_dict_or_list)) + if d: + def _finish_err(r): + # so far, the only error that could really become of this + # request is a timeout, since APNS simply terminates connectons + # that are made unsuccessfully, which twisted will try endlessly + # to reconnect to, we timeout and notifify the client + raise xmlrpc.Fault( + 500, 'Connection to the APNS server could not be made.') + return d.addCallbacks(lambda r: None, _finish_err) + + def xmlrpc_feedback(self, app_id): + """ Queries the Apple APNS feedback server for inactive app tokens. Returns + a list of tuples as (datetime_went_dark, token_str). + + Arguments: + app_id the app_id to query + Returns: + Feedback tuples like (datetime_expired, token_str) + """ + + return self.apns_service(app_id).read().addCallback( + lambda r: decode_feedback(r)) def encode_notifications(tokens, notifications): - """ Returns the encoded bytes of tokens and notifications - - tokens a list of tokens or a string of only one token - notifications a list of notifications or a dictionary of only one - """ - - fmt = "!BH32sH%ds" - structify = lambda t, p: struct.pack(fmt % len(p), 0, 32, t, len(p), p) - binaryify = lambda t: t.decode('hex') - if type(notifications) is dict and type(tokens) in (str, unicode): - tokens, notifications = ([tokens], [notifications]) - if type(notifications) is list and type(tokens) is list: - return ''.join(map(lambda y: structify(*y), ((binaryify(t), json.dumps(p, separators=(',',':'), ensure_ascii=False).encode('utf-8')) - for t, p in zip(tokens, notifications)))) + """ Returns the encoded bytes of tokens and notifications + + tokens a list of tokens or a string of only one token + notifications a list of notifications or a dictionary of only one + """ + + fmt = "!BH32sH%ds" + structify = lambda t, p: struct.pack(fmt % len(p), 0, 32, t, len(p), p) + binaryify = lambda t: t.decode('hex') + if type(notifications) is dict and type(tokens) in (str, unicode): + tokens, notifications = ([tokens], [notifications]) + if type(notifications) is list and type(tokens) is list: + return ''.join(map(lambda y: structify(*y), ((binaryify(t), json.dumps(p, separators=(',', ':'), ensure_ascii=False).encode('utf-8')) + for t, p in zip(tokens, notifications)))) + def decode_feedback(binary_tuples): - """ Returns a list of tuples in (datetime, token_str) format - - binary_tuples the binary-encoded feedback tuples - """ - - fmt = '!lh32s' - size = struct.calcsize(fmt) - with StringIO(binary_tuples) as f: - return [(datetime.datetime.fromtimestamp(ts), binascii.hexlify(tok)) - for ts, toklen, tok in (struct.unpack(fmt, tup) - for tup in iter(lambda: f.read(size), ''))] + """ Returns a list of tuples in (datetime, token_str) format + + binary_tuples the binary-encoded feedback tuples + """ + + fmt = '!lh32s' + size = struct.calcsize(fmt) + with StringIO(binary_tuples) as f: + return [(datetime.datetime.utcfromtimestamp(ts), binascii.hexlify(tok)) + for ts, toklen, tok in (struct.unpack(fmt, tup) + for tup in iter(lambda: f.read(size), ''))] def log_errback(name): - def _log_errback(err, *args): - log.err('errback in %s : %s' % (name, str(err))) - return err - return _log_errback + def _log_errback(err, *args): + log.err('errback in %s : %s' % (name, str(err))) + return err + return _log_errback diff --git a/requirements.txt b/requirements.txt index c6bf5d0..7d80f13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -Twisted>=8.2.0 +Twisted>=11.0.0 pyOpenSSL>=0.10 +requests>=1.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 28b187a..f57bc3c 100755 --- a/setup.py +++ b/setup.py @@ -60,5 +60,5 @@ 'Topic :: Software Development :: Libraries :: Python Modules'], packages=['pyapns'], package_data={}, - install_requires=['Twisted>=8.2.0', 'pyOpenSSL>=0.10'] + install_requires=['Twisted>=8.2.0', 'pyOpenSSL>=0.10', 'requests>=1.0'] )