Benötige Hilfe beim Erstellen einer API mit Flask

Django, Flask, Bottle, WSGI, CGI…
Antworten
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

Moin,

ich bin neu beim Thema Webentwicklung, Python/Flask, und auch in diesem Forum.
Meine Anwendung soll einerseits HTML für den Browser ausliefern, aber auch von einer .NET Desktopanwendung angesprochen werden können.
Von der Desktopanwendung aus sollen registrierte User "Shapes" und "Stencils" publizieren können.
Ein Shape besteht aus einigen Texteigenschaften und einer PNG Datei.
Ein Stencil hat eigene Eigenschaften, eine Binärdatei, und viele Shapes.
Nur registrierte User können Shapes und Stencils herunterladen.

Dank Hilfe einiger Tutorials funktioniert bereits:
* Webseite bereitstellen
* Benutzer registrieren
* Publizieren von Shapes aus der .NET Applikation
* Download nur für registrierte User (mit Flask-Login)

Die API für "addShape" sieht (vereinfacht) so aus:

Code: Alles auswählen

@bp.route("/addShape", methods=['POST'])
def addShape():
    try:
        file = request.files['image']

        newShape = Shape(
            name = request.form['name'],
            prompt = request.form['prompt'],
            keywords = request.form['keywords'],
            dataObject = request.form['dataObject'])
        
        db.session.add(newShape)
        db.session.commit()

        filename = f"{newShape.id}.png"
        file.save(os.path.join(os.path.abspath('app/static/images'), filename))

    except Exception as e:
        return jsonify({"message": str(e)}), 400
    
    return jsonify({"message": "Shape added"}), 201
Aktuell gibt es hier noch keine Zugriffskontrolle.
Meine Idee wäre es, Email und Passwort bei jedem Request als Eigenschaften mitzuliefern und dies ohne Flask-Login zu behandeln.
AddShape soll ja ausschließlich von der Anwendung aus angesprochen werden, und nicht vom Browser.

Mein Problem ist aber, dass diese Eigenschaften statische Key/Value Paare in request.form sind.
Das finde ich so schon nicht elegant. Aber spätestens für "addStencil" muss es dynamisch sein, da ein Stencil ja eine variable Anzahl an Shapes enthält.
Daher würde ich das gerne mit JSON machen.
Dies würde dann ein User-Objekt, ein Stencil-Objekt, sowie ein Array von Shape-Objekten enthalten.
Außerdem würden dem Request noch je ein PNG pro Shape und eine Binärdatei für das Stencil anhängen.
(Insgesamt reden wir hier von 1..10MB, falls das wichtig ist.)

All meine Versuche das so abzubilden sind bisher gescheitert.
Ich weiß auch nicht, ob der Fehler am Python-, oder am NET-Code liegt.
Zwar kann ich etwas basteln was teilweise geht, aber das fühlt sich nicht gut an.
Außerdem denke ich, dass die API von Anfang an stabil sein sollte.

Daher würde ich euch einmal um Hilfe bitten die Python-Seite vernünftig festzulegen.
So in etwa stelle ich mir das vor:

Code: Alles auswählen

@bp.route("/addStencil", methods=['POST'])
def addStencil():
    try:
        o = json.load(request.json)
        print(o.User.Email)
        print(o.Stencil.Name)
        for shape in o.Shapes:
            print(shape.Name)
        for file in request.files:
            print(file.filename)
            
    except Exception as e:
        return jsonify({"message": str(e)}), 400
    
    return jsonify({"message": "Stencil added"}), 201
Wenn das schon mal sauber wäre würde mir das sehr helfen.

Gruß,
Thomas
Sirius3
User
Beiträge: 18215
Registriert: Sonntag 21. Oktober 2012, 17:20

Die Zugriffskontroll sollte immer über flask-login laufen.Für REST-Apis ist es üblich einen Authorization-Header zu benutzen. Moderne Frameworks nutzen dafür einen Bearer-Token, der über einen separaten Login erzeugt wird.
Forms kann auch mit einer Variablen Zahl an Keys umgehen, eleganter ist es aber eine multi-form-POST-Request zu benutzten, wo ein Teil eine JSON-Struktur ist.

Variabelnamen und Funktionen werden in Python nach Konvention klein_mit_unterstrich geschrieben. `o` ist ein schlechter Variablenname, weil nichtssagend.
Für Pfad-Manipulation benutzt man pathlib und nicht os.path.
Der Pfad zu app/static/images sollte wohl nicht relativ zum aktuellen Arbeitsverzeichnis definiert sein und nicht als literaler String tief in einer Funktion vergraben sein, sondern am Anfang der Datei als Konstante definiert werden.
Rohe Error-Messages sollten nicht über eine Web-Api zurückgegeben werden, weil die unter umständen Interna des Servers verraten. UserFehler, die der Anwender verhindern kann sollten explizit abgefangen und mit einer Sinnvollen Meldung zurückgegeben werden, sonstige Fehler werden üblicherweise mit HTTP-Code 500 beantwortet und der Nutzer bekommt nur eine Kennung, mit der ein Admin dann in den Serverinternen Logs den Fehler finden kann.
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

