Skip to content

Commit 1efb824

Browse files
authored
Client ID registration (#444) (#445)
1 parent 9c94936 commit 1efb824

18 files changed

Lines changed: 395 additions & 7 deletions

.github/workflows/dev.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
AWS_REGION: ${{ secrets.AWS_REGION }}
1919
GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
2020
LAUNCH_DARKLY_KEY: ${{ secrets.LAUNCH_DARKLY_KEY_DEV }}
21+
DB_HOST: 127.0.0.1 # Will not work with 'localhost', since that will try a Unix socket connection (!)
2122
services:
2223
elasticsearch7:
2324
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.0
@@ -30,6 +31,16 @@ jobs:
3031
http.cors.allow-origin: "*"
3132
ports:
3233
- 9200:9200
34+
db:
35+
image: mysql:8.0
36+
env:
37+
MYSQL_DATABASE: "rorapi"
38+
MYSQL_USER: "ror_user"
39+
MYSQL_PASSWORD: "password"
40+
MYSQL_ROOT_PASSWORD: "password"
41+
ports:
42+
- 3306:3306
43+
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
3344
steps:
3445
- name: Checkout ror-api code
3546
uses: actions/checkout@v2

.github/workflows/release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ jobs:
99
ELASTIC_PASSWORD: "changeme"
1010
ELASTIC7_HOST: "localhost"
1111
ELASTIC7_PORT: "9200"
12+
DB_HOST: 127.0.0.1
1213
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
1314
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1415
AWS_REGION: ${{ secrets.AWS_REGION }}
@@ -26,6 +27,16 @@ jobs:
2627
http.cors.allow-origin: "*"
2728
ports:
2829
- 9200:9200
30+
db:
31+
image: mysql:8.0
32+
env:
33+
MYSQL_DATABASE: "rorapi"
34+
MYSQL_USER: "ror_user"
35+
MYSQL_PASSWORD: "password"
36+
MYSQL_ROOT_PASSWORD: "password"
37+
ports:
38+
- 3306:3306
39+
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
2940
steps:
3041
- name: Checkout ror-api code
3142
uses: actions/checkout@v2

.github/workflows/staging.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ jobs:
1010
ELASTIC_PASSWORD: "changeme"
1111
ELASTIC7_HOST: "localhost"
1212
ELASTIC7_PORT: "9200"
13+
DB_HOST: 127.0.0.1
1314
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
1415
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1516
AWS_REGION: ${{ secrets.AWS_REGION }}
@@ -27,6 +28,16 @@ jobs:
2728
http.cors.allow-origin: "*"
2829
ports:
2930
- 9200:9200
31+
db:
32+
image: mysql:8.0
33+
env:
34+
MYSQL_DATABASE: "rorapi"
35+
MYSQL_USER: "ror_user"
36+
MYSQL_PASSWORD: "password"
37+
MYSQL_ROOT_PASSWORD: "password"
38+
ports:
39+
- 3306:3306
40+
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
3041
steps:
3142
- name: Checkout ror-api code
3243
uses: actions/checkout@v2

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ RUN mv /etc/apt/sources.list.d /etc/apt/sources.list.d.bak && \
1616
mv /etc/apt/sources.list.d.bak /etc/apt/sources.list.d && \
1717
apt-get upgrade -y -o Dpkg::Options::="--force-confold" && \
1818
apt-get clean && \
19-
apt-get install ntp wget unzip tzdata python3-pip libmagic1 -y && \
19+
apt-get install ntp wget unzip tzdata python3-pip libmagic1 default-libmysqlclient-dev libcairo2-dev pkg-config -y && \
2020
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
2121

2222
# Enable Passenger and Nginx and remove the default site
@@ -54,6 +54,7 @@ RUN pip3 install --no-cache-dir -r requirements.txt
5454
RUN pip3 install yapf
5555

5656
# collect static files for Django
57+
ENV DJANGO_SKIP_DB_CHECK=True
5758
RUN python manage.py collectstatic --noinput
5859

5960
# Expose web

docker-compose.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ services:
1919
timeout: 1s
2020
volumes:
2121
- ./esdata:/usr/share/elasticsearch/data
22+
db:
23+
image: mysql:8.0
24+
volumes:
25+
- mysql_data:/var/lib/mysql
26+
env_file:
27+
- .env
28+
ports:
29+
- "3306:3306"
30+
healthcheck:
31+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
32+
timeout: 20s
33+
retries: 10
2234
web:
2335
container_name: rorapiweb
2436
env_file: .env
@@ -31,3 +43,6 @@ services:
3143
- ./rorapi:/home/app/webapp/rorapi
3244
depends_on:
3345
- elasticsearch7
46+
- db
47+
volumes:
48+
mysql_data:

requirements.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ update_address @ git+https://github.com/ror-community/update_address.git
2424
launchdarkly-server-sdk==7.6.1
2525
jsonschema==3.2.0
2626
python-magic
27-
iso639-lang
27+
iso639-lang
28+
mysqlclient==2.2.7
29+
bleach==6.0.0
30+
pycountry==22.3.5
31+
django-ses==3.5.0

rorapi/common/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from rest_framework.documentation import include_docs_urls
44
from . import views
55
from rorapi.common.views import (
6-
HeartbeatView,GenerateAddress,GenerateId,IndexData,IndexDataDump,BulkUpdate)
6+
HeartbeatView,GenerateAddress,GenerateId,IndexData,IndexDataDump,BulkUpdate,ClientRegistrationView,ValidateClientView)
77

