├── .gitignore ├── Pipfile ├── Pipfile.lock ├── README.md ├── docker-compose.yml ├── requirements.txt ├── zc-client ├── Dockerfile ├── app.py └── registration.py └── zc-gateway ├── Dockerfile ├── __init__.py ├── app.py └── discovery.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | flask = "*" 10 | zeroconf = "*" 11 | requests = "*" 12 | 13 | [requires] 14 | python_version = "3.7" 15 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "5c0dd6639c837e6b6e6b40cc720f8781196768cbf3ce0e2a34bcf655d3f88646" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", 22 | "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" 23 | ], 24 | "version": "==2019.9.11" 25 | }, 26 | "chardet": { 27 | "hashes": [ 28 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 29 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 30 | ], 31 | "version": "==3.0.4" 32 | }, 33 | "click": { 34 | "hashes": [ 35 | "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", 36 | "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" 37 | ], 38 | "version": "==7.0" 39 | }, 40 | "flask": { 41 | "hashes": [ 42 | "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", 43 | "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6" 44 | ], 45 | "index": "pypi", 46 | "version": "==1.1.1" 47 | }, 48 | "idna": { 49 | "hashes": [ 50 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 51 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 52 | ], 53 | "version": "==2.8" 54 | }, 55 | "ifaddr": { 56 | "hashes": [ 57 | "sha256:c19c64882a7ad51a394451dabcbbed72e98b5625ec1e79789924d5ea3e3ecb93" 58 | ], 59 | "version": "==0.1.6" 60 | }, 61 | "itsdangerous": { 62 | "hashes": [ 63 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 64 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 65 | ], 66 | "version": "==1.1.0" 67 | }, 68 | "jinja2": { 69 | "hashes": [ 70 | "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", 71 | "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" 72 | ], 73 | "version": "==2.10.3" 74 | }, 75 | "markupsafe": { 76 | "hashes": [ 77 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 78 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 79 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 80 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 81 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 82 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 83 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 84 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 85 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 86 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 87 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 88 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 89 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 90 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 91 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 92 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 93 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 94 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 95 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 96 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 97 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 98 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 99 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 100 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 101 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 102 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 103 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 104 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" 105 | ], 106 | "version": "==1.1.1" 107 | }, 108 | "requests": { 109 | "hashes": [ 110 | "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", 111 | "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" 112 | ], 113 | "index": "pypi", 114 | "version": "==2.22.0" 115 | }, 116 | "urllib3": { 117 | "hashes": [ 118 | "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", 119 | "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" 120 | ], 121 | "version": "==1.25.6" 122 | }, 123 | "werkzeug": { 124 | "hashes": [ 125 | "sha256:7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7", 126 | "sha256:e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4" 127 | ], 128 | "version": "==0.16.0" 129 | }, 130 | "zeroconf": { 131 | "hashes": [ 132 | "sha256:21d02538ff52fc572e1d785c692b97b8d4374623cb95d593cc06ab92bd5aaf61", 133 | "sha256:e0c333b967c48f8b2e5cc94a1d4d28893023fb06dfd797ee384a94cdd1d0eef5" 134 | ], 135 | "index": "pypi", 136 | "version": "==0.23.0" 137 | } 138 | }, 139 | "develop": {} 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple API gateway using python-zeroconf 2 | 3 | Sample implementation of an API gateway with automatic service discovery using the `python-zeroconf` library. Both gateway and services 4 | are executed in different Docker containers. 5 | 6 | ### Usage 7 | 8 | In order to execute this code you should have the Docker daemon running on your environment and also the `docker-compose` utility 9 | installed. For running the API gateway and two sample services just execute: 10 | ``` 11 | docker-compose up -d 12 | ``` 13 | 14 | Sample information on the automatically discovered services can be accessed at `http://0.0.0.0:4999/services`. 15 | 16 | ### Purpose 17 | 18 | This is only a sample implementation but it can be used as blueprint for much more complicated programs which use a microservice architecture. Beware that 19 | that the services must be developed in Python. 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | zc-gateway: 4 | build: 5 | context: "./" 6 | dockerfile: "./zc-gateway/Dockerfile" 7 | ports: 8 | - "4999:4999" 9 | zc-client-1: 10 | build: 11 | context: "./" 12 | dockerfile: "./zc-client/Dockerfile" 13 | ports: 14 | - "5001:5001" 15 | environment: 16 | - ZC_NAME=zc-client-1 17 | - ZC_PORT=5001 18 | zc-client-2: 19 | build: 20 | context: "./" 21 | dockerfile: "./zc-client/Dockerfile" 22 | ports: 23 | - "5002:5002" 24 | environment: 25 | - ZC_NAME=zc-client-2 26 | - ZC_PORT=5002 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2019.9.11 2 | chardet==3.0.4 3 | Click==7.0 4 | Flask==1.1.1 5 | idna==2.8 6 | ifaddr==0.1.6 7 | itsdangerous==1.1.0 8 | Jinja2==2.10.3 9 | MarkupSafe==1.1.1 10 | requests==2.22.0 11 | urllib3==1.25.6 12 | Werkzeug==0.16.0 13 | zeroconf==0.23.0 14 | -------------------------------------------------------------------------------- /zc-client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | 3 | RUN mkdir -p /app 4 | 5 | COPY ./zc-client /app 6 | COPY ./requirements.txt /app 7 | RUN pip install --trusted-host pypi.python.org -r /app/requirements.txt 8 | 9 | WORKDIR /app 10 | CMD ["python", "app.py"] 11 | -------------------------------------------------------------------------------- /zc-client/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask, jsonify 3 | from registration import ZeroConfRegistration 4 | 5 | NAME = "zc-client-1" if "ZC_NAME" not in os.environ else os.environ["ZC_NAME"] 6 | PORT = 5000 if "ZC_PORT" not in os.environ else int(os.environ["ZC_PORT"]) 7 | 8 | app = Flask(NAME) 9 | 10 | 11 | @app.route("/info") 12 | def info(): 13 | res = { 14 | "name": NAME, 15 | "response": f"Hello, I am the service {NAME}" 16 | } 17 | return jsonify(res) 18 | 19 | 20 | if __name__ == "__main__": 21 | 22 | # register the service 23 | zc_register = ZeroConfRegistration(NAME, PORT) 24 | 25 | kwargs = { 26 | "host": "0.0.0.0", 27 | "port": PORT 28 | } 29 | try: 30 | app.run(**kwargs) 31 | except (Exception, KeyboardInterrupt) as e: 32 | pass 33 | finally: 34 | # always unregister the service before terminating the application 35 | zc_register.unregister() 36 | -------------------------------------------------------------------------------- /zc-client/registration.py: -------------------------------------------------------------------------------- 1 | from socket import inet_aton 2 | from zeroconf import Zeroconf, ServiceInfo 3 | 4 | 5 | class ZeroConfRegistration: 6 | 7 | def __init__(self, name, port): 8 | self.name = name 9 | self.port = port 10 | self.zeroconf = Zeroconf() 11 | 12 | desc: dict = { 13 | 'docker_address': f"{name}:{port}", 14 | } 15 | self.info = ServiceInfo( 16 | "_http._tcp.local.", 17 | f"{self.name}._http._tcp.local.", 18 | addresses=[inet_aton("127.0.0.1")], 19 | port=self.port, 20 | properties=desc, 21 | ) 22 | self.zeroconf.register_service(self.info) 23 | 24 | def unregister(self): 25 | self.zeroconf.unregister_service(self.info) 26 | self.zeroconf.close() 27 | -------------------------------------------------------------------------------- /zc-gateway/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | 3 | RUN mkdir -p /app 4 | 5 | COPY ./zc-gateway /app 6 | COPY ./requirements.txt /app 7 | RUN pip install --trusted-host pypi.python.org -r /app/requirements.txt 8 | 9 | WORKDIR /app 10 | CMD ["python", "app.py"] 11 | -------------------------------------------------------------------------------- /zc-gateway/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madagra/python-api-gateway/cac972ff9732a9d68a3bbff69e0646b2b4bc4c9d/zc-gateway/__init__.py -------------------------------------------------------------------------------- /zc-gateway/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify 2 | import requests 3 | from discovery import ZeroConfDiscovery 4 | 5 | app = Flask(__name__) 6 | zc_discovery = ZeroConfDiscovery() 7 | 8 | 9 | @app.route("/services") 10 | def services(): 11 | res = {"services": {}} 12 | for a in zc_discovery.services.values(): 13 | # a = a.split(":") 14 | info = requests.get(f"http://{a}/info") 15 | r = info.json() 16 | res["services"][r["name"]] = r["response"] 17 | return jsonify(res) 18 | 19 | 20 | if __name__ == "__main__": 21 | 22 | zc_discovery.start() 23 | 24 | kwargs = { 25 | "host": "0.0.0.0", 26 | "port": 4999, 27 | } 28 | 29 | try: 30 | app.run(**kwargs) 31 | except (Exception, KeyboardInterrupt) as e: 32 | pass 33 | -------------------------------------------------------------------------------- /zc-gateway/discovery.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from threading import Thread 3 | from zeroconf import ServiceBrowser, Zeroconf, ZeroconfServiceTypes, ServiceStateChange 4 | 5 | 6 | class InvalidServiceAddedException(Exception): 7 | pass 8 | 9 | 10 | class InvalidServiceRemovedException(Exception): 11 | pass 12 | 13 | 14 | class ZeroConfDiscovery(Thread): 15 | 16 | def __init__(self): 17 | super().__init__() 18 | self.services = {} 19 | self.zeroconf = Zeroconf() 20 | 21 | def on_service_change(self, zeroconf, service_type, name, state_change): 22 | print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) 23 | try: 24 | service_name = name.split(".")[0] 25 | if state_change is ServiceStateChange.Added: 26 | info = self.zeroconf.get_service_info(service_type, name) 27 | service_port = info.port 28 | self.services[service_name] = f"{service_name}:{service_port}" 29 | elif state_change is ServiceStateChange.Removed: 30 | found = self.services.pop(service_name, None) 31 | if found is None: 32 | raise InvalidServiceRemovedException(f"The service removed is not valid.") 33 | 34 | except (Exception, KeyError) as e: 35 | raise InvalidServiceAddedException(f"The name or properties of the service " 36 | f"added are not valid: {e}") 37 | 38 | def run(self): 39 | _ = ServiceBrowser(self.zeroconf, "_http._tcp.local.", handlers=[self.on_service_change]) 40 | try: 41 | while True: 42 | ZeroconfServiceTypes.find() 43 | sleep(10) 44 | except (KeyboardInterrupt, InvalidServiceAddedException): 45 | pass 46 | finally: 47 | self.zeroconf.close() 48 | --------------------------------------------------------------------------------