Skip to content

Commit 892d2c9

Browse files
committed
Add initial implementation of /parts endpoint
1 parent 44ab523 commit 892d2c9

File tree

8 files changed

+399
-0
lines changed

8 files changed

+399
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
*.autosave
55
*.swp
66
.DS_Store
7+
config/
78
Thumbs.db

Dockerfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
ARG ALPINE_TAG
2+
FROM alpine:$ALPINE_TAG
3+
4+
# Install packages.
5+
RUN apk add --no-cache \
6+
python3 \
7+
py3-flask \
8+
py3-flask-pyc \
9+
py3-gunicorn \
10+
py3-gunicorn-pyc \
11+
py3-requests \
12+
py3-requests-pyc
13+
14+
# Copy files.
15+
COPY app.py app/
16+
COPY static/ app/static/
17+
WORKDIR app
18+
19+
# Set entrypoint.
20+
ENTRYPOINT [ \
21+
"gunicorn", \
22+
"--access-logfile=-", \
23+
"--bind=0.0.0.0:8000", \
24+
"--forwarded-allow-ips=*", \
25+
"--workers=4", \
26+
"app:app" \
27+
]

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# librepcb-api-server
22

3+
Official server-side implementation of the
4+
[LibrePCB API](https://developers.librepcb.org/d1/dcb/doc_server_api.html)
5+
as accessed by the LibrePCB application. Note that some older API paths are
6+
implemented in a different way and might be migrated to this repository
7+
later.
8+
9+
## Requirements
10+
11+
Only Docker Compose is needed to run this server on a Linux machine.
12+
13+
## Configuration
14+
15+
To make all features working, a configuration file `config/api.json` is
16+
required with the following content:
17+
18+
```json
19+
{
20+
/* Config for endpoint '/parts' */
21+
"parts_operational": false,
22+
"parts_query_url": "",
23+
"parts_query_token": ""
24+
}
25+
```
26+
27+
## Usage
28+
29+
For local development, the server can be run with this command:
30+
31+
```bash
32+
docker-compose up --build
33+
```
34+
35+
Afterwards, the API runs on http://localhost:8000/:
36+
37+
```bash
38+
curl -X POST -H "Content-Type: application/json" -d @demo-request.json \
39+
'http://localhost:8000/api/v1/parts/query' | jq '.'
40+
```
41+
342
## License
443

544
The content in this repository is published under the

app.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import json
4+
5+
import requests
6+
from flask import (Flask, g, make_response, request, send_from_directory,
7+
url_for)
8+
from werkzeug.middleware.proxy_fix import ProxyFix
9+
10+
app = Flask(__name__)
11+
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
12+
13+
PARTS_MAX_COUNT = 10
14+
PARTS_QUERY_TIMEOUT = 8.0
15+
PARTS_QUERY_FRAGMENT = """
16+
fragment f on Stock {
17+
products {
18+
basic {
19+
manufacturer
20+
mfgpartno
21+
status
22+
}
23+
url
24+
imageUrl
25+
datasheetUrl
26+
}
27+
summary {
28+
inStockInventory
29+
medianPrice
30+
suppliersInStock
31+
}
32+
}
33+
"""
34+
PARTS_QUERY_STATUS_MAP = {
35+
'active': 'Active',
36+
'active-unconfirmed': 'Active',
37+
'nrfnd': 'NRND',
38+
'eol': 'Obsolete',
39+
'obsolete': 'Obsolete',
40+
'discontinued': 'Obsolete',
41+
'transferred': 'Obsolete',
42+
'contact mfr': None, # Not supported, but here to avoid warning.
43+
}
44+
MANUFACTURER_REPLACEMENTS = {
45+
'ä': 'ae',
46+
'ö': 'oe',
47+
'ü': 'ue',
48+
'texas instruments': 'ti',
49+
'stmicroelectronics': 'st',
50+
}
51+
MANUFACTURER_REMOVALS = set([
52+
'contact',
53+
'devices',
54+
'electronics',
55+
'inc.',
56+
'inc',
57+
'incorporated',
58+
'integrated',
59+
'international',
60+
'limited',
61+
'ltd.',
62+
'ltd',
63+
'microelectronics',
64+
'semiconductor',
65+
'semiconductors',
66+
'solutions',
67+
'systems',
68+
'technology',
69+
'usa',
70+
])
71+
72+
73+
def _get_config(key, fallback=None):
74+
if 'config' not in g:
75+
try:
76+
with open('/config/api.json', 'rb') as f:
77+
g.config = json.load(f)
78+
except Exception as e:
79+
app.logger.critical(str(e))
80+
g.config = dict()
81+
return g.config.get(key, fallback)
82+
83+
84+
def _build_headers():
85+
return {
86+
'Content-Type': 'application/json',
87+
'Accept': 'application/json, multipart/mixed',
88+
'Authorization': 'Bearer {}'.format(_get_config('parts_query_token')),
89+
}
90+
91+
92+
def _build_request(parts):
93+
args = []
94+
queries = []
95+
variables = {}
96+
for i in range(len(parts)):
97+
args.append('$mpn{}:String!'.format(i))
98+
queries.append('q{}:findStocks(mfgpartno:$mpn{}){{...f}}'.format(i, i))
99+
variables['mpn{}'.format(i)] = parts[i]['mpn']
100+
query = 'query Stocks({}) {{\n{}\n}}'.format(
101+
','.join(args),
102+
'\n'.join(queries)
103+
) + PARTS_QUERY_FRAGMENT
104+
return dict(query=query, variables=variables)
105+
106+
107+
def _get_basic_value(product, key):
108+
if type(product) is dict:
109+
basic = product.get('basic')
110+
if type(basic) is dict:
111+
value = basic.get(key)
112+
if type(value) is str:
113+
return value
114+
return ''
115+
116+
117+
def _normalize_manufacturer(mfr):
118+
mfr = mfr.lower()
119+
for old, new in MANUFACTURER_REPLACEMENTS.items():
120+
mfr = mfr.replace(old, new)
121+
terms = [s for s in mfr.split(' ') if s not in MANUFACTURER_REMOVALS]
122+
return ' '.join(terms)
123+
124+
125+
def _calc_product_match_score(p, mpn_n, mfr_n):
126+
score = 0
127+
128+
status_p = PARTS_QUERY_STATUS_MAP.get(_get_basic_value(p, 'status'))
129+
if status_p == 'Active':
130+
score += 200
131+
elif status_p == 'NRND':
132+
score += 100
133+
134+
mpn_p = _get_basic_value(p, 'mfgpartno').lower()
135+
if mpn_p == mpn_n:
136+
score += 20 # MPN matches exactly.
137+
elif mpn_p.replace(' ', '') == mpn_n.replace(' ', ''):
138+
score += 10 # MPN matches when ignoring whitespaces.
139+
else:
140+
return 0 # MPN does not match!
141+
142+
mfr_p = _normalize_manufacturer(_get_basic_value(p, 'manufacturer'))
143+
if mfr_p == mfr_n:
144+
score += 4 # Manufacturer matches exactly.
145+
elif mfr_n in mfr_p:
146+
score += 3 # Manufacturer matches partially.
147+
elif mfr_n.replace(' ', '') in mfr_p.replace(' ', ''):
148+
score += 2 # Manufacturer matches partially when ignoring whitespaces.
149+
elif mfr_n.split(' ')[0] in mfr_p:
150+
score += 1 # The first term of the manufacturer matches.
151+
else:
152+
return 0 # Manufacturer does not match!
153+
154+
return score
155+
156+
157+
def _get_product(data, mpn, manufacturer):
158+
products = (data.get('products') or [])
159+
for p in products:
160+
p['_score'] = _calc_product_match_score(
161+
p, mpn.lower(), _normalize_manufacturer(manufacturer))
162+
products = sorted([p for p in products if p['_score'] > 0],
163+
key=lambda p: p['_score'], reverse=True)
164+
return products[0] if len(products) else None
165+
166+
167+
def _add_pricing_url(out, data):
168+
value = data.get('url')
169+
if value is not None:
170+
out['pricing_url'] = value
171+
172+
173+
def _add_image_url(out, data):
174+
value = data.get('imageUrl')
175+
if value is not None:
176+
out['picture_url'] = value
177+
178+
179+
def _add_status(out, data):
180+
status = data.get('status') or ''
181+
status_n = status.lower()
182+
value = PARTS_QUERY_STATUS_MAP.get(status_n.lower())
183+
if value is not None:
184+
out['status'] = value
185+
elif len(status_n) and (status_n not in PARTS_QUERY_STATUS_MAP):
186+
app.logger.warning('Unknown part lifecycle status: {}'.format(status))
187+
188+
189+
def _stock_to_availability(stock):
190+
if stock > 100000:
191+
return 10 # Very Good
192+
elif stock > 5000:
193+
return 5 # Good
194+
elif stock > 200:
195+
return 0 # Normal
196+
elif stock > 0:
197+
return -5 # Bad
198+
else:
199+
return -10 # Very Bad
200+
201+
202+
def _suppliers_to_availability(suppliers):
203+
if suppliers > 30:
204+
return 10 # Very Good
205+
elif suppliers > 9:
206+
return 5 # Good
207+
elif suppliers > 1:
208+
return 0 # Normal
209+
elif suppliers > 0:
210+
return -5 # Bad
211+
else:
212+
return -10 # Very Bad
213+
214+
215+
def _add_availability(out, data):
216+
stock = data.get('inStockInventory')
217+
suppliers = data.get('suppliersInStock')
218+
values = []
219+
if type(stock) is int:
220+
values.append(_stock_to_availability(stock))
221+
if type(suppliers) is int:
222+
values.append(_suppliers_to_availability(suppliers))
223+
if len(values):
224+
out['availability'] = min(values)
225+
226+
227+
def _add_prices(out, summary):
228+
value = summary.get('medianPrice')
229+
if type(value) in [float, int]:
230+
out['prices'] = [dict(quantity=1, price=float(value))]
231+
232+
233+
def _add_resources(out, data):
234+
value = data.get('datasheetUrl')
235+
if value is not None:
236+
out['resources'] = [
237+
dict(name="Datasheet", mediatype="application/pdf", url=value),
238+
]
239+
240+
241+
@app.route('/api/v1/parts', methods=['GET'])
242+
def parts():
243+
enabled = _get_config('parts_operational', False)
244+
response = make_response(dict(
245+
provider_name='Partstack',
246+
provider_url='https://partstack.com',
247+
provider_logo_url=url_for('parts_static',
248+
filename='parts-provider-partstack.png',
249+
_external=True),
250+
info_url='https://api.librepcb.org/api',
251+
query_url=url_for('parts_query', _external=True) if enabled else None,
252+
max_parts=PARTS_MAX_COUNT,
253+
))
254+
response.headers['Cache-Control'] = 'max-age=300'
255+
return response
256+
257+
258+
@app.route('/api/v1/parts/query', methods=['POST'])
259+
def parts_query():
260+
# Get requested parts.
261+
payload = request.get_json()
262+
parts = payload['parts'][:PARTS_MAX_COUNT]
263+
264+
# Query parts from information provider.
265+
query_response = requests.post(
266+
_get_config('parts_query_url'),
267+
headers=_build_headers(),
268+
json=_build_request(parts),
269+
timeout=PARTS_QUERY_TIMEOUT,
270+
)
271+
query_json = query_response.json()
272+
data = query_json.get('data') or {}
273+
errors = query_json.get('errors') or []
274+
if (len(data) == 0) and (type(query_json.get('message')) is str):
275+
errors.append(query_json['message'])
276+
for error in errors:
277+
app.logger.warning("GraphQL Error: " + str(error))
278+
279+
# Convert query response data and return it to the client.
280+
tx = dict(parts=[])
281+
for i in range(len(parts)):
282+
mpn = parts[i]['mpn']
283+
manufacturer = parts[i]['manufacturer']
284+
part_data = data.get('q' + str(i)) or {}
285+
product = _get_product(part_data, mpn, manufacturer)
286+
part = dict(
287+
mpn=mpn,
288+
manufacturer=manufacturer,
289+
results=0 if product is None else 1,
290+
)
291+
if product is not None:
292+
basic = product.get('basic') or {}
293+
summary = part_data.get('summary') or {}
294+
_add_pricing_url(part, product)
295+
_add_image_url(part, product)
296+
_add_status(part, basic)
297+
_add_availability(part, summary)
298+
_add_prices(part, summary)
299+
_add_resources(part, product)
300+
tx['parts'].append(part)
301+
return tx
302+
303+
304+
@app.route('/api/v1/parts/static/<filename>', methods=['GET'])
305+
def parts_static(filename):
306+
return send_from_directory(
307+
'static', filename, mimetype='image/png', max_age=24*3600)

demo-request.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"parts": [
3+
{
4+
"mpn": "1N4148",
5+
"manufacturer": "Vishay"
6+
}
7+
]
8+
}

0 commit comments

Comments
 (0)