88
urlpatterns = [
99
# Health check
@@ -14,6 +14,8 @@
1414
path('generateaddress/<str:geonamesid>', GenerateAddress.as_view()),
1515
url(r"^generateid$", GenerateId.as_view()),
1616
re_path(r"^(?P<version>(v1|v2))\/bulkupdate$", BulkUpdate.as_view()),
17+
re_path(r"^(?P<version>(v1|v2))\/register$", ClientRegistrationView.as_view()),
18+
path('validate-client-id/<str:client_id>/', ValidateClientView.as_view()),
1719
url(r"^(?P<version>(v1|v2))\/indexdata/(?P<branch>.*)", IndexData.as_view()),
1820
url(r"^(?P<version>(v1|v2))\/indexdatadump\/(?P<filename>v(\d+\.)?(\d+\.)?(\*|\d+)-\d{4}-\d{2}-\d{2}-ror-data)\/(?P<dataenv>(test|prod))$", IndexDataDump.as_view()),
1921
url(r"^(?P<version>(v1|v2))\/", include(views.organizations_router.urls)),

rorapi/common/views.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,87 @@
3737
import os
3838
import update_address as ua
3939
from rorapi.management.commands.generaterorid import check_ror_id
40-
from rorapi.management.commands.generaterorid import check_ror_id
4140
from rorapi.management.commands.indexror import process_files
4241
from django.core import management
4342
import rorapi.management.commands.indexrordump
43+
from django.core.mail import EmailMultiAlternatives
44+
from django.utils.timezone import now
45+
from rorapi.v2.models import Client
46+
from rorapi.v2.serializers import ClientSerializer
47+
48+
class ClientRegistrationView(APIView):
49+
def post(self, request, version='v2'):
50+
serializer = ClientSerializer(data=request.data)
51+
if serializer.is_valid():
52+
client = serializer.save()
53+
54+
subject = 'ROR API client ID'
55+
from_email = "ROR API Support <api@ror.org>"
56+
recipient_list = [client.email]
57+
58+
html_content = self._get_html_content(client.client_id)
59+
text_content = self._get_text_content(client.client_id)
60+
61+
msg = EmailMultiAlternatives(subject, text_content, from_email, recipient_list)
62+
msg.attach_alternative(html_content, "text/html")
63+
msg.send()
64+
65+
return Response({'client_id': client.client_id}, status=status.HTTP_201_CREATED)
66+
67+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
68+
69+
def _get_text_content(self, client_id):
70+
return f"""
71+
Thank you for registering for a ROR API client ID!
72+
73+
Your ROR API client ID is:
74+
{client_id}
75+
76+
This client ID is not used for authentication or authorization, and is therefore not secret and can be sent as plain text.
77+
78+
In order to receive a rate limit of 2000 requests per 5 minute period, please include this client ID with your ROR API requests, in a custom HTTP header named Client-Id, for example:
79+
80+
curl -H "Client-Id: {client_id}" https://api.ror.org/organizations?query=oxford
81+
82+
Requests without a valid client ID are subject to a rate limit of 50 requests per 5 minute period.
83+
84+
We do not provide a way to recover or revoke a lost client ID. If you lose track of your client ID, please register a new client ID. For more information about ROR API client IDs, see https://ror.readme.io/docs/client-id
85+
86+
If you have questions, please see ROR documentation or contact us at support@ror.org
87+
88+
Cheers,
89+
The ROR Team
90+
support@ror.org
91+
https://ror.org
92+
"""
93+
94+
95+
def _get_html_content(self, client_id):
96+
return f"""
97+
<div style="font-family: Arial, sans-serif; line-height: 1.5;">
98+
<p>Thank you for registering for a ROR API client ID!</p>
99+
<p><strong>Your ROR API client ID is:</strong></p>
100+
<pre style="background:#f4f4f4;padding:10px;">{client_id}</pre>
101+
<p>This client ID is not used for authentication or authorization, and is therefore not secret and can be sent as plain text.</p>
102+
<p>In order to receive a rate limit of <strong>2000 requests per 5 minute period</strong>, please include this client ID with your ROR API requests, in a custom HTTP header named <code>Client-Id</code>, for example:</p>
103+
<pre style="background:#f4f4f4;padding:10px;">curl -H "Client-Id: {client_id}" https://api.ror.org/organizations?query=oxford</pre>
104+
<p>Requests without a valid client ID are subject to a rate limit of 50 requests per 5 minute period.</p>
105+
<p>We do not provide a way to recover or revoke a lost client ID. If you lose track of your client ID, please register a new one.</p>
106+
<p>For more information about ROR API client IDs, see <a href="https://ror.readme.io/docs/client-id/">our documentation</a>.</p>
107+
<p>If you have questions, please see the ROR documentation or contact us at <a href="mailto:support@ror.org">support@ror.org</a>.</p>
108+
<p>Cheers,<br>
109+
The ROR Team<br>
110+
<a href="mailto:support@ror.org">support@ror.org</a><br>
111+
<a href="https://ror.org">https://ror.org</a></p>
112+
</div>
113+
"""
114+
115+
116+
class ValidateClientView(APIView):
117+
def get(self, request, client_id):
118+
client_exists = Client.objects.filter(client_id=client_id).exists()
119+
120+
return Response({'valid': client_exists}, status=status.HTTP_200_OK)
44121

45122
class OurTokenPermission(BasePermission):
46123
"""

rorapi/management/commands/generaterorid.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,10 @@ def check_ror_id(version):
2626
check_ror_id(version)
2727
return ror_id
2828

29+
30+
def generate_ror_client_id():
31+
"""Generates a random ROR client ID.
32+
"""
33+
34+
n = random.randint(0, 2**160 - 1)
35+
return base32_crockford.encode(n).lower().zfill(32)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 2.2.28 on 2025-03-11 07:13
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
initial = True
9+
10+
dependencies = [
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='Client',
16+
fields=[
17+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('email', models.EmailField(max_length=255)),
19+
('name', models.CharField(blank=True, max_length=255)),
20+
('institution_name', models.CharField(blank=True, max_length=255)),
21+
('institution_ror', models.URLField(blank=True, max_length=255)),
22+
('country_code', models.CharField(blank=True, max_length=2)),
23+
('ror_use', models.TextField(blank=True, max_length=500)),
24+
('client_id', models.CharField(editable=False, max_length=32, unique=True)),
25+
('created_at', models.DateTimeField(auto_now_add=True)),
26+
('last_request_at', models.DateTimeField(blank=True, null=True)),
27+
('request_count', models.IntegerField(default=0)),
28+
],
29+
),
30+
]

0 commit comments

Comments
 (0)