Danke für die ausführliche Antwort, jetzt habe ich erst mal was zu Recherchieren.
Inzwischen habe ich Postman entdeckt.
Damit kann ich die API nun endlich vernünftig mit einem stabilen Endpunkt testen, sehr Hilfreich.
Anscheinend ist es in Python nicht so einfach komplexe JSON Strings (verschachtelt und mit Arrays) zu deserialisieren.
Also vergleichbar zu Newtonsoft.Json:

Code: Alles auswählen

ModelClass modelObject= JsonConvert.DeserializeObject<ModelClass>(json);
Das hier sieht mir noch am besten aus:
https://stackoverflow.com/questions/698 ... th-objects
Oder sollte man sich bei APIs auf einfache Dictionarys beschränken?
Sirius3
User
Beiträge: 18215
Registriert: Sonntag 21. Oktober 2012, 17:20

Warum sollte man json in eine Klassenstruktur deserialisieren wollen? Am einfachsten ist es immer noch, einfach die Listen und Wörterbücher explizit in die gewünschten Objekte umzuwandeln.
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@ThomasWi: Es gibt *mehrere* Python-Bibliotheken zum (de)serialisieren von Objekten. Zum Beispiel https://catt.rs/
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

Sirius3 hat geschrieben: Donnerstag 18. Juli 2024, 14:56 Warum sollte man json in eine Klassenstruktur deserialisieren wollen?
Weil ich nach json.loads() versucht habe mit Punktnotation auf die Eigenschaften zuzugreifen.
Weil das nicht funktionierte dachte ich das deserialisieren funktioniert so nicht und habe mich auf die Suche nach Alternativen gemacht... :oops:

So funktioniert es:

Code: Alles auswählen

add_stencil_request = json.loads(json_string)

for shape in add_stencil_request['Shapes']:
    print(shape['Name'])
    #print(shape.Name)
__blackjack__ hat geschrieben: Donnerstag 18. Juli 2024, 19:33 @ThomasWi: Es gibt *mehrere* Python-Bibliotheken zum (de)serialisieren von Objekten. Zum Beispiel https://catt.rs/
Danke, das ist zwar jetzt nicht mehr nötig, werde ich mir aber dennoch ansehen.
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

Folgende Route macht nun was ich möchte:

Code: Alles auswählen

@app.route("/addstencil", methods=['POST'])
def add_stencil():
    try:
        add_stencil_request = json.loads(request.form.get('json'))

        for shape in add_stencil_request['Shapes']:
            print(shape['Name'])
        
        print(request.files.get('stencil').filename)

        for file in request.files.getlist('shapes'):
            print(file.filename)
            
    except Exception as e:
        print(str(e))
        return jsonify({"message": "Failed"}), 500
    
    return jsonify({"message": "Stencil added"}), 201
Benutzer Authentifikation mache ich erst nachdem die Kommunikation mit der .NET Applikation steht.
Vermutlich verwende ich dafür Flask-HTTPAuth mit Token.
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@ThomasWi: Anmerkungen zum Code: `str()` ist bei `print()` unnötig, das macht die `print()`-Funktion schon selbst. An der Stelle sollte man aber besser so etwas wie `logging.exception()` verwenden, dann wird auch der Traceback ausgegeben und man mehr Informationen zur Fehlersuche.

Die beiden `get()`-Aufrufe machen nicht wirklich Sinn, weil in beiden Fällen der Code bei einem Folgefehler auf die Nase fällt wenn da tatsächlich `None` geliefert wird.

Code: Alles auswählen

@app.route("/addstencil", methods=["POST"])
def add_stencil():
    try:
        for shape in json.loads(request.form["json"])["Shapes"]:
            print(shape["Name"])

        print(request.files["stencil"].filename)

        for file in request.files.getlist("shapes"):
            print(file.filename)

    except Exception:
        logging.exception("Error adding stencil.")
        return jsonify({"message": "Failed"}), 500

    return jsonify({"message": "Stencil added"}), 201
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

Danke für die Tipps, habe ich so umgesetzt.
Die Kommunikation zwischen Client und Server ist nun vollständig, inkl. Authentifizierung per Token.
Den Token werde ich einfach beim Anlegen des Users generieren und mit in die users Datenbank ablegen.
Oder Ich erstelle eine eigene Tabelle "tokens".

