Skip to content

Commit

Permalink
Add HTTP API
Browse files Browse the repository at this point in the history
  • Loading branch information
amotl committed Jun 7, 2020
1 parent 4d610de commit bf9d801
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 43 deletions.
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ in progress
===========


2020-06-08 0.2.0
================
- Add HTTP API


2020-06-06 0.1.1
================
- Update slogan
Expand Down
21 changes: 19 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ With service API::
Features
********
- Colorspace conversion: monochrome, grayscale
- Cropping with negative right/bottom padding
- Cropping with negative right/bottom offsets
- Resizing while keeping aspect ratio
- Output format: Any image formats or bytes
- Output format: Any image formats from Pillow or raw bytes
- HTTP API


Expand Down Expand Up @@ -85,3 +85,20 @@ Start the Imagecast service as daemon::
Example::

/?uri=https%3A%2F%2Funsplash.com%2Fphotos%2FWvdKljW55rM%2Fdownload%3Fforce%3Dtrue&monochrome=80&crop=850,1925,-950,-900&width=640

.. note::

You should not run the service without restricting the
list of allowed remote hosts on the public internet.

To do that, invoke the service like::

imagecast service --allowed-hosts=unsplash.com,media.example.org


**************
Other projects
**************
- https://github.com/DictGet/ecce-homo
- https://github.com/agschwender/pilbox
- https://github.com/francescortiz/image
101 changes: 101 additions & 0 deletions imagecast/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# (c) 2020 Andreas Motl <andreas@terkin.org>
# License: GNU Affero General Public License, Version 3
import io
import logging
from dataclasses import dataclass
from typing import List

from PIL import Image
from fastapi import FastAPI, Query, Depends, HTTPException
from fastapi.responses import HTMLResponse, PlainTextResponse
from httptools.parser.parser import parse_url
from pydantic import BaseSettings
from starlette.responses import StreamingResponse
from starlette.status import HTTP_403_FORBIDDEN

from imagecast import __appname__, __version__
from imagecast.core import process


# https://fastapi.tiangolo.com/advanced/settings/
class Settings(BaseSettings):
allowed_hosts: List[str] = []


settings = Settings()
app = FastAPI()

log = logging.getLogger(__name__)


# Using pydantic models for GET request query params.
# https://github.com/tiangolo/fastapi/issues/318#issuecomment-584020926
@dataclass
class QueryOptions:
uri: str = Query(default=None)
monochrome: int = Query(default=None)
grayscale: bool = Query(default=False)
crop: str = Query(default=None)
width: int = Query(default=None)
height: int = Query(default=None)
dpi: int = Query(default=72)
format: str = Query(default=None)


@app.get("/")
def index(options: QueryOptions = Depends(QueryOptions)):
appname = f'{__appname__} {__version__}'
about = 'Imagecast is like ImageMagick but for Pythonistas. Optionally provides its features via HTTP API.'

if options.uri:

# Protect the service from accessing arbitrary remote URIs.
uri_parsed = parse_url(options.uri.encode('utf-8'))
remote_host = uri_parsed.host.decode()
if '*' not in settings.allowed_hosts and remote_host not in settings.allowed_hosts:
raise HTTPException(status_code=HTTP_403_FORBIDDEN)

ie = process(options)

options.format = options.format or ie.image.format

if options.format == 'bytes':
buffer = ie.to_bytes()
else:
buffer = ie.to_buffer(options.format, options.dpi)

mime_type = Image.MIME.get(options.format)

return StreamingResponse(io.BytesIO(buffer), media_type=mime_type)
else:
return HTMLResponse(f"""
<html>
<head>
<title>{appname}</title>
</head>
<body>
<h3>About</h3>
{about}
<h3>Examples</h3>
<ul>
<li><a href="?uri=https%3A%2F%2Funsplash.com%2Fphotos%2FWvdKljW55rM%2Fdownload%3Fforce%3Dtrue&monochrome=80&crop=850,1925,-950,-900&width=640&format=png">Unsplash example</a></li>
</ul>
</body>
</html>
""")


@app.get("/robots.txt", response_class=PlainTextResponse)
def robots():
return f"""
User-agent: *
Disallow: /
""".strip()


def start_service(listen_address):
host, port = listen_address.split(':')
port = int(port)
from uvicorn.main import run
run(app='imagecast.api:app', host=host, port=port, reload=True)
29 changes: 8 additions & 21 deletions imagecast/cli.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# -*- coding: utf-8 -*-
# (c) 2020 Andreas Motl <andreas@hiveeyes.org>
# License: GNU Affero General Public License, Version 3
import os
import sys
import json
import logging

from docopt import docopt, DocoptExit

from imagecast import __appname__, __version__
from imagecast.core import ImageEngine
from imagecast.core import process
from imagecast.util import normalize_options, setup_logging

