Skip to content

Commit

Permalink
Add initial implementation of /parts endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
ubruhin committed Feb 28, 2024
1 parent 44ab523 commit f1933ec
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env
.idea/
.vscode/
*~
Expand Down
27 changes: 27 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 \
py3-requests \
py3-requests-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" \
]
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
223 changes: 223 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-

import os
import requests

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_QUERY_URL = os.environ['PARTS_QUERY_URL']
PARTS_QUERY_TOKEN = os.environ['PARTS_QUERY_TOKEN']

PARTS_MAX_COUNT = 20
PARTS_QUERY_TIMEOUT = 10.0
PARTS_QUERY_FRAGMENT = """
fragment f on Stock {
products {
basic {
manufacturer
mfgpartno
status
}
url
}
summary {
inStockInventory
medianPrice
suppliersInStock
}
}
"""
PARTS_QUERY_STATUS_MAP = {
'active': 'Active',
'nrfnd': 'NRND',
'obsolete': 'Obsolete',
'discontinued': 'Obsolete',
'transferred': 'Obsolete',
}


def _build_headers():
return {
'Content-Type': 'application/json',
'Accept': 'application/json, multipart/mixed',
'Authorization': 'Bearer {}'.format(PARTS_QUERY_TOKEN),
}


def _build_request(parts):
args = []
queries = []
variables = {}
for i in range(len(parts)):
args.append('$mpn{}:String!'.format(i))
queries.append('q{}:findStocks(mfgpartno:$mpn{}){{...f}}'.format(i, i))
variables['mpn{}'.format(i)] = parts[i]['mpn']
query = 'query Stocks({}) {{\n{}\n}}'.format(
','.join(args),
'\n'.join(queries)
) + PARTS_QUERY_FRAGMENT
return dict(query=query, variables=variables)


def _get_basic_value(product, key):
if type(product) is dict:
basic = product.get('basic')
if type(basic) is dict:
value = basic.get(key)
if type(value) is str:
return value
return ''


def _normalize_basic_value(mfr):
return mfr.lower().replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue')


def _get_product(data, mpn, manufacturer):
mpn_n = _normalize_basic_value(mpn)
mfr_n = _normalize_basic_value(manufacturer)
products = [
(
_normalize_basic_value(_get_basic_value(p, 'mfgpartno')),
_normalize_basic_value(_get_basic_value(p, 'manufacturer')),
p,
)
for p in (data.get('products') or [])
]
for p_mpn, p_mfr, p in products:
if (p_mpn == mpn_n) and (p_mfr == mfr_n):
return p
for p_mpn, p_mfr, p in products:
if (p_mpn == mpn_n) and (mfr_n in p_mfr):
return p
for p_mpn, p_mfr, p in products:
if (p_mpn == mpn_n) and (mfr_n.split(' ')[0] in p_mfr):
return p
return None


def _add_pricing_url(out, data):
value = data.get('url')
if value is not None:
out['pricing_url'] = value


def _add_status(out, data):
status = data.get('status') or ''
value = PARTS_QUERY_STATUS_MAP.get(status.lower())
if value is not None:
out['status'] = value
elif len(status):
out['status'] = status
app.logger.warning('Unknown part lifecycle status: {}'.format(status))


def _stock_to_availability(stock):
if stock > 100000:
return 10 # Very Good
elif stock > 5000:
return 5 # Good
elif stock > 200:
return 0 # Normal
elif stock > 0:
return -5 # Bad
else:
return -10 # Very Bad


def _suppliers_to_availability(suppliers):
if suppliers > 30:
return 10 # Very Good
elif suppliers > 9:
return 5 # Good
elif suppliers > 1:
return 0 # Normal
elif suppliers > 0:
return -5 # Bad
else:
return -10 # Very Bad


def _add_availability(out, data):
stock = data.get('inStockInventory')
suppliers = data.get('suppliersInStock')
values = []
if type(stock) is int:
values.append(_stock_to_availability(stock))
if type(suppliers) is int:
values.append(_suppliers_to_availability(suppliers))
if len(values):
out['availability'] = min(values)


def _add_prices(out, summary):
value = summary.get('medianPrice')
if type(value) in [float, int]:
out['prices'] = [dict(quantity=1, price=float(value))]


@app.route('/api/v1/parts', methods=['GET'])
def parts():
response = make_response(dict(
provider_name='Partstack',
provider_url='https://partstack.com',
provider_logo_url=url_for('parts_static',
filename='parts-provider-partstack.png',
_external=True),
info_url='https://api.librepcb.org/api',
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/<filename>', 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():
# Get requested parts.
payload = request.get_json()
parts = payload['parts'][:PARTS_MAX_COUNT]

# Query parts from information provider.
query_response = requests.post(
PARTS_QUERY_URL, headers=_build_headers(), json=_build_request(parts),
timeout=PARTS_QUERY_TIMEOUT)
query_json = query_response.json()
data = query_json.get('data') or {}
errors = query_json.get('errors') or []
if (len(data) == 0) and (type(query_json.get('message')) is str):
errors.append(query_json['message'])
for error in errors:
app.logger.warning("GraphQL Error: " + str(error))

# Convert query response data and return it to the client.
tx = dict(parts=[])
for i in range(len(parts)):
mpn = parts[i]['mpn']
manufacturer = parts[i]['manufacturer']
part_data = data.get('q' + str(i)) or {}
product = _get_product(part_data, mpn, manufacturer)
part = dict(
mpn=mpn,
manufacturer=manufacturer,
results=0 if product is None else 1,
)
if product is not None:
basic = product.get('basic') or {}
summary = part_data.get('summary') or {}
_add_pricing_url(part, product)
_add_status(part, basic)
_add_availability(part, summary)
_add_prices(part, summary)
tx['parts'].append(part)
return tx
8 changes: 8 additions & 0 deletions demo-request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"parts": [
{
"mpn": "1N4148",
"manufacturer": "Vishay"
}
]
}
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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
PARTS_QUERY_URL: "${PARTS_QUERY_URL}"
PARTS_QUERY_TOKEN: "${PARTS_QUERY_TOKEN}"
entrypoint: ['flask', 'run']
Binary file added static/parts-provider-librepcb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/parts-provider-partstack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit f1933ec

Please sign in to comment.