11import json
22import logging
3+ import tempfile
34import urllib .request
45import urllib .error
56
7+ from rest_framework .renderers import BaseRenderer , JSONRenderer
68from rest_framework .views import APIView
79from rest_framework .viewsets import ViewSet
810from rest_framework .exceptions import Throttled
9- from rest_framework .renderers import BaseRenderer
1011from django .core .exceptions import ObjectDoesNotExist
1112from django .shortcuts import redirect , get_object_or_404
1213
2021from urllib .parse import urljoin
2122
2223from pulpcore .plugin .util import get_domain
23-
2424from pulpcore .plugin .tasking import dispatch
2525
2626from pulp_rust .app .models import (
2929 RustPackageYank ,
3030 _strip_sparse_prefix ,
3131)
32- from pulp_rust .app .tasks import ayank_package , aunyank_package
32+ from pulp_rust .app .tasks import (
33+ ayank_package ,
34+ aunyank_package ,
35+ apublish_package ,
36+ parse_cargo_publish_body ,
37+ )
3338from pulp_rust .app .serializers import (
3439 IndexRootSerializer ,
3540 RustContentSerializer ,
@@ -110,7 +115,7 @@ def initial(self, request, *args, **kwargs):
110115 else :
111116 cargo_base = request .build_absolute_uri (f"/pulp/cargo/{ repo } /" )
112117 self .base_content_url = urljoin (BASE_CONTENT_URL , f"pulp/cargo/{ repo } /" )
113- self .base_api_url = cargo_base
118+ self .base_api_url = cargo_base . rstrip ( "/" )
114119 self .base_download_url = f"{ cargo_base } api/v1/crates"
115120
116121 @classmethod
@@ -253,6 +258,101 @@ def retrieve(self, request, repo):
253258 return HttpResponse (json .dumps (data ), content_type = "application/json" )
254259
255260
261+ class CargoPublishApiView (APIView ):
262+ """
263+ View for Cargo's crate publish endpoint (PUT /api/v1/crates/new).
264+
265+ Parses the custom binary format from ``cargo publish`` and dispatches a task
266+ to create the artifact, content, and new repository version.
267+
268+ See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish
269+ """
270+
271+ # TODO: Authentication/authorization is not yet implemented.
272+ # All users with network access can publish. In production, this should
273+ # require a valid token and verify crate ownership.
274+ authentication_classes = []
275+ permission_classes = []
276+ renderer_classes = [JSONRenderer ]
277+
278+ def get_distribution (self ):
279+ return get_object_or_404 (
280+ RustDistribution , base_path = self .kwargs ["repo" ], pulp_domain = get_domain ()
281+ )
282+
283+ @staticmethod
284+ def _error_response (detail , status = 400 ):
285+ return HttpResponse (
286+ json .dumps ({"errors" : [{"detail" : detail }]}),
287+ content_type = "application/json" ,
288+ status = status ,
289+ )
290+
291+ def put (self , request , ** kwargs ):
292+ """
293+ Handle ``cargo publish`` requests.
294+
295+ Parses the binary body (JSON metadata + .crate tarball), validates the
296+ distribution allows uploads and the crate doesn't already exist in the
297+ repository, then dispatches a publish task.
298+ """
299+ distro = self .get_distribution ()
300+
301+ if not distro .allow_uploads :
302+ return self ._error_response ("this registry does not allow uploads" , status = 403 )
303+
304+ if not distro .repository :
305+ return self ._error_response (
306+ "no repository associated with this distribution" , status = 404
307+ )
308+
309+ try :
310+ metadata , crate_bytes = parse_cargo_publish_body (request .body )
311+ except Exception :
312+ return self ._error_response ("invalid publish request body" )
313+
314+ name = metadata .get ("name" )
315+ vers = metadata .get ("vers" )
316+ if not name or not vers :
317+ return self ._error_response ("missing required fields: name, vers" )
318+
319+ # Check for duplicates before dispatching — crates.io rejects re-publishing
320+ repo_version = distro .repository .latest_version ()
321+ if RustContent .objects .filter (pk__in = repo_version .content , name = name , vers = vers ).exists ():
322+ return self ._error_response (f"crate version `{ name } @{ vers } ` is already uploaded" )
323+
324+ # Write the .crate bytes to a temp file — raw bytes can't be passed
325+ # through dispatch() because task kwargs are stored as JSON.
326+ tmp = tempfile .NamedTemporaryFile (suffix = ".crate" , delete = False )
327+ tmp .write (crate_bytes )
328+ tmp .close ()
329+
330+ task = dispatch (
331+ apublish_package ,
332+ exclusive_resources = [distro .repository ],
333+ immediate = True ,
334+ kwargs = {
335+ "repository_pk" : str (distro .repository .pk ),
336+ "metadata" : metadata ,
337+ "crate_path" : tmp .name ,
338+ },
339+ )
340+ has_task_completed (task )
341+
342+ return HttpResponse (
343+ json .dumps (
344+ {
345+ "warnings" : {
346+ "invalid_categories" : [],
347+ "invalid_badges" : [],
348+ "other" : [],
349+ }
350+ }
351+ ),
352+ content_type = "application/json" ,
353+ )
354+
355+
256356class CargoDownloadApiView (APIView ):
257357 """
258358 View for Cargo's crate download, readme, yank, and unyank endpoints.
@@ -261,7 +361,7 @@ class CargoDownloadApiView(APIView):
261361 # Authentication disabled for now
262362 authentication_classes = []
263363 permission_classes = []
264- renderer_classes = [PlainTextRenderer ]
364+ renderer_classes = [PlainTextRenderer , JSONRenderer ]
265365
266366 def get_full_path (self , base_path , pulp_domain = None ): # TODO: replace with ApiMixin?
267367 if settings .DOMAIN_ENABLED :
0 commit comments