log = logging.getLogger(__name__)
Expand All @@ -20,7 +21,7 @@ def run():
Usage:
imagecast --uri=<uri> [--monochrome=<threshold>] [--grayscale] [--width=<width>] [--height=<height>] [--crop=<cropbox>] [--display] [--format=<format>] [--dpi=<dpi>] [--save=<save>]
imagecast service [--listen=<listen>]
imagecast service [--listen=<listen>] [--allowed-hosts=<allowed-hosts>]
imagecast --version
imagecast (-h | --help)
Expand All @@ -36,6 +37,7 @@ def run():
--dpi=<dpi> Output DPI. [Default: 72]
--save=<save> Path to output file
--listen=<listen> HTTP server listen address. [Default: localhost:9999]
--allowed-hosts=<allowed-hosts> Allowed hosts to request images from. [Default: *]
--version Show version information
--debug Enable debug messages
-h --help Show this screen
Expand All @@ -61,6 +63,9 @@ def run():

# Run service.
if options.service:

os.environ['ALLOWED_HOSTS'] = json.dumps(options.allowed_hosts.split(','))

listen_address = options.listen
log.info(f'Starting {name}')
log.info(f'Starting web service on {listen_address}')
Expand All @@ -73,25 +78,7 @@ def run():
if not (options.display or options.save or options.format):
raise KeyError('Please specify one of "--display", "--save" or "--format"')

ie = ImageEngine()
ie.download(options.uri)
ie.read()

if options.monochrome:
ie.monochrome(int(options.monochrome))

if options.grayscale:
ie.grayscale()

# (left, top, right, bottom)
if options.crop:
cropbox = map(int, options.crop.split(','))
ie.crop(cropbox)

if options.width:
ie.resize_width(int(options.width))
if options.height:
ie.resize_height(int(options.height))
ie = process(options)

dpi = int(options.dpi)

Expand Down
36 changes: 33 additions & 3 deletions imagecast/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@
# License: GNU Affero General Public License, Version 3
import io
import requests
import ttl_cache
from PIL import Image


@ttl_cache(15)
def fetch_cached(uri):
response = requests.get(uri)
return response.content


class ImageEngine:

def __init__(self):
self.data = None
self.image = None

def download(self, url):
response = requests.get(url)
self.data = response.content
def download(self, uri):
self.data = fetch_cached(uri)

def read(self):
self.image = Image.open(io.BytesIO(self.data))
Expand Down Expand Up @@ -62,3 +68,27 @@ def to_buffer(self, format, dpi):
buffer = io.BytesIO()
self.image.save(buffer, format=format, dpi=(dpi, dpi))
return buffer.getvalue()


def process(options):
ie = ImageEngine()
ie.download(options.uri)
ie.read()

if options.monochrome:
ie.monochrome(int(options.monochrome))

if options.grayscale:
ie.grayscale()

# (left, top, right, bottom)
if options.crop:
cropbox = map(int, options.crop.split(','))
ie.crop(cropbox)

if options.width:
ie.resize_width(int(options.width))
if options.height:
ie.resize_height(int(options.height))

return ie
35 changes: 18 additions & 17 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from setuptools import setup, find_packages

here = os.path.abspath(os.path.dirname(__file__))
README = open(os.path.join(here, 'README.rst')).read()
README = open(os.path.join(here, "README.rst")).read()

setup(name='imagecast',
version='0.1.1',
description='Imagecast is like ImageMagick but for Pythonistas. Optionally provides its features via HTTP API.',
setup(name="imagecast",
version="0.1.1",
description="Imagecast is like ImageMagick but for Pythonistas. Optionally provides its features via HTTP API.",
long_description=README,
license="AGPL 3, EUPL 1.2",
classifiers=[
Expand Down Expand Up @@ -38,30 +38,31 @@
"Operating System :: Unix",
"Operating System :: MacOS"
],
author='Andreas Motl',
author_email='andreas.motl@panodata.org',
url='https://github.com/panodata/imagecast',
keywords='image conversion http api proxy',
author="Andreas Motl",
author_email="andreas.motl@panodata.org",
url="https://github.com/panodata/imagecast",
keywords="image conversion http api proxy",
packages=find_packages(),
include_package_data=True,
package_data={
},
zip_safe=False,
install_requires=[
'docopt==0.6.2',
'munch==2.3.2',
'Pillow==7.1.2',
'requests==2.23.0',
"docopt==0.6.2",
"munch==2.3.2",
"Pillow==7.1.2",
"requests==2.23.0",
"ttl-cache==1.6",
],
extras_require={
'service': [
'fastapi==0.55.1',
'uvicorn==0.11.5',
"service": [
"fastapi==0.55.1",
"uvicorn==0.11.5",
],
},
entry_points={
'console_scripts': [
'imagecast = imagecast.cli:run',
"console_scripts": [
"imagecast = imagecast.cli:run",
],
},
)

0 comments on commit bf9d801

Please sign in to comment.