diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d60d66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +ARG ALPINE_TAG +FROM alpine:$ALPINE_TAG + +# Install packages. +RUN apk add --no-cache \ + python3 \ + py3-flask \ + py3-flask-pyc \ + py3-gunicorn \ + py3-gunicorn-pyc + +# Copy files. +COPY app.py app/ +COPY static/ app/static/ +WORKDIR app + +# Set entrypoint. +ENTRYPOINT [ \ + "gunicorn", \ + "--access-logfile=-", \ + "--bind=0.0.0.0:8000", \ + "--forwarded-allow-ips=*", \ + "--workers=4", \ + "app:app" \ +] diff --git a/README.md b/README.md index 3256962..b1e98f5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,30 @@ # librepcb-api-server +Official server-side implementation of the +[LibrePCB API](https://developers.librepcb.org/d1/dcb/doc_server_api.html) +as accessed by the LibrePCB application. Note that some older API paths are +implemented in a different way and might be migrated to this repository +later. + +## Requirements + +Only Docker Compose is needed to run this server on a Linux machine. + +## Usage + +For local development, the server can be run with this command: + +```bash +docker-compose up --build +``` + +Afterwards, the API runs on http://localhost:8000/: + +```bash +curl -X POST -H "Content-Type: application/json" -d @demo-request.json \ + 'http://localhost:8000/api/v1/parts/query' | jq '.' +``` + ## License The content in this repository is published under the diff --git a/app.py b/app.py new file mode 100755 index 0000000..9825b63 --- /dev/null +++ b/app.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +from random import randrange +from urllib.parse import urlencode + +from flask import Flask, make_response, request, send_from_directory, url_for +from werkzeug.middleware.proxy_fix import ProxyFix + +app = Flask(__name__) +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + +PARTS_MAX_COUNT = 20 + +STATUS_VALUES = [ + "Active", + "Active", + "Active", + "Active", + "Active", + "Active", + "Active", + "Active", + "Active", + "Active", + "NRND", + "Obsolete", + "Preview", +] + +AVAILABILITY_VALUES = [ + 10, + 5, + 5, + 5, + 5, + 5, + 5, + 0, + 0, + 0, + 0, + 0, + -5, + -10, +] + +PICTURE_VALUES = [ + "https://www.vishay.com/images/product-images/pt-large/81857-pt-large.jpg", # noqa: E501 + "https://www.ti.com/content/dam/ticom/images/products/package/d/d0008a.png", # noqa: E501 + "https://mm.digikey.com/Volume0/opasdata/d220001/medias/images/2088/100-UFBGA%287x7%29.jpg", # noqa: E501 + "https://www.luminus.com/img/new/Ecosystem_MP-MP-5050-240H.png", # noqa: E501 +] + +PRICE_VALUES = [ + [dict(quantity=1, price=0.008), dict(quantity=100, price=0.007)], + [dict(quantity=1, price=0.01), dict(quantity=100, price=0.008)], + [dict(quantity=1, price=0.12), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.125), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.125), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.125), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.125), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.125), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.125), dict(quantity=100, price=0.11)], + [dict(quantity=1, price=0.5), dict(quantity=100, price=0.45)], + [dict(quantity=1, price=1.0), dict(quantity=100, price=0.98)], + [dict(quantity=1, price=15.2), dict(quantity=100, price=14.8)], +] + + +def _get_random(values): + return values[randrange(len(values))] + + +@app.route('/api/v1/parts', methods=['GET']) +def parts(): + response = make_response(dict( + provider_name="LibrePCB", + provider_url="https://librepcb.org", + provider_logo_url=url_for('parts_static', + filename='parts-provider-librepcb.png', + _external=True), + query_url=url_for('parts_query', _external=True), + max_parts=PARTS_MAX_COUNT, + )) + response.headers['Cache-Control'] = 'max-age=300' + return response + + +@app.route('/api/v1/parts/static/', methods=['GET']) +def parts_static(filename): + return send_from_directory( + 'static', filename, mimetype='image/png', max_age=24*3600) + + +@app.route('/api/v1/parts/query', methods=['POST']) +def parts_query(): + rx = request.get_json() + tx = dict(parts=[]) + for rx_part in rx['parts'][:PARTS_MAX_COUNT]: + part = dict( + mpn=rx_part['mpn'], + manufacturer=rx_part['manufacturer'], + results=min(1, randrange(10)), + ) + if randrange(10) < 8: + part['product_url'] = "https://duckduckgo.com/?{}".format( + urlencode(dict(q=rx_part['mpn']))) + if randrange(10) < 8: + part['picture_url'] = _get_random(PICTURE_VALUES) + if randrange(10) < 8: + part['pricing_url'] = "https://partstack.com/s?{}".format( + urlencode(dict(pn=rx_part['mpn']))) + if randrange(10) < 8: + part['status'] = _get_random(STATUS_VALUES) + if randrange(10) < 8: + part['availability'] = _get_random(AVAILABILITY_VALUES) + if randrange(10) < 8: + part['prices'] = _get_random(PRICE_VALUES) + if randrange(10) < 8: + part['resources'] = [ + dict( + name="Datasheet", + mediatype="application/pdf", + url="https://www.vishay.com/docs/81857/1n4148.pdf", + ), + ] + tx['parts'].append(part) + return tx diff --git a/demo-request.json b/demo-request.json new file mode 100644 index 0000000..8b794ab --- /dev/null +++ b/demo-request.json @@ -0,0 +1,8 @@ +{ + "parts": [ + { + "mpn": "1N4148", + "manufacturer": "Vishay" + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c4c79f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.8" + +services: + server: + build: + context: . + args: + ALPINE_TAG: '3.19' + ports: + - 8000:8000 + environment: + FLASK_RUN_DEBUG: 1 + FLASK_RUN_HOST: '0.0.0.0' + FLASK_RUN_PORT: 8000 # Same as Gunicorn + entrypoint: ['flask', 'run'] diff --git a/static/parts-provider-librepcb.png b/static/parts-provider-librepcb.png new file mode 100644 index 0000000..2ff2fed Binary files /dev/null and b/static/parts-provider-librepcb.png differ