Ihr sendet den Fehlercode 500.
Ich war von 400 ausgegangen, da ein Fehler wahrscheinlich (genau fange ich das ja nicht ab) am fehlerhaften Request vom Client liegt..?
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@ThomasWi: Ich hatte die 500 einfach übernommen. Ich sehe das Argument für 400, aber da hier ganz allgemein *jede* Ausnahme so behandelt wird, kann man das ja gar nicht sagen ob was mit der Anfrage nicht stimmte, oder beim Code der eine korrekte Anfrage verarbeitet ein Problem aufgetreten ist. So etwas wie Validierung der Anfrage ist in dem Code ja nicht enthalten.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

Macht Sinn. Die Frage war auch eher akademischer Natur, da ich gerade erst in dieses Thema einsteige.
Ich denke, da beide Endpunkte von mir kommen und das keine öffentliche API sein soll ist es den Aufwand nicht wert alle möglichen Fehler einzeln abzufangen.
Sirius3 hat geschrieben: Mittwoch 17. Juli 2024, 18:50 Der Pfad zu app/static/images sollte wohl nicht relativ zum aktuellen Arbeitsverzeichnis definiert sein und nicht als literaler String tief in einer Funktion vergraben sein, sondern am Anfang der Datei als Konstante definiert werden.
Besser?

Code: Alles auswählen

file.save(Path(current_app.root_path) / 'static' / 'images' / f"{shape_id}.png")
Ich könnte das zwar noch in app.config['STATIC_FOLDER'] speichern, aber das werde ich doch ohnehin niemals ändern wollen, oder?
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Ich würde da eher `instance_path` nehmen.

In ein Verzeichnis das „static“ im Namen hat, dynamisch Dateien zu erzeugen ist auch ein bisschen komisch. Und ich persönlich würde da noch ein Verzeichnis dazwischen setzen, weil wenn direkt in images/ nummerierte PNG-Dateien liegen die Shapes repräsentieren, kann man nicht später noch andere nummerierte PNG-Dateien für was anderes in diesem Verzeichnis erzeugen/ablegen. Also beispielsweise ... / "images" / "shapes" / f"{shape_id}.png"

STATIC_FOLDER ist zum ändern da. Macht man beispielsweise oft wenn der/ein Webserver das ausliefern soll, statt der eigenen App indirekt über den Webserver.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
ThomasWi
User
Beiträge: 7
Registriert: Mittwoch 17. Juli 2024, 09:22

"public" fände ich auch passender als "static".
Aber "static" ist ja erst mal der Default von Flask, das habe ich jetzt so hingenommen.
Neben "images" liegen dort auch "css", "fonts" und "favicon.ico".
Für diese Shape-PNGs wird halt ein Ordner benötigt, wo der Browser direkt drauf zugreifen kann.
Unterordner "shapes" finde ich eine gute Idee, das werde ich so machen.

Wenn ich das richtig verstehe, dann ist "instance_path" für Ordner außerhalb der Versionskontrolle gedacht?
Zumindest "css", "fonts" und "favicon.ico" sollen ja in die Versionskontrolle.
In "images" liegt eine .gitignore, die alles außer sich selbst ausschließt.
So wird der Ordner commited, enthaltene Bilder aber nicht.
Neben "static" gibt es auch noch einen Ordner "stencils", der von außen aber nicht direkt zu erreichen ist.
Die Stencils werden je nach Berechtigung durch Flask ausgeliefert, wobei dann auch der ursprüngliche Dateiname wiederhergestellt wird.

Eine frühe Demo der Anwendung liegt hier:
https://github.com/Visio-Resources/Visi ... pesLibrary

Diskussion dazu ist hier:
https://visguy.com/vgforum/index.php?topic=10346.0

Und ein Kurzvideo der Funktion:
https://www.youtube.com/watch?v=5oSnbU3-JzU

Gerade versuche ich das ganze auf einen Produktivlevel zu bringen, mit Benutzermanagement, etc.
Nicht einfach für mich, aber ihr habt mir schon sehr geholfen, danke dafür.
Benutzeravatar
__blackjack__
User
Beiträge: 13919
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@ThomasWi: Das kann ruhig unter Versionskontrolle, es sollte aber ausserhalb der Python-Packages liegen. Nicht das man am Ende im Produktivsystem den Python-Quelltext mit unter dem Document-Root vom Webserver hat, das was an der Konfiguration nicht stimmt und jeder Zugriff auf das alles hat.
“I am Dyslexic of Borg, Your Ass will be Laminated” — unknown
Antworten