How to return JSON from FastAPI (backend) with websocket to Vue (frontend)?

Question:

I have an application, in which the frontend is implemented in Vue and the backend uses FastAPI framework. The communication is achieved through websockets.

Currently, the frontend allows the user to enter a term, which is sent to the backend to generate the autocomplete and also perform a search on a URL that returns a JSON object. In which, I save this JSON object in the frontend folder. After that, the backend returns the autocomplete data for the term in question to the frontend. The frontend displays the aucomplete along with the json data.

However, when I studied a little more, I noticed that there is a way to send the JSON returned by the request URL to Vue (frontend), without having to save it locally, avoiding giving an error of not allowing to execute this process more than once.

My current code is as follows. For FastAPI (backend):

@app.websocket("/")
async def predict_question(websocket: WebSocket):
    await websocket.accept()
    while True:
        input_text = await websocket.receive_text()
        autocomplete_text = text_gen.generate_text(input_text)
        autocomplete_text = re.sub(r"[([{})]]", "", autocomplete_text)
        autocomplete_text = autocomplete_text.split()
        autocomplete_text = autocomplete_text[0:2]
        resp = req.get('www.description_url_search_='+input_text+'')
        datajson = resp.json()
        with open('/home/user/backup/AutoComplete/frontend/src/data.json', 'w', encoding='utf-8') as f:
            json.dump(datajson, f, ensure_ascii=False, indent=4)
        await websocket.send_text(' '.join(autocomplete_text))

File App.vue (frontend):

<template>
  <div class="main-container">
    <h1 style="color:#0072c6;">Title</h1>
    <p style="text-align:center; color:#0072c6;">
      Version 0.1
      <br>
    </p>
    <Autocomplete />
    <br>
  </div>
  <div style="color:#0072c6;">
    <JsonArq />
  </div>
  <div style="text-align:center;">
    <img src="./components/logo-1536.png" width=250 height=200 alt="Logo" >
  </div>
</template>

<script>
import Autocomplete from './components/Autocomplete.vue'
import JsonArq from './components/EstepeJSON.vue'
export default {
  name: 'App',
  components: {
    Autocomplete, 
    JsonArq: JsonArq
  }
}
</script>

<style>

  .main-container {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    font-family: 'Fredoka', sans-serif;
  }

  h1 {
    font-size: 3rem;
  }

  @import url('https://fonts.googleapis.com/css2?family=Fredoka&display=swap');
</style>

Autocomplete.vue file in the components directory:

<template>
<div class="pad-container">
  <div tabindex="1" @focus="setCaret" class="autocomplete-container">
    <span @input="sendText" @keypress="preventInput" ref="editbar" class="editable" contenteditable="true"></span>
    <span class="placeholder" contenteditable="false">{{autoComplete}}</span>    
  </div>
</div>

</template>

<script>
export default {
  name: 'Autocomplete',
  data: function() {
    return {
      autoComplete: "",
      maxChars: 75,
      connection: null
    }
  },
  mounted() {
    const url = "ws://localhost:8000/"
    this.connection = new WebSocket(url);
    this.connection.onopen = () => console.log("connection established");
    this.connection.onmessage = this.receiveText;
  },
  methods: {
    setCaret() {
      const range= document.createRange()
      const sel = window.getSelection();
      const parentNode = this.$refs.editbar;

      if (parentNode.firstChild == undefined) {
        const emptyNode = document.createTextNode("");
        parentNode.appendChild(emptyNode);
      }

      range.setStartAfter(this.$refs.editbar.firstChild);
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    },
    preventInput(event) {
      let prevent = false;      

      // handles capital letters, numbers, and punctuations input
      if (event.key == event.key.toUpperCase()) {
        prevent = true;
      }

      // exempt spacebar input
      if (event.code == "Space") {
        prevent = false;
      }

      // handle input overflow
      const nChars = this.$refs.editbar.textContent.length;
      if (nChars >= this.maxChars) {
        prevent = true;
      }

      if (prevent == true) {
        event.preventDefault();
      }
    },
    sendText() {
      const inputText = this.$refs.editbar.textContent;
      this.connection.send(inputText);
    },
    receiveText(event) {
      this.autoComplete = event.data;
    }
  }
}
</script>


EstepeJSON.ue file in the components directory:

<template>
  <div width="80%" v-for="regList in myJson" :key="regList" class="container">
    <table>
        <thead>
          <tr>
            <th>Documento</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="countryList in regList[2]" :key="countryList">
            <td style="visibility: visible">{{ countryList}}</td>
          </tr>
        </tbody>
      </table>
    </div>

  <link
    rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"
  />
</template>

<script>
import json from "@/data.json";

export default {
  name: "EstepeJson",
  data() {
    return {
      myJson: json,
    };
  },
};
</script>

Example of the JSON returned by the URL:

[
{
"Title": "SOFT-STARTER", 
"Cod": "Produto: 15775931", 
"Description": "A soft-starter SSW7000 permite o controle de partida/parada e proteção de motores.", 
"Technical_characteristics": ["Corrente nominal", "600 A", "Tensão nominal", "4,16 kV", "Tensão auxiliar", "200-240 V", "Grau de proteção", "IP41", "Certificação", "CE"]
},
{
"Title": "SOFT-STARTER SSW", 
"Cod": "Produto: 14223395", 
"Description": "A soft-starter SSW7000 permite o controle de partida/parada e proteção de motores de indução trifásicos de média tensão.", 
"Technical_characteristics": ["Corrente nominal", "125 A", "Tensão nominal", "6,9 kV", "Tensão auxiliar", "200-240 V", "Grau de proteção", "IP54/NEMA12", "Certificação", "CE"]
}
]
Asked By: CH97

||

Answers:

Just convert your data to a json string with json.dumps(mydata)

Answered By: Charles Yang

First, instead of using Python requests library (which would block the event loop, see this answer for more details), I would highly suggest using httpx, which offers an async API as well. Have a look at this answer and this answer for more details and working examples.

Second, to send data as JSON, you need to use await websocket.send_json(data), as explained in Starlette documentation. As shown in Starlette’s websockets source code, Starlette/FastAPI will use text = json.dumps(data) (to serialise the data you passed) when calling send_json() function. Hence, you need to pass a Python dict object. Similar to requests, in httpx you can call the .json() method on the response object to get the response data as a dict object, and then pass the data to send_json() method.

Working Example

from fastapi import FastAPI, WebSocket
from fastapi.responses import HTMLResponse
import httpx

app = FastAPI()


html = """
<!DOCTYPE html>
<html>
    <head>
        <title>Chat</title>
    </head>
    <body>
        <h1>WebSocket Chat</h1>
        <form action="" onsubmit="sendMessage(event)">
            <input type="text" id="messageText" autocomplete="off"/>
            <button>Send</button>
        </form>
        <ul id='messages'>
        </ul>
        <script>
            var ws = new WebSocket("ws://localhost:8000/ws");
            ws.onmessage = function(event) {
                var messages = document.getElementById('messages')
                var message = document.createElement('li')
                var content = document.createTextNode(event.data)
                message.appendChild(content)
                messages.appendChild(message)
            };
            function sendMessage(event) {
                var input = document.getElementById("messageText")
                ws.send(input.value)
                input.value = ''
                event.preventDefault()
            }
        </script>
    </body>
</html>
"""


@app.on_event("startup")
async def startup_event():
    app.state.client = httpx.AsyncClient()
    

@app.on_event('shutdown')
async def shutdown_event():
    await app.state.client.aclose()


@app.get('/')
async def get():
    return HTMLResponse(html)
    

@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_text()
        # here, use httpx to issue a request, as demonstrated in the linked answers above
        r = await app.state.client.get('http://httpbin.org/get')
        await websocket.send_json(r.json())
Answered By: Chris

using @Chris’ tips on HTTP and after some research I managed to solve my problem. Below is the resolution.

In my backend FastAPI file I implemented HTTPX async (tip from @Chris). And after returning the JSON, I took the autocomplete term and added it to the first position of the JSON. Thus returning to Vue (frontend) a JSON with autocomplete and HTTPX data.

File FastAPI:

async def predict_question(websocket: WebSocket):
 await manager.connect(websocket)
 input_text = await websocket.receive_text()
 if not input_text:
  await manager.send_personal_message(json.dumps([]), websocket)
 else:
  autocomplete_text = text_gen.generate_text(input_text)
  autocomplete_text = re.sub(r"[([{})]]", "", autocomplete_text)
  autocomplete_text = autocomplete_text.split()
  autocomplete_text = autocomplete_text[0:2]
  resp = client.build_request("GET", 'www.description_url_search_='+input_text+'')
  r = await client.send(resp)
  datajson = r.json()
  datajson.insert(0, ' '.join(autocomplete_text))
  await manager.send_personal_message(json.dumps(datajson), websocket)

In the Autocomplete.vue file I made small changes.
First I merged the EstepeJson.vue file into Autocomplete.vue, especially the json reading part in the html.
Second, in the data: function(){} I added one more object, called myJson: [].

Third, in the receiveText method I changed the way to receive data from the websocket. Since now I have JSON.parse to convert event.data to JSON. Then I use the shift method to take the first position in the json and remove this data from the file. And finally, return the json to the myjson variable.

File Autocomplete.vue:

<template>
<div class="pad-container">
  <div tabindex="1" @focus="setCaret" class="autocomplete-container">
    <span @input="sendText" @keypress="preventInput" ref="editbar" class="editable" contenteditable="true"></span>
    <span class="placeholder" data-ondeleteId="#editx" contenteditable="false">{{autoComplete}}</span>    
  </div>
</div>
<div v-for="regList in myJson" :key="regList" class="container" >
  <table>
    <thead>
      <tr>
        <th>Documento</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="countryList in regList[2]" :key="countryList">
        <td style="visibility: visible">{{ countryList}}</td>
      </tr>
    </tbody>
  </table>
  </div>
</template>

<script>
...
data: function() {
    return {
      autoComplete: "",
      maxChars: 75,
      connection: null, 
      myJson: []
    }
  },
.....
...
    receiveText(event) {
      let result = JSON.parse(event.data)
      this.autoComplete = result.shift();
      this.myJson = result
    }
</script>
Answered By: CH97